From 88de3e2773e8ff7d9178e4aa659e78d9fe500c6c Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 15 Jul 2025 01:01:57 +0300 Subject: [PATCH 001/197] =?UTF-8?q?=D7=A8=D7=95=D7=97=D7=91=20=D7=A1=D7=A8?= =?UTF-8?q?=D7=92=D7=9C=20=D7=A6=D7=93=20=D7=9E=D7=95=D7=AA=D7=90=D7=9D=20?= =?UTF-8?q?=D7=90=D7=99=D7=A9=D7=99=D7=AA=20-=20=D7=94=D7=AA=D7=97=D7=9C?= =?UTF-8?q?=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pdf_book/pdf_book_screen.dart | 48 ++++++++++++++++++++++-- lib/settings/settings_bloc.dart | 10 +++++ lib/settings/settings_event.dart | 9 +++++ lib/settings/settings_repository.dart | 8 ++++ lib/settings/settings_state.dart | 6 +++ lib/text_book/view/text_book_screen.dart | 36 +++++++++++++++++- 6 files changed, 112 insertions(+), 5 deletions(-) diff --git a/lib/pdf_book/pdf_book_screen.dart b/lib/pdf_book/pdf_book_screen.dart index 221be5ced..b7bf56222 100644 --- a/lib/pdf_book/pdf_book_screen.dart +++ b/lib/pdf_book/pdf_book_screen.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'dart:math'; +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:otzaria/bookmarks/bloc/bookmark_bloc.dart'; @@ -7,6 +8,8 @@ import 'package:otzaria/data/repository/data_repository.dart'; import 'package:otzaria/models/books.dart'; import 'package:otzaria/pdf_book/pdf_page_number_dispaly.dart'; import 'package:otzaria/settings/settings_bloc.dart'; +import 'package:otzaria/settings/settings_event.dart'; +import 'package:otzaria/settings/settings_state.dart'; import 'package:otzaria/tabs/models/pdf_tab.dart'; import 'package:otzaria/utils/open_book.dart'; import 'package:otzaria/utils/ref_helper.dart'; @@ -45,6 +48,8 @@ class _PdfBookScreenState extends State int _currentLeftPaneTabIndex = 0; final FocusNode _searchFieldFocusNode = FocusNode(); final FocusNode _navigationFieldFocusNode = FocusNode(); + late final ValueNotifier _sidebarWidth; + late final StreamSubscription _settingsSub; Future _runInitialSearchIfNeeded() async { final controller = widget.tab.searchController; @@ -121,6 +126,14 @@ class _PdfBookScreenState extends State // 3. שמור את הבקר בטאב כדי ששאר חלקי האפליקציה יוכלו להשתמש בו. widget.tab.pdfViewerController = pdfController; + _sidebarWidth = ValueNotifier( + Settings.getValue('key-sidebar-width', defaultValue: 300)!); + + _settingsSub = + context.read().stream.listen((state) { + _sidebarWidth.value = state.sidebarWidth; + }); + // -- שאר הקוד של initState נשאר כמעט זהה -- pdfController.addListener(_onPdfViewerControllerUpdate); @@ -188,6 +201,8 @@ class _PdfBookScreenState extends State _leftPaneTabController?.dispose(); _searchFieldFocusNode.dispose(); _navigationFieldFocusNode.dispose(); + _sidebarWidth.dispose(); + _settingsSub.cancel(); super.dispose(); } @@ -321,6 +336,28 @@ class _PdfBookScreenState extends State body: Row( children: [ _buildLeftPane(), + ValueListenableBuilder( + valueListenable: widget.tab.showLeftPane, + builder: (context, show, child) => show + ? MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragUpdate: (details) { + final newWidth = (_sidebarWidth.value + + details.delta.dx) + .clamp(200.0, 600.0); + _sidebarWidth.value = newWidth; + }, + onHorizontalDragEnd: (_) { + context.read().add( + UpdateSidebarWidth(_sidebarWidth.value)); + }, + child: const VerticalDivider(width: 4), + ), + ) + : const SizedBox.shrink(), + ), Expanded( child: NotificationListener( onNotification: (notification) { @@ -451,9 +488,14 @@ class _PdfBookScreenState extends State duration: const Duration(milliseconds: 300), child: ValueListenableBuilder( valueListenable: widget.tab.showLeftPane, - builder: (context, showLeftPane, child) => SizedBox( - width: showLeftPane ? 300 : 0, - child: child!, + builder: (context, showLeftPane, child) => + ValueListenableBuilder( + valueListenable: _sidebarWidth, + builder: (context, width, child2) => SizedBox( + width: showLeftPane ? width : 0, + child: child2!, + ), + child: child, ), child: Container( color: Theme.of(context).colorScheme.surface, diff --git a/lib/settings/settings_bloc.dart b/lib/settings/settings_bloc.dart index 42382d6e5..83f056436 100644 --- a/lib/settings/settings_bloc.dart +++ b/lib/settings/settings_bloc.dart @@ -26,6 +26,7 @@ class SettingsBloc extends Bloc { on(_onUpdateRemoveNikudFromTanach); on(_onUpdateDefaultSidebarOpen); on(_onUpdatePinSidebar); + on(_onUpdateSidebarWidth); } Future _onLoadSettings( @@ -50,6 +51,7 @@ class SettingsBloc extends Bloc { removeNikudFromTanach: settings['removeNikudFromTanach'], defaultSidebarOpen: settings['defaultSidebarOpen'], pinSidebar: settings['pinSidebar'], + sidebarWidth: settings['sidebarWidth'], )); } @@ -179,4 +181,12 @@ class SettingsBloc extends Bloc { await _repository.updatePinSidebar(event.pinSidebar); emit(state.copyWith(pinSidebar: event.pinSidebar)); } + + Future _onUpdateSidebarWidth( + UpdateSidebarWidth event, + Emitter emit, + ) async { + await _repository.updateSidebarWidth(event.sidebarWidth); + emit(state.copyWith(sidebarWidth: event.sidebarWidth)); + } } diff --git a/lib/settings/settings_event.dart b/lib/settings/settings_event.dart index 596a7202b..a1e7b3f36 100644 --- a/lib/settings/settings_event.dart +++ b/lib/settings/settings_event.dart @@ -152,4 +152,13 @@ class UpdatePinSidebar extends SettingsEvent { @override List get props => [pinSidebar]; +} + +class UpdateSidebarWidth extends SettingsEvent { + final double sidebarWidth; + + const UpdateSidebarWidth(this.sidebarWidth); + + @override + List get props => [sidebarWidth]; } \ No newline at end of file diff --git a/lib/settings/settings_repository.dart b/lib/settings/settings_repository.dart index 6c406b413..d7a7efef6 100644 --- a/lib/settings/settings_repository.dart +++ b/lib/settings/settings_repository.dart @@ -19,6 +19,7 @@ class SettingsRepository { static const String keyRemoveNikudFromTanach = 'key-remove-nikud-tanach'; static const String keyDefaultSidebarOpen = 'key-default-sidebar-open'; static const String keyPinSidebar = 'key-pin-sidebar'; + static const String keySidebarWidth = 'key-sidebar-width'; final SettingsWrapper _settings; @@ -85,6 +86,8 @@ class SettingsRepository { keyPinSidebar, defaultValue: false, ), + 'sidebarWidth': + _settings.getValue(keySidebarWidth, defaultValue: 300), }; } @@ -150,6 +153,10 @@ class SettingsRepository { await _settings.setValue(keyPinSidebar, value); } + Future updateSidebarWidth(double value) async { + await _settings.setValue(keySidebarWidth, value); + } + /// Initialize default settings to disk if this is the first app launch Future _initializeDefaultsIfNeeded() async { if (await _checkIfDefaultsNeeded()) { @@ -181,6 +188,7 @@ class SettingsRepository { await _settings.setValue(keyRemoveNikudFromTanach, false); await _settings.setValue(keyDefaultSidebarOpen, false); await _settings.setValue(keyPinSidebar, false); + await _settings.setValue(keySidebarWidth, 300.0); // Mark as initialized await _settings.setValue('settings_initialized', true); diff --git a/lib/settings/settings_state.dart b/lib/settings/settings_state.dart index d8c64b435..26813af24 100644 --- a/lib/settings/settings_state.dart +++ b/lib/settings/settings_state.dart @@ -18,6 +18,7 @@ class SettingsState extends Equatable { final bool removeNikudFromTanach; final bool defaultSidebarOpen; final bool pinSidebar; + final double sidebarWidth; const SettingsState({ required this.isDarkMode, @@ -36,6 +37,7 @@ class SettingsState extends Equatable { required this.removeNikudFromTanach, required this.defaultSidebarOpen, required this.pinSidebar, + required this.sidebarWidth, }); factory SettingsState.initial() { @@ -56,6 +58,7 @@ class SettingsState extends Equatable { removeNikudFromTanach: false, defaultSidebarOpen: false, pinSidebar: false, + sidebarWidth: 300, ); } @@ -76,6 +79,7 @@ class SettingsState extends Equatable { bool? removeNikudFromTanach, bool? defaultSidebarOpen, bool? pinSidebar, + double? sidebarWidth, }) { return SettingsState( isDarkMode: isDarkMode ?? this.isDarkMode, @@ -95,6 +99,7 @@ class SettingsState extends Equatable { removeNikudFromTanach ?? this.removeNikudFromTanach, defaultSidebarOpen: defaultSidebarOpen ?? this.defaultSidebarOpen, pinSidebar: pinSidebar ?? this.pinSidebar, + sidebarWidth: sidebarWidth ?? this.sidebarWidth, ); } @@ -116,5 +121,6 @@ class SettingsState extends Equatable { removeNikudFromTanach, defaultSidebarOpen, pinSidebar, + sidebarWidth, ]; } diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index 67aa08492..8286de241 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'dart:math'; import 'dart:convert'; +import 'dart:async'; import 'package:csv/csv.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -8,6 +9,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:otzaria/bookmarks/bloc/bookmark_bloc.dart'; import 'package:otzaria/settings/settings_bloc.dart'; +import 'package:otzaria/settings/settings_event.dart' hide UpdateFontSize; import 'package:otzaria/settings/settings_state.dart'; import 'package:otzaria/tabs/models/text_tab.dart'; import 'package:otzaria/text_book/bloc/text_book_bloc.dart'; @@ -48,6 +50,8 @@ class _TextBookViewerBlocState extends State final FocusNode textSearchFocusNode = FocusNode(); final FocusNode navigationSearchFocusNode = FocusNode(); late TabController tabController; + late final ValueNotifier _sidebarWidth; + late final StreamSubscription _settingsSub; String? encodeQueryParameters(Map params) { return params.entries @@ -71,7 +75,14 @@ class _TextBookViewerBlocState extends State length: 4, // יש 4 לשוניות vsync: this, initialIndex: initialIndex, - ); + ); + + _sidebarWidth = ValueNotifier( + Settings.getValue('key-sidebar-width', defaultValue: 300)!); + _settingsSub = context + .read() + .stream + .listen((state) => _sidebarWidth.value = state.sidebarWidth); } @override @@ -79,6 +90,8 @@ class _TextBookViewerBlocState extends State tabController.dispose(); textSearchFocusNode.dispose(); navigationSearchFocusNode.dispose(); + _sidebarWidth.dispose(); + _settingsSub.cancel(); super.dispose(); } @@ -699,6 +712,24 @@ $selectedText : Row( children: [ _buildTabBar(state), + if (state.showLeftPane) + MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragUpdate: (details) { + final newWidth = + (_sidebarWidth.value + details.delta.dx).clamp(200.0, 600.0); + _sidebarWidth.value = newWidth; + }, + onHorizontalDragEnd: (_) { + context + .read() + .add(UpdateSidebarWidth(_sidebarWidth.value)); + }, + child: const VerticalDivider(width: 4), + ), + ), Expanded(child: _buildHTMLViewer(state)), ], ), @@ -793,7 +824,8 @@ $selectedText return AnimatedSize( duration: const Duration(milliseconds: 300), child: SizedBox( - width: state.showLeftPane ? 400 : 0, + // קובעים את הרוחב כדי שהאנימציה תפעל על שינוי הרוחב + width: state.showLeftPane ? _sidebarWidth.value : 0, child: Padding( padding: const EdgeInsets.fromLTRB(1, 0, 4, 0), child: Column( From 5d651cd4b743e60e98de614b2436a4b9f04973a1 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 15 Jul 2025 15:36:46 +0300 Subject: [PATCH 002/197] FIX --- test/unit/settings/settings_bloc_test.dart | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/test/unit/settings/settings_bloc_test.dart b/test/unit/settings/settings_bloc_test.dart index c515be0d7..43e8d1122 100644 --- a/test/unit/settings/settings_bloc_test.dart +++ b/test/unit/settings/settings_bloc_test.dart @@ -43,6 +43,7 @@ void main() { 'removeNikudFromTanach': true, 'defaultSidebarOpen': true, 'pinSidebar': true, + 'sidebarWidth': 300.0, }; blocTest( @@ -72,6 +73,7 @@ void main() { mockSettings['removeNikudFromTanach'] as bool, defaultSidebarOpen: mockSettings['defaultSidebarOpen'] as bool, pinSidebar: mockSettings['pinSidebar'] as bool, + sidebarWidth: mockSettings['sidebarWidth'] as double, ), ], verify: (_) { @@ -197,5 +199,20 @@ void main() { }, ); }); + group('UpdateSidebarWidth', () { + const newWidth = 350.0; + + blocTest( + 'emits updated state when UpdateSidebarWidth is added', + build: () => settingsBloc, + act: (bloc) => bloc.add(const UpdateSidebarWidth(newWidth)), + expect: () => [ + settingsBloc.state.copyWith(sidebarWidth: newWidth), + ], + verify: (_) { + verify(mockRepository.updateSidebarWidth(newWidth)).called(1); + }, + ); + }); }); -} +} \ No newline at end of file From 22edbcceee2449e23b91c8cd47a390de22050dad Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 15 Jul 2025 15:37:23 +0300 Subject: [PATCH 003/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=9B?= =?UTF-8?q?=D7=A4=D7=AA=D7=95=D7=A8=20=D7=9C=D7=90=D7=99=D7=A4=D7=95=D7=A1?= =?UTF-8?q?=20=D7=94=D7=94=D7=92=D7=93=D7=A8=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/settings/settings_screen.dart | 324 ++++++++++++++++++------------ 1 file changed, 194 insertions(+), 130 deletions(-) diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index a3d1ffac1..ef7dd7e46 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -47,7 +47,7 @@ class _MySettingsScreenState extends State List rows = []; for (int i = 0; i < children.length; i += columns) { List rowChildren = []; - + for (int j = 0; j < columns; j++) { if (i + j < children.length) { rowChildren.add(Expanded(child: children[i + j])); @@ -60,12 +60,13 @@ class _MySettingsScreenState extends State } } } - + // עוטפים את ה-Row ב-IntrinsicHeight כדי להבטיח גובה אחיד לקו המפריד rows.add( IntrinsicHeight( child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, // גורם לילדים להימתח + crossAxisAlignment: + CrossAxisAlignment.stretch, // גורם לילדים להימתח children: rowChildren, ), ), @@ -146,7 +147,7 @@ class _MySettingsScreenState extends State .read() .add(UpdateDarkMode(value)); }, - activeColor: Theme.of(context).cardColor, + activeColor: Theme.of(context).cardColor, ), ColorPickerSettingsTile( title: 'צבע בסיס', @@ -197,38 +198,46 @@ class _MySettingsScreenState extends State .add(UpdateFontFamily(value)); }, ), -SettingsContainer( - children: [ - Padding( - padding: const EdgeInsets.only(top: 8, bottom: 4, left: 16, right: 16), - child: Row( - children: [ - const Icon(Icons.horizontal_distribute), - const SizedBox(width: 16), - Text( - 'רוחב השוליים בצידי הטקסט', - style: Theme.of(context).textTheme.titleLarge?.copyWith(fontSize: 16), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: MarginSliderPreview( - initial: Settings.getValue('key-padding-size', defaultValue: state.paddingSize)!, - min: 0, - max: 500, - step: 2, - onChanged: (v) { - // הלוגיקה לשמירת הערך נשארת זהה ומדויקת - Settings.setValue('key-padding-size', v); - context.read().add(UpdatePaddingSize(v)); - setState(() {}); - }, - ), - ), - ], -), + SettingsContainer( + children: [ + Padding( + padding: const EdgeInsets.only( + top: 8, bottom: 4, left: 16, right: 16), + child: Row( + children: [ + const Icon(Icons.horizontal_distribute), + const SizedBox(width: 16), + Text( + 'רוחב השוליים בצידי הטקסט', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontSize: 16), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: MarginSliderPreview( + initial: Settings.getValue( + 'key-padding-size', + defaultValue: state.paddingSize)!, + min: 0, + max: 500, + step: 2, + onChanged: (v) { + // הלוגיקה לשמירת הערך נשארת זהה ומדויקת + Settings.setValue('key-padding-size', v); + context + .read() + .add(UpdatePaddingSize(v)); + setState(() {}); + }, + ), + ), + ], + ), ], ), Platform.isAndroid @@ -285,12 +294,12 @@ SettingsContainer( ], ), SettingsGroup( - title: 'הגדרות ממשק', - titleAlignment: Alignment.centerRight, - titleTextStyle: const TextStyle(fontSize: 25), - children: [ - _buildColumns(2, [ - SwitchSettingsTile( + title: 'הגדרות ממשק', + titleAlignment: Alignment.centerRight, + titleTextStyle: const TextStyle(fontSize: 25), + children: [ + _buildColumns(2, [ + SwitchSettingsTile( settingKey: 'key-replace-holy-names', title: 'הסתרת שמות הקודש', enabledLabel: 'השמות הקדושים יוחלפו מפאת קדושתם', @@ -302,7 +311,7 @@ SettingsContainer( .read() .add(UpdateReplaceHolyNames(value)); }, - activeColor: Theme.of(context).cardColor, + activeColor: Theme.of(context).cardColor, ), SwitchSettingsTile( settingKey: 'key-show-teamim', @@ -316,7 +325,7 @@ SettingsContainer( .read() .add(UpdateShowTeamim(value)); }, - activeColor: Theme.of(context).cardColor, + activeColor: Theme.of(context).cardColor, ), SwitchSettingsTile( settingKey: 'key-default-nikud', @@ -330,7 +339,7 @@ SettingsContainer( .read() .add(UpdateDefaultRemoveNikud(value)); }, - activeColor: Theme.of(context).cardColor, + activeColor: Theme.of(context).cardColor, ), SwitchSettingsTile( settingKey: 'key-remove-nikud-tanach', @@ -344,7 +353,7 @@ SettingsContainer( .read() .add(UpdateRemoveNikudFromTanach(value)); }, - activeColor: Theme.of(context).cardColor, + activeColor: Theme.of(context).cardColor, ), SwitchSettingsTile( settingKey: 'key-splited-view', @@ -353,7 +362,7 @@ SettingsContainer( disabledLabel: 'המפרשים יוצגו מתחת הטקסט', leading: Icon(Icons.vertical_split), defaultValue: false, - activeColor: Theme.of(context).cardColor, + activeColor: Theme.of(context).cardColor, ), SwitchSettingsTile( settingKey: 'key-default-sidebar-open', @@ -368,7 +377,7 @@ SettingsContainer( .read() .add(UpdateDefaultSidebarOpen(value)); }, - activeColor: Theme.of(context).cardColor, + activeColor: Theme.of(context).cardColor, ), SwitchSettingsTile( settingKey: 'key-pin-sidebar', @@ -387,7 +396,7 @@ SettingsContainer( .add(const UpdateDefaultSidebarOpen(true)); } }, - activeColor: Theme.of(context).cardColor, + activeColor: Theme.of(context).cardColor, ), SwitchSettingsTile( settingKey: 'key-use-fast-search', @@ -402,7 +411,7 @@ SettingsContainer( .read() .add(UpdateUseFastSearch(value)); }, - activeColor: Theme.of(context).cardColor, + activeColor: Theme.of(context).cardColor, ), SwitchSettingsTile( settingKey: 'key-show-external-books', @@ -422,7 +431,7 @@ SettingsContainer( .read() .add(UpdateShowOtzarHachochma(value)); }, - activeColor: Theme.of(context).cardColor, + activeColor: Theme.of(context).cardColor, ), ]), ], @@ -439,7 +448,7 @@ SettingsContainer( defaultValue: true, enabledLabel: 'מאגר הספרים יתעדכן אוטומטית', disabledLabel: 'מאגר הספרים לא יתעדכן אוטומטית.', - activeColor: Theme.of(context).cardColor, + activeColor: Theme.of(context).cardColor, ), SwitchSettingsTile( title: 'סנכרון ספרי דיקטה', @@ -448,7 +457,7 @@ SettingsContainer( defaultValue: false, enabledLabel: 'ספרי דיקטה יסונכרנו יחד עם הספרייה', disabledLabel: 'לא יסונכרנו ספרי דיקטה', - activeColor: Theme.of(context).cardColor, + activeColor: Theme.of(context).cardColor, ), _buildColumns(2, [ BlocBuilder( @@ -466,21 +475,21 @@ SettingsContainer( builder: (context) => AlertDialog( content: const Text( 'האם לעצור את תהליך יצירת האינדקס?'), - actions: [ - TextButton( - child: const Text('ביטול'), - onPressed: () { - Navigator.pop(context, false); - }, - ), - TextButton( - child: const Text('אישור'), - onPressed: () { - Navigator.pop(context, true); - }, - ), - ], - )); + actions: [ + TextButton( + child: const Text('ביטול'), + onPressed: () { + Navigator.pop(context, false); + }, + ), + TextButton( + child: const Text('אישור'), + onPressed: () { + Navigator.pop(context, true); + }, + ), + ], + )); if (result == true) { context .read() @@ -489,28 +498,30 @@ SettingsContainer( } } else { final result = await showDialog( - context: context, - builder: (context) => AlertDialog( - content: - const Text('האם לאפס את האינדקס?'), - actions: [ - TextButton( - child: const Text('ביטול'), - onPressed: () { - Navigator.pop(context, false); - }, - ), - TextButton( - child: const Text('אישור'), - onPressed: () { - Navigator.pop(context, true); - }, - ), - ], - )); + context: context, + builder: (context) => AlertDialog( + content: const Text( + 'האם לאפס את האינדקס?'), + actions: [ + TextButton( + child: const Text('ביטול'), + onPressed: () { + Navigator.pop(context, false); + }, + ), + TextButton( + child: const Text('אישור'), + onPressed: () { + Navigator.pop(context, true); + }, + ), + ], + )); if (result == true) { //reset the index - context.read().add(ClearIndex()); + context + .read() + .add(ClearIndex()); final library = context.read().state.library; if (library != null) { @@ -525,25 +536,25 @@ SettingsContainer( }, ), SwitchSettingsTile( - title: 'עדכון אינדקס', - leading: const Icon(Icons.sync), - settingKey: 'key-auto-index-update', - defaultValue: state.autoUpdateIndex, - enabledLabel: 'אינדקס החיפוש יתעדכן אוטומטית', - disabledLabel: 'אינדקס החיפוש לא יתעדכן אוטומטית', - onChange: (value) async { - context - .read() - .add(UpdateAutoUpdateIndex(value)); - if (value) { - final library = DataRepository.instance.library; + title: 'עדכון אינדקס', + leading: const Icon(Icons.sync), + settingKey: 'key-auto-index-update', + defaultValue: state.autoUpdateIndex, + enabledLabel: 'אינדקס החיפוש יתעדכן אוטומטית', + disabledLabel: 'אינדקס החיפוש לא יתעדכן אוטומטית', + onChange: (value) async { context - .read() - .add(StartIndexing(await library)); - } - }, - activeColor: Theme.of(context).cardColor, - ), + .read() + .add(UpdateAutoUpdateIndex(value)); + if (value) { + final library = DataRepository.instance.library; + context + .read() + .add(StartIndexing(await library)); + } + }, + activeColor: Theme.of(context).cardColor, + ), ]), if (!(Platform.isAndroid || Platform.isIOS)) _buildColumns(2, [ @@ -587,7 +598,52 @@ SettingsContainer( 'קבלת עדכונים על גרסאות בדיקה, ייתכנו באגים וחוסר יציבות', disabledLabel: 'קבלת עדכונים על גרסאות יציבות בלבד', leading: Icon(Icons.bug_report), - activeColor: Theme.of(context).cardColor, + activeColor: Theme.of(context).cardColor, + ), + SimpleSettingsTile( + title: 'איפוס הגדרות', + subtitle: + 'פעולה זו תמחק את כל ההגדרות ותחזיר את התוכנה למצב התחלתי', + leading: const Icon(Icons.restore, color: Colors.red), + onTap: () async { + // דיאלוג לאישור המשתמש + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('איפוס הגדרות?'), + content: const Text( + 'כל ההגדרות האישיות שלך ימחקו. פעולה זו אינה הפיכה. האם להמשיך?'), + actions: [ + TextButton( + onPressed: () => + Navigator.pop(context, false), + child: const Text('ביטול')), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('אישור', + style: TextStyle(color: Colors.red))), + ], + ), + ); + + if (confirmed == true && context.mounted) { + Settings.clearCache(); + + // הודעה למשתמש שנדרשת הפעלה מחדש + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: const Text('ההגדרות אופסו'), + content: const Text( + 'יש לסגור ולהפעיל מחדש את התוכנה כדי שהשינויים יכנסו לתוקף.'), + actions: [ + TextButton( + onPressed: () => exit(0), + child: const Text('סגור את התוכנה')) + ])); + } + }, ), FutureBuilder( future: PackageInfo.fromPlatform(), @@ -679,13 +735,14 @@ class _MarginSliderPreviewState extends State { return GestureDetector( onPanUpdate: (details) { setState(() { - double newMargin = isLeft - ? _margin + details.delta.dx - : _margin - details.delta.dx; - + double newMargin = + isLeft ? _margin + details.delta.dx : _margin - details.delta.dx; + // מגבילים את המרחב לפי רוחב הווידג'ט והגדרות המשתמש final maxWidth = (context.findRenderObject() as RenderBox).size.width; - _margin = newMargin.clamp(widget.min, maxWidth / 2).clamp(widget.min, widget.max); + _margin = newMargin + .clamp(widget.min, maxWidth / 2) + .clamp(widget.min, widget.max); }); widget.onChanged(_margin); }, @@ -697,14 +754,14 @@ class _MarginSliderPreviewState extends State { color: Colors.transparent, // אזור הלחיצה שקוף alignment: Alignment.center, child: Container( - // --- שינוי 1: עיצוב הידית מחדש --- - width: thumbSize, - height: thumbSize, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, // צבע ראשי - shape: BoxShape.circle, - boxShadow: kElevationToShadow[1], // הצללה סטנדרטית של פלאטר - ), + // --- שינוי 1: עיצוב הידית מחדש --- + width: thumbSize, + height: thumbSize, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, // צבע ראשי + shape: BoxShape.circle, + boxShadow: kElevationToShadow[1], // הצללה סטנדרטית של פלאטר + ), ), ), ); @@ -715,7 +772,8 @@ class _MarginSliderPreviewState extends State { return LayoutBuilder( builder: (context, constraints) { final fullWidth = constraints.maxWidth; - final previewTextWidth = (fullWidth - 2 * _margin).clamp(0.0, fullWidth); + final previewTextWidth = + (fullWidth - 2 * _margin).clamp(0.0, fullWidth); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -747,38 +805,44 @@ class _MarginSliderPreviewState extends State { ), ), ), - + // הצגת הערך מעל הידית (רק בזמן תצוגה) if (_showPreview) Positioned( left: _margin - 10, top: 0, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primary, borderRadius: BorderRadius.circular(8), ), child: Text( _margin.toStringAsFixed(0), - style: TextStyle(color: Theme.of(context).colorScheme.onPrimary, fontSize: 12), + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + fontSize: 12), ), ), ), - + if (_showPreview) Positioned( right: _margin - 10, top: 0, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primary, borderRadius: BorderRadius.circular(8), ), child: Text( _margin.toStringAsFixed(0), - style: TextStyle(color: Theme.of(context).colorScheme.onPrimary, fontSize: 12), + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + fontSize: 12), ), ), ), @@ -788,7 +852,7 @@ class _MarginSliderPreviewState extends State { left: _margin - (thumbSize), child: _buildThumb(isLeft: true), ), - + // הכפתור הימני Positioned( right: _margin - (thumbSize), @@ -797,7 +861,7 @@ class _MarginSliderPreviewState extends State { ], ), ), - + const SizedBox(height: 8), // ------------ תצוגה מקדימה עם אנימציה חלקה ------------- @@ -838,4 +902,4 @@ class _MarginSliderPreviewState extends State { }, ); } -} \ No newline at end of file +} From 1261558f1419e91d1b4099c5eb9bb7b026628b05 Mon Sep 17 00:00:00 2001 From: Y-Ploni <7353755@gmail.com> Date: Tue, 15 Jul 2025 23:04:49 +0300 Subject: [PATCH 004/197] Add group show/hide buttons for commentators --- .../combined_view/combined_book_screen.dart | 54 +++++++--- .../view/commentators_list_screen.dart | 99 ++++++++++++++++++- .../view/splited_view/simple_book_view.dart | 53 +++++++--- 3 files changed, 172 insertions(+), 34 deletions(-) diff --git a/lib/text_book/view/combined_view/combined_book_screen.dart b/lib/text_book/view/combined_view/combined_book_screen.dart index f3e6b29d7..b9f1a8cef 100644 --- a/lib/text_book/view/combined_view/combined_book_screen.dart +++ b/lib/text_book/view/combined_view/combined_book_screen.dart @@ -45,29 +45,51 @@ class _CombinedViewState extends State { final GlobalKey _selectionKey = GlobalKey(); - /// helper קטן שמחזיר רשימת MenuEntry מקבוצה אחת + /// helper קטן שמחזיר רשימת MenuEntry מקבוצה אחת, כולל כפתור הצג/הסתר הכל List> _buildGroup( + String groupName, List? group, TextBookLoaded st, ) { if (group == null || group.isEmpty) return const []; - return group.map((title) { - // בודקים אם הפרשן הנוכחי פעיל - final bool isActive = st.activeCommentators.contains(title); - return ctx.MenuItem( - label: title, - // הוספה: מוסיפים אייקון V אם הפרשן פעיל - icon: isActive ? Icons.check : null, + final bool groupActive = + group.every((title) => st.activeCommentators.contains(title)); + + return [ + ctx.MenuItem( + label: 'הצג את כל ${groupName}', + icon: groupActive ? Icons.check : null, onSelected: () { final current = List.from(st.activeCommentators); - current.contains(title) ? current.remove(title) : current.add(title); - context.read().add(UpdateCommentators(current)); + if (groupActive) { + current.removeWhere(group.contains); + } else { + for (final title in group) { + if (!current.contains(title)) current.add(title); + } + } + context.read().add(UpdateCommentators(current)); }, - ); - }).toList(); + ), + ...group.map((title) { + final bool isActive = st.activeCommentators.contains(title); + return ctx.MenuItem( + label: title, + icon: isActive ? Icons.check : null, + onSelected: () { + final current = List.from(st.activeCommentators); + current.contains(title) + ? current.remove(title) + : current.add(title); + context.read().add(UpdateCommentators(current)); + }, + ); + }), + ]; } + ctx.ContextMenu _buildContextMenu(TextBookLoaded state) { // 1. קבלת מידע על גודל המסך final screenHeight = MediaQuery.of(context).size.height; @@ -112,14 +134,14 @@ class _CombinedViewState extends State { ), const ctx.MenuDivider(), // ראשונים - ..._buildGroup(state.rishonim, state), + ..._buildGroup('ראשונים', state.rishonim, state), // מוסיפים קו הפרדה רק אם יש גם ראשונים וגם אחרונים if (state.rishonim.isNotEmpty && state.acharonim.isNotEmpty) const ctx.MenuDivider(), // אחרונים - ..._buildGroup(state.acharonim, state), + ..._buildGroup('אחרונים', state.acharonim, state), // מוסיפים קו הפרדה רק אם יש גם אחרונים וגם בני זמננו if (state.acharonim.isNotEmpty && @@ -127,7 +149,7 @@ class _CombinedViewState extends State { const ctx.MenuDivider(), // מחברי זמננו - ..._buildGroup(state.modernCommentators, state), + ..._buildGroup('מחברי זמננו', state.modernCommentators, state), // הוסף קו הפרדה רק אם יש קבוצות אחרות וגם פרשנים לא-משויכים if ((state.rishonim.isNotEmpty || @@ -137,7 +159,7 @@ class _CombinedViewState extends State { const ctx.MenuDivider(), // הוסף את רשימת הפרשנים הלא משויכים - ..._buildGroup(ungrouped, state), + ..._buildGroup('שאר מפרשים', ungrouped, state), ], ), ctx.MenuItem.submenu( diff --git a/lib/text_book/view/commentators_list_screen.dart b/lib/text_book/view/commentators_list_screen.dart index f14296423..0b5ab7659 100644 --- a/lib/text_book/view/commentators_list_screen.dart +++ b/lib/text_book/view/commentators_list_screen.dart @@ -20,10 +20,18 @@ class CommentatorsListViewState extends State { TextEditingController searchController = TextEditingController(); List selectedTopics = []; List commentatorsList = []; + List _rishonim = []; + List _acharonim = []; + List _modern = []; + List _ungrouped = []; static const String _rishonimTitle = '__TITLE_RISHONIM__'; static const String _acharonimTitle = '__TITLE_ACHARONim__'; static const String _modernTitle = '__TITLE_MODERN__'; static const String _ungroupedTitle = '__TITLE_UNGROUPED__'; + static const String _rishonimButton = '__BUTTON_RISHONIM__'; + static const String _acharonimButton = '__BUTTON_ACHARONIM__'; + static const String _modernButton = '__BUTTON_MODERN__'; + static const String _ungroupedButton = '__BUTTON_UNGROUPED__'; Future> filterGroup(List group) async { @@ -62,23 +70,32 @@ class CommentatorsListViewState extends State { .where((c) => !alreadyListed.contains(c)) .toList(); final ungrouped = await filterGroup(ungroupedRaw); + + _rishonim = rishonim; + _acharonim = acharonim; + _modern = modern; + _ungrouped = ungrouped; // בניית הרשימה עם כותרות לפני כל קבוצה קיימת final List merged = []; if (rishonim.isNotEmpty) { + merged.add(_rishonimButton); merged.add(_rishonimTitle); // הוסף כותרת ראשונים merged.addAll(rishonim); } if (acharonim.isNotEmpty) { + merged.add(_acharonimButton); merged.add(_acharonimTitle); // הוסף כותרת אחרונים merged.addAll(acharonim); } if (modern.isNotEmpty) { + merged.add(_modernButton); merged.add(_modernTitle); // הוסף כותרת מחברי זמננו merged.addAll(modern); } if (ungrouped.isNotEmpty) { + merged.add(_ungroupedButton); merged.add(_ungroupedTitle); // הוסף כותרת לשאר merged.addAll(ungrouped); } @@ -166,11 +183,11 @@ class CommentatorsListViewState extends State { CheckboxListTile( title: const Text('הצג את כל הפרשנים'), // שמרתי את השינוי שלך value: commentatorsList - .where((e) => !e.startsWith('__TITLE_')) + .where((e) => !e.startsWith('__TITLE_') && !e.startsWith('__BUTTON_')) .every(state.activeCommentators.contains), onChanged: (checked) { final items = commentatorsList - .where((e) => !e.startsWith('__TITLE_')) + .where((e) => !e.startsWith('__TITLE_') && !e.startsWith('__BUTTON_')) .toList(); if (checked ?? false) { context.read().add(UpdateCommentators( @@ -191,6 +208,84 @@ class CommentatorsListViewState extends State { itemBuilder: (context, index) { final item = commentatorsList[index]; + // בדוק אם הפריט הוא כפתור הצגת קבוצה + if (item == _rishonimButton) { + final allActive = + _rishonim.every(state.activeCommentators.contains); + return CheckboxListTile( + title: const Text('הצג את כל הראשונים'), + value: allActive, + onChanged: (checked) { + final current = List.from(state.activeCommentators); + if (checked ?? false) { + for (final t in _rishonim) { + if (!current.contains(t)) current.add(t); + } + } else { + current.removeWhere(_rishonim.contains); + } + context.read().add(UpdateCommentators(current)); + }, + ); + } + if (item == _acharonimButton) { + final allActive = + _acharonim.every(state.activeCommentators.contains); + return CheckboxListTile( + title: const Text('הצג את כל האחרונים'), + value: allActive, + onChanged: (checked) { + final current = List.from(state.activeCommentators); + if (checked ?? false) { + for (final t in _acharonim) { + if (!current.contains(t)) current.add(t); + } + } else { + current.removeWhere(_acharonim.contains); + } + context.read().add(UpdateCommentators(current)); + }, + ); + } + if (item == _modernButton) { + final allActive = + _modern.every(state.activeCommentators.contains); + return CheckboxListTile( + title: const Text('הצג את כל מחברי זמננו'), + value: allActive, + onChanged: (checked) { + final current = List.from(state.activeCommentators); + if (checked ?? false) { + for (final t in _modern) { + if (!current.contains(t)) current.add(t); + } + } else { + current.removeWhere(_modern.contains); + } + context.read().add(UpdateCommentators(current)); + }, + ); + } + if (item == _ungroupedButton) { + final allActive = + _ungrouped.every(state.activeCommentators.contains); + return CheckboxListTile( + title: const Text('הצג את כל שאר המפרשים'), + value: allActive, + onChanged: (checked) { + final current = List.from(state.activeCommentators); + if (checked ?? false) { + for (final t in _ungrouped) { + if (!current.contains(t)) current.add(t); + } + } else { + current.removeWhere(_ungrouped.contains); + } + context.read().add(UpdateCommentators(current)); + }, + ); + } + // בדוק אם הפריט הוא כותרת if (item.startsWith('__TITLE_')) { String titleText = ''; diff --git a/lib/text_book/view/splited_view/simple_book_view.dart b/lib/text_book/view/splited_view/simple_book_view.dart index 44c1af936..5b897ca34 100644 --- a/lib/text_book/view/splited_view/simple_book_view.dart +++ b/lib/text_book/view/splited_view/simple_book_view.dart @@ -42,27 +42,48 @@ class _SimpleBookViewState extends State { final GlobalKey _selectionKey = GlobalKey(); - /// helper קטן שמחזיר רשימת MenuEntry מקבוצה אחת + /// helper קטן שמחזיר רשימת MenuEntry מקבוצה אחת, כולל כפתור הצג/הסתר הכל List> _buildGroup( + String groupName, List? group, TextBookLoaded st, ) { if (group == null || group.isEmpty) return const []; - return group.map((title) { - // בודקים אם הפרשן הנוכחי פעיל - final bool isActive = st.activeCommentators.contains(title); - - return ctx.MenuItem( - label: title, - // הוספה: מוסיפים אייקון V אם הפרשן פעיל - icon: isActive ? Icons.check : null, + + final bool groupActive = + group.every((title) => st.activeCommentators.contains(title)); + + return [ + ctx.MenuItem( + label: 'הצג את כל ${groupName}', + icon: groupActive ? Icons.check : null, onSelected: () { final current = List.from(st.activeCommentators); - current.contains(title) ? current.remove(title) : current.add(title); + if (groupActive) { + current.removeWhere(group.contains); + } else { + for (final title in group) { + if (!current.contains(title)) current.add(title); + } + } context.read().add(UpdateCommentators(current)); }, - ); - }).toList(); + ), + ...group.map((title) { + final bool isActive = st.activeCommentators.contains(title); + return ctx.MenuItem( + label: title, + icon: isActive ? Icons.check : null, + onSelected: () { + final current = List.from(st.activeCommentators); + current.contains(title) + ? current.remove(title) + : current.add(title); + context.read().add(UpdateCommentators(current)); + }, + ); + }), + ]; } ctx.ContextMenu _buildContextMenu(TextBookLoaded state) { @@ -110,14 +131,14 @@ class _SimpleBookViewState extends State { ), const ctx.MenuDivider(), // ראשונים - ..._buildGroup(state.rishonim, state), + ..._buildGroup('ראשונים', state.rishonim, state), // מוסיפים קו הפרדה רק אם יש גם ראשונים וגם אחרונים if (state.rishonim.isNotEmpty && state.acharonim.isNotEmpty) const ctx.MenuDivider(), // אחרונים - ..._buildGroup(state.acharonim, state), + ..._buildGroup('אחרונים', state.acharonim, state), // מוסיפים קו הפרדה רק אם יש גם אחרונים וגם בני זמננו if (state.acharonim.isNotEmpty && @@ -125,7 +146,7 @@ class _SimpleBookViewState extends State { const ctx.MenuDivider(), // מחברי זמננו - ..._buildGroup(state.modernCommentators, state), + ..._buildGroup('מחברי זמננו', state.modernCommentators, state), // הוסף קו הפרדה רק אם יש קבוצות אחרות וגם פרשנים לא-משויכים if ((state.rishonim.isNotEmpty || @@ -135,7 +156,7 @@ class _SimpleBookViewState extends State { const ctx.MenuDivider(), // הוסף את רשימת הפרשנים הלא משויכים - ..._buildGroup(ungrouped, state), + ..._buildGroup('שאר מפרשים', ungrouped, state), ], ), ctx.MenuItem.submenu( From 6bc725049c933d5f9ef1845ccb7d7d11952933d9 Mon Sep 17 00:00:00 2001 From: Y-Ploni <7353755@gmail.com> Date: Wed, 16 Jul 2025 08:03:53 +0300 Subject: [PATCH 005/197] Fix sidebar group buttons after separators --- lib/text_book/view/commentators_list_screen.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/text_book/view/commentators_list_screen.dart b/lib/text_book/view/commentators_list_screen.dart index 0b5ab7659..7d7760f1b 100644 --- a/lib/text_book/view/commentators_list_screen.dart +++ b/lib/text_book/view/commentators_list_screen.dart @@ -80,23 +80,23 @@ class CommentatorsListViewState extends State { final List merged = []; if (rishonim.isNotEmpty) { - merged.add(_rishonimButton); merged.add(_rishonimTitle); // הוסף כותרת ראשונים + merged.add(_rishonimButton); merged.addAll(rishonim); } if (acharonim.isNotEmpty) { - merged.add(_acharonimButton); merged.add(_acharonimTitle); // הוסף כותרת אחרונים + merged.add(_acharonimButton); merged.addAll(acharonim); } if (modern.isNotEmpty) { - merged.add(_modernButton); merged.add(_modernTitle); // הוסף כותרת מחברי זמננו + merged.add(_modernButton); merged.addAll(modern); } if (ungrouped.isNotEmpty) { - merged.add(_ungroupedButton); merged.add(_ungroupedTitle); // הוסף כותרת לשאר + merged.add(_ungroupedButton); merged.addAll(ungrouped); } if (mounted) { From b93179947b279234d50cdcec5b647797d47e34ba Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 16 Jul 2025 12:08:49 +0300 Subject: [PATCH 006/197] =?UTF-8?q?=D7=9B=D7=AA=D7=99=D7=91=D7=AA=20=D7=A4?= =?UTF-8?q?=D7=99=D7=A8=D7=95=D7=98=20=D7=94=D7=98=D7=A2=D7=95=D7=AA=20?= =?UTF-8?q?=D7=91=D7=AA=D7=95=D7=9B=D7=A0=D7=94,=20=D7=9C=D7=A4=D7=A0?= =?UTF-8?q?=D7=99=20=D7=94=D7=9E=D7=A2=D7=91=D7=A8=20=D7=9C=D7=90=D7=99?= =?UTF-8?q?=D7=9E=D7=99=D7=99=D7=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/text_book/view/text_book_screen.dart | 288 +++++++++++++---------- 1 file changed, 167 insertions(+), 121 deletions(-) diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index 8286de241..05a274224 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -31,6 +31,14 @@ import 'package:otzaria/utils/ref_helper.dart'; import 'package:otzaria/utils/text_manipulation.dart' as utils; import 'package:url_launcher/url_launcher.dart'; +/// נתוני הדיווח שנאספו מתיבת סימון הטקסט + פירוט הטעות שהמשתמש הקליד. +class ReportedErrorData { + final String selectedText; // הטקסט שסומן ע"י המשתמש + final String errorDetails; // פירוט הטעות (שדה טקסט נוסף) + const ReportedErrorData( + {required this.selectedText, required this.errorDetails}); +} + class TextBookViewerBloc extends StatefulWidget { final void Function(OpenedTab) openBookCallback; final TextBookTab tab; @@ -62,28 +70,28 @@ class _TextBookViewerBlocState extends State .join('&'); } - @override - void initState() { - super.initState(); - - // אם יש טקסט חיפוש (searchText), נתחיל בלשונית 'חיפוש' (שנמצאת במקום ה-1) - // אחרת, נתחיל בלשונית 'ניווט' (שנמצאת במקום ה-0) - final int initialIndex = widget.tab.searchText.isNotEmpty ? 1 : 0; - - // יוצרים את בקר הלשוניות עם האינדקס ההתחלתי שקבענו - tabController = TabController( - length: 4, // יש 4 לשוניות - vsync: this, - initialIndex: initialIndex, - ); + @override + void initState() { + super.initState(); + + // אם יש טקסט חיפוש (searchText), נתחיל בלשונית 'חיפוש' (שנמצאת במקום ה-1) + // אחרת, נתחיל בלשונית 'ניווט' (שנמצאת במקום ה-0) + final int initialIndex = widget.tab.searchText.isNotEmpty ? 1 : 0; + + // יוצרים את בקר הלשוניות עם האינדקס ההתחלתי שקבענו + tabController = TabController( + length: 4, // יש 4 לשוניות + vsync: this, + initialIndex: initialIndex, + ); - _sidebarWidth = ValueNotifier( - Settings.getValue('key-sidebar-width', defaultValue: 300)!); - _settingsSub = context - .read() - .stream - .listen((state) => _sidebarWidth.value = state.sidebarWidth); - } + _sidebarWidth = ValueNotifier( + Settings.getValue('key-sidebar-width', defaultValue: 300)!); + _settingsSub = context + .read() + .stream + .listen((state) => _sidebarWidth.value = state.sidebarWidth); + } @override void dispose() { @@ -107,40 +115,41 @@ class _TextBookViewerBlocState extends State return BlocBuilder( bloc: context.read(), builder: (context, state) { - if (state is TextBookInitial) { - context.read().add( - LoadContent( - fontSize: settingsState.fontSize, - showSplitView: Settings.getValue('key-splited-view') ?? false, - removeNikud: settingsState.defaultRemoveNikud, - ), - ); - } + if (state is TextBookInitial) { + context.read().add( + LoadContent( + fontSize: settingsState.fontSize, + showSplitView: + Settings.getValue('key-splited-view') ?? false, + removeNikud: settingsState.defaultRemoveNikud, + ), + ); + } - if (state is TextBookInitial || state is TextBookLoading) { - return const Center(child: CircularProgressIndicator()); - } + if (state is TextBookInitial || state is TextBookLoading) { + return const Center(child: CircularProgressIndicator()); + } - if (state is TextBookError) { - return Center(child: Text('Error: ${(state).message}')); - } + if (state is TextBookError) { + return Center(child: Text('Error: ${(state).message}')); + } - if (state is TextBookLoaded) { - return LayoutBuilder( - builder: (context, constrains) { - final wideScreen = (MediaQuery.of(context).size.width >= 600); - return Scaffold( - appBar: _buildAppBar(context, state, wideScreen), - body: _buildBody(context, state, wideScreen), + if (state is TextBookLoaded) { + return LayoutBuilder( + builder: (context, constrains) { + final wideScreen = (MediaQuery.of(context).size.width >= 600); + return Scaffold( + appBar: _buildAppBar(context, state, wideScreen), + body: _buildBody(context, state, wideScreen), + ); + }, ); - }, - ); - } + } - // Fallback - return const Center(child: Text('Unknown state')); - }, - ); + // Fallback + return const Center(child: Text('Unknown state')); + }, + ); }, ); } @@ -448,16 +457,16 @@ class _TextBookViewerBlocState extends State if (!mounted) return; - final selectedText = await _showTextSelectionDialog( + final ReportedErrorData? reportData = await _showTextSelectionDialog( context, visibleText, state.fontSize, ); - if (selectedText == null || selectedText.isEmpty) return; + if (reportData == null) return; // בוטל או לא נבחר טקסט if (!mounted) return; - final shouldProceed = await _showConfirmationDialog(context, selectedText); + final shouldProceed = await _showConfirmationDialog(context, reportData); if (shouldProceed != true) return; @@ -475,7 +484,8 @@ class _TextBookViewerBlocState extends State state.book.title, currentRef, bookDetails, - selectedText, + reportData.selectedText, + reportData.errorDetails, ), }), ); @@ -497,28 +507,32 @@ class _TextBookViewerBlocState extends State } } - Future _showTextSelectionDialog( + Future _showTextSelectionDialog( BuildContext context, String text, double fontSize, ) async { String? selectedContent; - return showDialog( + final TextEditingController detailsController = TextEditingController(); + return showDialog( context: context, builder: (BuildContext context) { return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( title: const Text('בחר את הטקסט שבו יש טעות'), - content: SizedBox( - width: double.maxFinite, - height: MediaQuery.of(context).size.height * 0.6, + content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('סמן את הטקסט שבו נמצאת הטעות:'), const SizedBox(height: 8), - Expanded( + // השתמשנו ב-ConstrainedBox כדי לתת גובה מקסימלי, במקום Expanded + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.4, + ), child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -526,33 +540,50 @@ class _TextBookViewerBlocState extends State borderRadius: BorderRadius.circular(4), ), child: SingleChildScrollView( - - child: BlocBuilder( - builder: (context, settingsState) { - return SelectableText( - text, - style: TextStyle( - fontSize: fontSize, - fontFamily: settingsState.fontFamily, - ), - onSelectionChanged: (selection, cause) { - if (selection.start != selection.end) { - final newContent = text.substring( - selection.start, selection.end); - if (newContent.isNotEmpty) { - setDialogState(() { - selectedContent = newContent; - }); - } - } - }, - ); - + child: SelectableText( + text, + style: TextStyle( + fontSize: fontSize, + fontFamily: + Settings.getValue('key-font-family') ?? 'candara', + ), + onSelectionChanged: (selection, cause) { + if (selection.start != selection.end) { + final newContent = text.substring( + selection.start, + selection.end, + ); + if (newContent.isNotEmpty) { + setDialogState(() { + selectedContent = newContent; + }); + } + } }, ), ), ), ), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerRight, + child: Text( + 'פירוט הטעות (אופציונלי):', + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 4), + TextField( + controller: detailsController, + minLines: 2, + maxLines: 4, + decoration: const InputDecoration( + isDense: true, + border: OutlineInputBorder(), + hintText: 'כתוב כאן מה לא תקין, הצע תיקון וכו\'', + ), + textDirection: TextDirection.rtl, + ), ], ), ), @@ -564,7 +595,12 @@ class _TextBookViewerBlocState extends State TextButton( onPressed: selectedContent == null || selectedContent!.isEmpty ? null - : () => Navigator.of(context).pop(selectedContent), + : () => Navigator.of(context).pop( + ReportedErrorData( + selectedText: selectedContent!, + errorDetails: detailsController.text.trim(), + ), + ), child: const Text('המשך'), ), ], @@ -577,7 +613,7 @@ class _TextBookViewerBlocState extends State Future _showConfirmationDialog( BuildContext context, - String selectedText, + ReportedErrorData reportData, ) { return showDialog( context: context, @@ -592,7 +628,15 @@ class _TextBookViewerBlocState extends State 'הטקסט שנבחר:', style: TextStyle(fontWeight: FontWeight.bold), ), - Text(selectedText), + Text(reportData.selectedText), + const SizedBox(height: 16), + if (reportData.errorDetails.isNotEmpty) ...[ + const Text( + 'פירוט הטעות:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(reportData.errorDetails), + ], ], ), actions: [ @@ -615,26 +659,29 @@ class _TextBookViewerBlocState extends State String currentRef, Map bookDetails, String selectedText, + String errorDetails, ) { + final detailsSection = errorDetails.isEmpty ? '' : '\n$errorDetails'; + return '''שם הספר: $bookTitle -מיקום: $currentRef -שם הקובץ: ${bookDetails['שם הקובץ']} -נתיב הקובץ: ${bookDetails['נתיב הקובץ']} -תיקיית המקור: ${bookDetails['תיקיית המקור']} + מיקום: $currentRef + שם הקובץ: ${bookDetails['שם הקובץ']} + נתיב הקובץ: ${bookDetails['נתיב הקובץ']} + תיקיית המקור: ${bookDetails['תיקיית המקור']} -הטקסט שבו נמצאה הטעות: -$selectedText + הטקסט שבו נמצאה הטעות: + $selectedText -פירוט הטעות: + פירוט הטעות:$detailsSection -'''; + '''; } Future> _getBookDetails(String bookTitle) async { try { final libraryPath = Settings.getValue('key-library-path'); final file = File( - '$libraryPath${Platform.pathSeparator}אוצריא${Platform.pathSeparator}אודות התוכנה${Platform.pathSeparator}SourcesBooks.csv'); + '$libraryPath${Platform.pathSeparator}אוצריא${Platform.pathSeparator}אודות התוכנה${Platform.pathSeparator}SourcesBooks.csv'); if (!await file.exists()) { return _getDefaultBookDetails(); } @@ -642,27 +689,26 @@ $selectedText // קריאת הקובץ כ-stream final inputStream = file.openRead(); final converter = const CsvToListConverter(); - + var isFirstLine = true; - + await for (final line in inputStream .transform(utf8.decoder) .transform(const LineSplitter())) { - // דילוג על שורת הכותרת if (isFirstLine) { isFirstLine = false; continue; } - + try { // המרת השורה לרשימה final row = converter.convert(line).first; - + if (row.length >= 3) { final fileNameRaw = row[0].toString(); final fileName = fileNameRaw.replaceAll('.txt', ''); - + if (fileName == bookTitle) { return { 'שם הקובץ': fileNameRaw, @@ -677,11 +723,10 @@ $selectedText continue; } } - } catch (e) { debugPrint('Error reading sourcebooks.csv: $e'); } - + return _getDefaultBookDetails(); } @@ -719,7 +764,8 @@ $selectedText behavior: HitTestBehavior.translucent, onHorizontalDragUpdate: (details) { final newWidth = - (_sidebarWidth.value + details.delta.dx).clamp(200.0, 600.0); + (_sidebarWidth.value + details.delta.dx) + .clamp(200.0, 600.0); _sidebarWidth.value = newWidth; }, onHorizontalDragEnd: (_) { @@ -729,7 +775,7 @@ $selectedText }, child: const VerticalDivider(width: 4), ), - ), + ), Expanded(child: _buildHTMLViewer(state)), ], ), @@ -852,12 +898,12 @@ $selectedText ), if (MediaQuery.of(context).size.width >= 600) IconButton( - onPressed: (Settings.getValue('key-pin-sidebar') ?? - false) - ? null - : () => context.read().add( - TogglePinLeftPane(!state.pinLeftPane), - ), + onPressed: + (Settings.getValue('key-pin-sidebar') ?? false) + ? null + : () => context.read().add( + TogglePinLeftPane(!state.pinLeftPane), + ), icon: const Icon(Icons.push_pin), isSelected: state.pinLeftPane || (Settings.getValue('key-pin-sidebar') ?? false), @@ -896,17 +942,17 @@ $selectedText ); } - Widget _buildSearchView(BuildContext context, TextBookLoaded state) { - return TextBookSearchView( - focusNode: textSearchFocusNode, - data: state.content.join('\n'), - scrollControler: state.scrollController, - // הוא מעביר את טקסט החיפוש מה-state הנוכחי אל תוך רכיב החיפוש - initialQuery: state.searchText, - closeLeftPaneCallback: () => - context.read().add(const ToggleLeftPane(false)), - ); - } + Widget _buildSearchView(BuildContext context, TextBookLoaded state) { + return TextBookSearchView( + focusNode: textSearchFocusNode, + data: state.content.join('\n'), + scrollControler: state.scrollController, + // הוא מעביר את טקסט החיפוש מה-state הנוכחי אל תוך רכיב החיפוש + initialQuery: state.searchText, + closeLeftPaneCallback: () => + context.read().add(const ToggleLeftPane(false)), + ); + } Widget _buildTocViewer(BuildContext context, TextBookLoaded state) { return TocViewer( From 386c37f36d5c3734805716c7b3bb0c21e601062f Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 16 Jul 2025 13:22:57 +0300 Subject: [PATCH 007/197] =?UTF-8?q?=D7=93=D7=99=D7=95=D7=95=D7=97=20=D7=98?= =?UTF-8?q?=D7=A2=D7=95=D7=99=D7=95=D7=AA=20=D7=9C=D7=90=20=D7=9E=D7=A7?= =?UTF-8?q?=D7=95=D7=95=D7=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/text_book/view/text_book_screen.dart | 211 ++++++++++++++++++----- 1 file changed, 171 insertions(+), 40 deletions(-) diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index 05a274224..4ac6fe43a 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -39,6 +39,13 @@ class ReportedErrorData { {required this.selectedText, required this.errorDetails}); } +/// פעולה שנבחרה בדיאלוג האישור. +enum ReportAction { + cancel, + sendEmail, + saveForLater, +} + class TextBookViewerBloc extends StatefulWidget { final void Function(OpenedTab) openBookCallback; final TextBookTab tab; @@ -60,6 +67,9 @@ class _TextBookViewerBlocState extends State late TabController tabController; late final ValueNotifier _sidebarWidth; late final StreamSubscription _settingsSub; + static const String _reportFileName = 'דיווח שגיאות בספרים.txt'; + static const String _reportSeparator = '=============================='; + static const String _fallbackMail = 'otzaria.200@gmail.com'; String? encodeQueryParameters(Map params) { return params.entries @@ -466,44 +476,56 @@ class _TextBookViewerBlocState extends State if (reportData == null) return; // בוטל או לא נבחר טקסט if (!mounted) return; - final shouldProceed = await _showConfirmationDialog(context, reportData); - - if (shouldProceed != true) return; - - final emailAddress = - bookDetails['תיקיית המקור']?.contains('sefaria') == true - ? 'corrections@sefaria.org' - : 'otzaria.200@gmail.com'; - - final emailUri = Uri( - scheme: 'mailto', - path: emailAddress, - query: encodeQueryParameters({ - 'subject': 'דיווח על טעות: ${state.book.title}', - 'body': _buildEmailBody( - state.book.title, - currentRef, - bookDetails, - reportData.selectedText, - reportData.errorDetails, - ), - }), + final ReportAction? action = + await _showConfirmationDialog(context, reportData); + + if (action == null || action == ReportAction.cancel) return; + + // נבנה את גוף המייל (נעשה שימוש גם לשליחה וגם לשמירה) + final emailBody = _buildEmailBody( + state.book.title, + currentRef, + bookDetails, + reportData.selectedText, + reportData.errorDetails, ); - try { - if (!await launchUrl(emailUri, mode: LaunchMode.externalApplication)) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('לא ניתן לפתוח את תוכנת הדואר')), - ); + if (action == ReportAction.sendEmail) { + // בחירת כתובת דוא"ל לקבלת הדיווח + final emailAddress = + bookDetails['תיקיית המקור']?.contains('sefaria') == true + ? 'corrections@sefaria.org' + : _fallbackMail; + + final emailUri = Uri( + scheme: 'mailto', + path: emailAddress, + query: encodeQueryParameters({ + 'subject': 'דיווח על טעות: ${state.book.title}', + 'body': emailBody, + }), + ); + + try { + if (!await launchUrl(emailUri, mode: LaunchMode.externalApplication)) { + _showSimpleSnack('לא ניתן לפתוח את תוכנת הדואר'); } + } catch (_) { + _showSimpleSnack('לא ניתן לפתוח את תוכנת הדואר'); } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('לא ניתן לפתוח את תוכנת הדואר')), - ); + return; + } + + if (action == ReportAction.saveForLater) { + final saved = await _saveReportToFile(emailBody); + if (!saved) { + _showSimpleSnack('שמירת הדיווח נכשלה.'); + return; } + + final count = await _countReportsInFile(); + _showSavedSnack(count); + return; } } @@ -611,18 +633,19 @@ class _TextBookViewerBlocState extends State ); } - Future _showConfirmationDialog( + Future _showConfirmationDialog( BuildContext context, ReportedErrorData reportData, ) { - return showDialog( + return showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: const Text('דיווח על טעות בספר'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'הטקסט שנבחר:', @@ -639,14 +662,21 @@ class _TextBookViewerBlocState extends State ], ], ), + ), actions: [ TextButton( child: const Text('ביטול'), - onPressed: () => Navigator.of(context).pop(false), + onPressed: () => Navigator.of(context).pop(ReportAction.cancel), + ), + TextButton( + child: const Text('שמור לדיווח מאוחר'), + onPressed: () => + Navigator.of(context).pop(ReportAction.saveForLater), ), TextButton( child: const Text('פתיחת דוא"ל'), - onPressed: () => Navigator.of(context).pop(true), + onPressed: () => + Navigator.of(context).pop(ReportAction.sendEmail), ), ], ); @@ -677,6 +707,107 @@ class _TextBookViewerBlocState extends State '''; } +/// שמירת דיווח לקובץ בתיקייה הראשית של הספרייה (libraryPath). +Future _saveReportToFile(String reportContent) async { + try { + final libraryPath = Settings.getValue('key-library-path'); + + if (libraryPath == null || libraryPath.isEmpty) { + debugPrint('libraryPath not set; cannot save report.'); + return false; + } + + final filePath = + '$libraryPath${Platform.pathSeparator}$_reportFileName'; + final file = File(filePath); + + final exists = await file.exists(); + + final sink = file.openWrite( + mode: exists ? FileMode.append : FileMode.write, + encoding: utf8, + ); + + // אם יש כבר תוכן קודם בקובץ קיים -> הוסף מפריד לפני הרשומה החדשה + if (exists && (await file.length()) > 0) { + sink.writeln(''); // שורת רווח + sink.writeln(_reportSeparator); + sink.writeln(''); // שורת רווח + } + + sink.write(reportContent); + await sink.flush(); + await sink.close(); + return true; + } catch (e) { + debugPrint('Failed saving report: $e'); + return false; + } +} + +/// סופר כמה דיווחים יש בקובץ – לפי המפריד. +Future _countReportsInFile() async { + try { + final libraryPath = Settings.getValue('key-library-path'); + if (libraryPath == null || libraryPath.isEmpty) return 0; + + final filePath = + '$libraryPath${Platform.pathSeparator}$_reportFileName'; + final file = File(filePath); + if (!await file.exists()) return 0; + + final content = await file.readAsString(encoding: utf8); + if (content.trim().isEmpty) return 0; + + final occurrences = _reportSeparator.allMatches(content).length; + return occurrences + 1; + } catch (e) { + debugPrint('countReports error: $e'); + return 0; + } +} + +void _showSimpleSnack(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); +} + +/// SnackBar לאחר שמירה: מציג מונה + פעולה לפתיחת דוא"ל (mailto). +void _showSavedSnack(int count) { + if (!mounted) return; + + final message = + "הדיווח נשמר בהצלחה לקובץ '$_reportFileName', הנמצא בתיקייה הראשית של אוצריא.\n" + "יש לך כבר $count דיווחים, וכעת תוכל לשלוח את הקובץ למייל: $_fallbackMail!"; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 8), + content: Text(message, textDirection: TextDirection.rtl), + action: SnackBarAction( + label: 'שלח', + onPressed: () { + _launchMail(_fallbackMail); + }, + ), + ), + ); +} + +Future _launchMail(String email) async { + final emailUri = Uri( + scheme: 'mailto', + path: email, + ); + try { + await launchUrl(emailUri, mode: LaunchMode.externalApplication); + } catch (e) { + _showSimpleSnack('לא ניתן לפתוח את תוכנת הדואר'); + } +} + Future> _getBookDetails(String bookTitle) async { try { final libraryPath = Settings.getValue('key-library-path'); From 49c6ac65567e7cf83d31d8e7185f243c08f6aa67 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 16 Jul 2025 14:49:45 +0300 Subject: [PATCH 008/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=91?= =?UTF-8?q?=D7=90=D7=92=20=D7=91=D7=94=D7=A2=D7=AA=D7=A7=D7=AA=20=D7=98?= =?UTF-8?q?=D7=A7=D7=A1=D7=98,=20=D7=9B=D7=A9=D7=A4=D7=A8=D7=A9=D7=A0?= =?UTF-8?q?=D7=99=D7=9D=20=D7=9E=D7=95=D7=A6=D7=92=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../combined_view/combined_book_screen.dart | 31 +++---- .../view/splited_view/simple_book_view.dart | 83 ++++++++----------- 2 files changed, 47 insertions(+), 67 deletions(-) diff --git a/lib/text_book/view/combined_view/combined_book_screen.dart b/lib/text_book/view/combined_view/combined_book_screen.dart index b9f1a8cef..518b642db 100644 --- a/lib/text_book/view/combined_view/combined_book_screen.dart +++ b/lib/text_book/view/combined_view/combined_book_screen.dart @@ -89,7 +89,6 @@ class _CombinedViewState extends State { ]; } - ctx.ContextMenu _buildContextMenu(TextBookLoaded state) { // 1. קבלת מידע על גודל המסך final screenHeight = MediaQuery.of(context).size.height; @@ -117,17 +116,20 @@ class _CombinedViewState extends State { items: [ ctx.MenuItem( label: 'הצג את כל המפרשים', - icon: state.activeCommentators.toSet().containsAll( - state.availableCommentators) + icon: state.activeCommentators + .toSet() + .containsAll(state.availableCommentators) ? Icons.check - : null, + : null, onSelected: () { - final allActive = state.activeCommentators.toSet().containsAll( - state.availableCommentators); + final allActive = state.activeCommentators + .toSet() + .containsAll(state.availableCommentators); context.read().add( UpdateCommentators( - allActive ? [] : List.from( - state.availableCommentators), + allActive + ? [] + : List.from(state.availableCommentators), ), ); }, @@ -234,18 +236,7 @@ class _CombinedViewState extends State { itemCount: widget.data.length, itemBuilder: (context, index) { ExpansibleController controller = ExpansibleController(); - // WORKAROUND: Add an invisible newline character to preserve line breaks - // when copying text from the SelectionArea. This addresses a known - // issue in Flutter where newlines are stripped when copying from - // multiple widgets. - // See: https://github.com/flutter/flutter/issues/104548#issuecomment-2051481671 - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - buildExpansiomTile(controller, index, state), - const Text('\n', style: TextStyle(fontSize: 0, height: 0)), - ], - ); + return buildExpansiomTile(controller, index, state); }, ); } diff --git a/lib/text_book/view/splited_view/simple_book_view.dart b/lib/text_book/view/splited_view/simple_book_view.dart index 5b897ca34..5f12b0ce5 100644 --- a/lib/text_book/view/splited_view/simple_book_view.dart +++ b/lib/text_book/view/splited_view/simple_book_view.dart @@ -113,13 +113,15 @@ class _SimpleBookViewState extends State { items: [ ctx.MenuItem( label: 'הצג את כל המפרשים', - icon: state.activeCommentators.toSet().containsAll( - state.availableCommentators) + icon: state.activeCommentators + .toSet() + .containsAll(state.availableCommentators) ? Icons.check : null, onSelected: () { - final allActive = state.activeCommentators.toSet().containsAll( - state.availableCommentators); + final allActive = state.activeCommentators + .toSet() + .containsAll(state.availableCommentators); context.read().add( UpdateCommentators( allActive @@ -218,51 +220,38 @@ class _SimpleBookViewState extends State { scrollOffsetController: state.scrollOffsetController, itemCount: widget.data.length, itemBuilder: (context, index) { - // WORKAROUND: Add an invisible newline character to preserve line breaks - // when copying text from the SelectionArea. This addresses a known - // issue in Flutter where newlines are stripped when copying from - // multiple widgets. - // See: https://github.com/flutter/flutter/issues/104548#issuecomment-2051481671 - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - BlocBuilder( - builder: (context, settingsState) { - String data = widget.data[index]; - if (!settingsState.showTeamim) { - data = utils.removeTeamim(data); - } + return BlocBuilder( + builder: (context, settingsState) { + String data = widget.data[index]; + if (!settingsState.showTeamim) { + data = utils.removeTeamim(data); + } - if (settingsState.replaceHolyNames) { - data = utils.replaceHolyNames(data); - } - return InkWell( - onTap: () => context.read().add( - UpdateSelectedIndex(index), - ), - child: Html( - // remove nikud if needed - data: state.removeNikud - ? utils.highLight( - utils.removeVolwels('$data\n'), - state.searchText, - ) - : utils.highLight( - '$data\n', state.searchText), - style: { - 'body': Style( - fontSize: FontSize(widget.textSize), - fontFamily: settingsState.fontFamily, - textAlign: TextAlign.justify, - ), - }, + if (settingsState.replaceHolyNames) { + data = utils.replaceHolyNames(data); + } + return InkWell( + onTap: () => context.read().add( + UpdateSelectedIndex(index), ), - ); - }, - ), - const Text('\n', - style: TextStyle(fontSize: 0, height: 0)), - ], + child: Html( + // remove nikud if needed + data: state.removeNikud + ? utils.highLight( + utils.removeVolwels('$data\n'), + state.searchText, + ) + : utils.highLight('$data\n', state.searchText), + style: { + 'body': Style( + fontSize: FontSize(widget.textSize), + fontFamily: settingsState.fontFamily, + textAlign: TextAlign.justify, + ), + }, + ), + ); + }, ); }, ), From 805ccc74202b48abe29180bb9212f3468483ff5c Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 17 Jul 2025 15:11:09 +0300 Subject: [PATCH 009/197] =?UTF-8?q?=D7=A6=D7=9E=D7=A6=D7=95=D7=9D=20=D7=A9?= =?UTF-8?q?=D7=98=D7=97=20=D7=AA=D7=95=D7=A6=D7=90=D7=95=D7=AA=20=D7=94?= =?UTF-8?q?=D7=97=D7=99=D7=A4=D7=95=D7=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/tantivy_search_results.dart | 262 ++++++++++++++++++-- 1 file changed, 248 insertions(+), 14 deletions(-) diff --git a/lib/search/view/tantivy_search_results.dart b/lib/search/view/tantivy_search_results.dart index 03301849b..6d0f90889 100644 --- a/lib/search/view/tantivy_search_results.dart +++ b/lib/search/view/tantivy_search_results.dart @@ -1,7 +1,9 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; +import 'package:html/parser.dart' as html_parser; import 'package:otzaria/models/books.dart'; import 'package:otzaria/search/bloc/search_bloc.dart'; import 'package:otzaria/search/bloc/search_state.dart'; @@ -27,6 +29,218 @@ class TantivySearchResults extends StatefulWidget { } class _TantivySearchResultsState extends State { + // פונקציה לחישוב כמה תווים יכולים להיכנס בשורה אחת + int _calculateCharsPerLine(double availableWidth, TextStyle textStyle) { + final textPainter = TextPainter( + text: TextSpan(text: 'א' * 100, style: textStyle), // טקסט לדוגמה + textDirection: TextDirection.rtl, + ); + textPainter.layout(maxWidth: availableWidth); + + // חישוב כמה תווים נכנסים בשורה אחת + final singleCharWidth = textPainter.width / 100; + final charsPerLine = (availableWidth / singleCharWidth).floor(); + + textPainter.dispose(); + return charsPerLine; + } + + // פונקציה חכמה ליצירת קטע טקסט עם הדגשות - מבטיחה שכל ההתאמות יופיעו! + List createSnippetSpans( + String fullHtml, + String query, + TextStyle defaultStyle, + TextStyle highlightStyle, + double availableWidth, + ) { + // 1. קבלת הטקסט הנקי מה-HTML + final plainText = + html_parser.parse(fullHtml).documentElement?.text.trim() ?? ''; + + // 2. חילוץ מילות החיפוש + final searchTerms = query + .trim() + .replaceAll(RegExp(r'[~"*\(\)]'), ' ') + .split(RegExp(r'\s+')) + .where((s) => s.isNotEmpty) + .toList(); + + if (searchTerms.isEmpty || plainText.isEmpty) { + return [TextSpan(text: plainText, style: defaultStyle)]; + } + + // 3. מציאת כל ההתאמות של כל המילים בטקסט המקורי - זה הכי חשוב! + final List allMatches = []; + for (final term in searchTerms) { + final regex = RegExp(RegExp.escape(term), caseSensitive: false); + allMatches.addAll(regex.allMatches(plainText)); + } + + if (allMatches.isEmpty) { + return [ + TextSpan( + text: plainText.substring(0, min(200, plainText.length)), + style: defaultStyle) + ]; + } + + // 4. מיון ההתאמות וקביעת הגבולות המוחלטים + allMatches.sort((a, b) => a.start.compareTo(b.start)); + final int absoluteFirstMatch = allMatches.first.start; + final int absoluteLastMatch = allMatches.last.end; + final int totalMatchesSpan = absoluteLastMatch - absoluteFirstMatch; + + // 5. קביעת הקטע - עקרון ברזל: כל ההתאמות חייבות להיכלל! + int snippetStart; + int snippetEnd; + + // חישוב אורך הטקסט הנדרש לשלוש שורות בהתבסס על רוחב המסך בפועל + final charsPerLine = _calculateCharsPerLine(availableWidth, defaultStyle); + final targetLength = (charsPerLine * 3).clamp(120, 400); // הקטנתי את הטווח + + // תמיד מתחילים מהגבולות המוחלטים של ההתאמות + snippetStart = absoluteFirstMatch; + snippetEnd = absoluteLastMatch; + + // לוגיקה מתוקנת: אם יש מילה אחת או מילים קרובות, נוסיף הקשר מוגבל + if (totalMatchesSpan < 50) { + // אם המילים קרובות מאוד (כולל מילה אחת) + // נוסיף הקשר מוגבל - מקסימום 60 תווים מכל צד + const limitedPadding = 60; + snippetStart = + (absoluteFirstMatch - limitedPadding).clamp(0, plainText.length); + snippetEnd = + (absoluteLastMatch + limitedPadding).clamp(0, plainText.length); + } else if (totalMatchesSpan < targetLength) { + // אם ההתאמות קצרות מהיעד, נוסיף הקשר עד שנגיע ל-3 שורות + int remainingSpace = targetLength - totalMatchesSpan; + int paddingBefore = remainingSpace ~/ 2; + int paddingAfter = remainingSpace - paddingBefore; + + snippetStart = + (absoluteFirstMatch - paddingBefore).clamp(0, plainText.length); + snippetEnd = + (absoluteLastMatch + paddingAfter).clamp(0, plainText.length); + } else { + // אם ההתאמות ארוכות, נוסיף רק מעט הקשר + const minPadding = 30; + snippetStart = + (absoluteFirstMatch - minPadding).clamp(0, plainText.length); + snippetEnd = (absoluteLastMatch + minPadding).clamp(0, plainText.length); + } + + // התאמה לגבולות מילים - אבל לא על חשבון ההתאמות! + // וידוא שלא חותכים מילה בהתחלה + if (snippetStart > 0 && snippetStart < absoluteFirstMatch) { + // מחפשים רווח לפני הנקודה הנוכחית + int? spaceIndex = plainText.lastIndexOf(' ', snippetStart); + if (spaceIndex != -1 && spaceIndex >= snippetStart - 50) { + snippetStart = spaceIndex + 1; + } else { + // אם לא מצאנו רווח קרוב, נתחיל מתחילת המילה + while (snippetStart > 0 && plainText[snippetStart - 1] != ' ') { + snippetStart--; + } + } + } + + // וידוא שלא חותכים מילה בסוף + if (snippetEnd < plainText.length && snippetEnd > absoluteLastMatch) { + // מחפשים רווח אחרי הנקודה הנוכחית + int? spaceIndex = plainText.indexOf(' ', snippetEnd); + if (spaceIndex != -1 && spaceIndex <= snippetEnd + 50) { + snippetEnd = spaceIndex; + } else { + // אם לא מצאנו רווח קרוב, נסיים בסוף המילה + while (snippetEnd < plainText.length && plainText[snippetEnd] != ' ') { + snippetEnd++; + } + } + } + + // וידוא אחרון שלא חתכנו את ההתאמות + if (snippetStart > absoluteFirstMatch) { + snippetStart = absoluteFirstMatch; + } + if (snippetEnd < absoluteLastMatch) { + snippetEnd = absoluteLastMatch; + } + + final snippetText = plainText.substring(snippetStart, snippetEnd); + + // 6. בדיקה נוספת - ספירת ההתאמות בקטע הסופי + int finalMatchCount = 0; + for (final term in searchTerms) { + final regex = RegExp(RegExp.escape(term), caseSensitive: false); + finalMatchCount += regex.allMatches(snippetText).length; + } + + // אם יש פחות התאמות בקטע הסופי, זה אומר שמשהו השתבש + if (finalMatchCount < allMatches.length) { + // במקרה כזה, נחזור לטקסט המלא או לקטע גדול יותר + snippetStart = (absoluteFirstMatch - 100).clamp(0, plainText.length); + snippetEnd = (absoluteLastMatch + 100).clamp(0, plainText.length); + final expandedSnippet = plainText.substring(snippetStart, snippetEnd); + + // בדיקה אחרונה + int expandedMatchCount = 0; + for (final term in searchTerms) { + final regex = RegExp(RegExp.escape(term), caseSensitive: false); + expandedMatchCount += regex.allMatches(expandedSnippet).length; + } + + if (expandedMatchCount >= allMatches.length) { + return _buildTextSpans( + expandedSnippet, searchTerms, defaultStyle, highlightStyle); + } + } + + return _buildTextSpans( + snippetText, searchTerms, defaultStyle, highlightStyle); + } + + // פונקציה עזר לבניית ה-TextSpans + List _buildTextSpans( + String text, + List searchTerms, + TextStyle defaultStyle, + TextStyle highlightStyle, + ) { + final List spans = []; + int currentPosition = 0; + + final highlightRegex = RegExp( + searchTerms.map(RegExp.escape).join('|'), + caseSensitive: false, + ); + + for (final match in highlightRegex.allMatches(text)) { + // טקסט רגיל לפני ההדגשה + if (match.start > currentPosition) { + spans.add(TextSpan( + text: text.substring(currentPosition, match.start), + style: defaultStyle, + )); + } + // הטקסט המודגש + spans.add(TextSpan( + text: match.group(0), + style: highlightStyle, + )); + currentPosition = match.end; + } + + // טקסט רגיל אחרי ההדגשה האחרונה + if (currentPosition < text.length) { + spans.add(TextSpan( + text: text.substring(currentPosition), + style: defaultStyle, + )); + } + + return spans; + } + @override Widget build(BuildContext context) { return LayoutBuilder(builder: (context, constrains) { @@ -78,11 +292,36 @@ class _TantivySearchResultsState extends State { builder: (context, settingsState) { String titleText = '[תוצאה ${index + 1}] ${result.reference}'; - String snippet = result.text; + String rawHtml = result.text; if (settingsState.replaceHolyNames) { titleText = utils.replaceHolyNames(titleText); - snippet = utils.replaceHolyNames(snippet); + rawHtml = utils.replaceHolyNames(rawHtml); } + + // חישוב רוחב זמין לטקסט (מינוס אייקון ו-padding) + final availableWidth = constrains.maxWidth - + (result.isPdf + ? 56.0 + : 16.0) - // רוחב האייקון או padding + 32.0; // padding נוסף של ListTile + + // Create the snippet using the new robust function + final snippetSpans = createSnippetSpans( + rawHtml, + state.searchQuery, + TextStyle( + fontSize: settingsState.fontSize, + fontFamily: settingsState.fontFamily, + ), + TextStyle( + fontSize: settingsState.fontSize, + fontFamily: settingsState.fontFamily, + color: Colors.red, + fontWeight: FontWeight.bold, + ), + availableWidth, + ); + return ListTile( leading: result.isPdf ? const Icon(Icons.picture_as_pdf) @@ -124,17 +363,12 @@ class _TantivySearchResultsState extends State { } }, title: Text(titleText), - subtitle: Html(data: snippet, style: { - 'body': Style( - fontSize: FontSize( - context.read().state.fontSize, - ), - fontFamily: context - .read() - .state - .fontFamily, - textAlign: TextAlign.justify), - }), + subtitle: Text.rich( + TextSpan(children: snippetSpans), + maxLines: null, // אין הגבלה על מספר השורות! + textAlign: TextAlign.justify, + textDirection: TextDirection.rtl, + ), ); }, ); From a4cb3397bc7ce3c7b414013bc579391bf11aef25 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 17 Jul 2025 16:01:25 +0300 Subject: [PATCH 010/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=A7?= =?UTF-8?q?=D7=99=D7=A6=D7=95=D7=A8=D7=99=20=D7=A9=D7=9E=D7=95=D7=AA=20?= =?UTF-8?q?=D7=9C=D7=90=D7=99=D7=AA=D7=95=D7=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/utils/text_manipulation.dart | 277 ++++++++++++++++++++++++++++--- 1 file changed, 258 insertions(+), 19 deletions(-) diff --git a/lib/utils/text_manipulation.dart b/lib/utils/text_manipulation.dart index e28f36c66..30502f6d1 100644 --- a/lib/utils/text_manipulation.dart +++ b/lib/utils/text_manipulation.dart @@ -63,8 +63,8 @@ String removeSectionNames(String s) => s .replaceAll('סימן ', '') .replaceAll('הלכה ', '') .replaceAll('מאמר ', '') - .replaceAll('קטן ', '') - .replaceAll('משנה ', '') + .replaceAll('קטן ', '') + .replaceAll('משנה ', '') .replaceAll('"', '') .replaceAll("'", '') .replaceAll(',', '') @@ -73,27 +73,266 @@ String removeSectionNames(String s) => s String replaceParaphrases(String s) { s = s - .replaceAll(' שוע', ' שולחן ערוך') + .replaceAll(' מהדורא תנינא', ' מהדו"ת') + .replaceAll(' מהדורא', ' מהדורה') + .replaceAll(' מהדורה', ' מהדורא') + .replaceAll(' פני', ' פני יהושע') + .replaceAll(' תניינא', ' תנינא') + .replaceAll(' תנינא', ' תניינא') + .replaceAll(' אא', ' אשל אברהם') + .replaceAll(' אבהע', ' אבן העזר') + .replaceAll(' אבעז', ' אבן עזרא') + .replaceAll(' אדז', ' אדרא זוטא') + .replaceAll(' אדרא רבה', ' אדרא') + .replaceAll(' אדרות', ' אדרא') + .replaceAll(' אהע', ' אבן העזר') + .replaceAll(' אהעז', ' אבן העזר') + .replaceAll(' אוהח', ' אור החיים') + .replaceAll(' אוח', ' אורח חיים') + .replaceAll(' אורח', ' אורח חיים') + .replaceAll(' אידרא', ' אדרא') + .replaceAll(' אידרות', ' אדרא') + .replaceAll(' ארבעה טורים', ' טור') + .replaceAll(' באהג', ' באר הגולה') + .replaceAll(' באוה', ' ביאור הלכה') + .replaceAll(' באוהל', ' ביאור הלכה') + .replaceAll(' באור הלכה', ' ביאור הלכה') .replaceAll(' בב', ' בבא בתרא') + .replaceAll(' בהגרא', ' ביאור הגרא') + .replaceAll(' בי', ' ביאור') + .replaceAll(' בי', ' בית יוסף') + .replaceAll(' ביאהל', ' ביאור הלכה') + .replaceAll(' ביאו', ' ביאור') + .replaceAll(' ביאוה', ' ביאור הלכה') + .replaceAll(' ביאוהג', ' ביאור הגרא') + .replaceAll(' ביאוהל', ' ביאור הלכה') + .replaceAll(' ביהגרא', ' ביאור הגרא') + .replaceAll(' ביהל', ' בית הלוי') + .replaceAll(' במ', ' בבא מציעא') + .replaceAll(' במדבר', ' במדבר רבה') + .replaceAll(' במח', ' באר מים חיים') + .replaceAll(' במר', ' במדבר רבה') + .replaceAll(' בעהט', ' בעל הטורים') .replaceAll(' בק', ' בבא קמא') - .replaceAll('אוח', 'אורח חיים') - .replaceAll(' יוד', ' יורה דעה') + .replaceAll(' בר', ' בראשית רבה') + .replaceAll(' ברר', ' בראשית רבה') + .replaceAll(' בש', ' בית שמואל') + .replaceAll(' ד', ' דף') + .replaceAll(' דבר', ' דברים רבה') + .replaceAll(' דהי', ' דברי הימים') + .replaceAll(' דויד', ' דוד') + .replaceAll(' דמ', ' דגול מרבבה') + .replaceAll(' דמ', ' דרכי משה') + .replaceAll(' דמר', ' דגול מרבבה') + .replaceAll(' דרך ה', ' דרך השם') + .replaceAll(' דרך פיקודיך', ' דרך פקודיך') + .replaceAll(' דרמ', ' דרכי משה') + .replaceAll(' דרפ', ' דרך פקודיך') + .replaceAll(' האריזל', ' הארי') + .replaceAll(' הגהות מיימוני', ' הגהות מיימוניות') + .replaceAll(' הגהות מימוניות', ' הגהות מיימוניות') + .replaceAll(' הגהמ', ' הגהות מיימוניות') + .replaceAll(' הגמ', ' הגהות מיימוניות') + .replaceAll(' הילכות', ' הלכות') + .replaceAll(' הל', ' הלכות') + .replaceAll(' הלכ', ' הלכות') + .replaceAll(' הלכה', ' הלכות') + .replaceAll(' המשנה', ' המשניות') + .replaceAll(' ויקר', ' ויקרא רבה') + .replaceAll(' ויר', ' ויקרא רבה') + .replaceAll(' זהח', ' זוהר חדש') + .replaceAll(' זהר חדש', ' זוהר חדש') + .replaceAll(' זהר', ' זוהר') + .replaceAll(' זוהח', ' זוהר חדש') + .replaceAll(' זח', ' זוהר חדש') + .replaceAll(' חדושי', ' חי') + .replaceAll(' חוד', ' חוות דעת') + .replaceAll(' חוהל', ' חובת הלבבות') + .replaceAll(' חווד', ' חוות דעת') .replaceAll(' חומ', ' חושן משפט') - .replaceAll('משנה תורה', 'רמבם') - .replaceAll(' במ', 'בבא מציעא') - .replaceAll('אהעז', 'אבן העזר') - .replaceAll('שך', 'שפתי כהן') - .replaceAll('סמע', 'מאירת עינים') - .replaceAll('בש', 'בית שמואל') - .replaceAll('קצהח', 'קצות החושן') - .replaceAll('נתיהמ', 'נתיבות המשפט') - .replaceAll('פתש', 'פתחי תשובה') - .replaceAll('משנב', 'משנה ברורה') - .replaceAll('שטמק', 'שיטה מקובצת') - .replaceAll('פמג', 'פרי מגדים') - .replaceAll('פרמג', 'פרי מגדים') + .replaceAll(' חח', ' חפץ חיים') + .replaceAll(' חי', ' חדושי') + .replaceAll(' חידושי אגדות', ' חדושי אגדות') + .replaceAll(' חידושי הלכות', ' חדושי הלכות') + .replaceAll(' חידושי', ' חדושי') + .replaceAll(' חידושי', ' חי') + .replaceAll(' חתס', ' חתם סופר') + .replaceAll(' יד החזקה', ' רמבם') + .replaceAll(' יהושוע', ' יהושע') + .replaceAll(' יוד', ' יורה דעה') + .replaceAll(' יוט', ' יום טוב') + .replaceAll(' יורד', ' יורה דעה') + .replaceAll(' ילקוט', ' ילקוט שמעוני') + .replaceAll(' ילקוש', ' ילקוט שמעוני') + .replaceAll(' ילקש', ' ילקוט שמעוני') + .replaceAll(' ירוש', ' ירושלמי') + .replaceAll(' ירמי', ' ירמיהו') + .replaceAll(' ירמיה', ' ירמיהו') + .replaceAll(' ישעי', ' ישעיהו') + .replaceAll(' ישעיה', ' ישעיהו') + .replaceAll(' כופ', ' כרתי ופלתי') + .replaceAll(' כפ', ' כרתי ופלתי') + .replaceAll(' כרופ', ' כרתי ופלתי') + .replaceAll(' כתס', ' כתב סופר') + .replaceAll(' לחמ', ' לחם משנה') + .replaceAll(' ליקוטי אמרים', ' תניא') + .replaceAll(' מ', ' משנה') + .replaceAll(' מאוש', ' מאור ושמש') + .replaceAll(' מב', ' משנה ברורה') + .replaceAll(' מגא', ' מגיני ארץ') + .replaceAll(' מגא', ' מגן אברהם') + .replaceAll(' מגילת', ' מגלת') + .replaceAll(' מגמ', ' מגיד משנה') + .replaceAll(' מד רבה', ' מדרש רבה') + .replaceAll(' מד', ' מדרש') + .replaceAll(' מדות', ' מידות') + .replaceAll(' מדר', ' מדרש רבה') + .replaceAll(' מדר', ' מדרש') + .replaceAll(' מדרש רבא', ' מדרש רבה') + .replaceAll(' מדת', ' מדרש תהלים') + .replaceAll(' מהרשא', ' חדושי אגדות') + .replaceAll(' מהרשא', ' חדושי הלכות') + .replaceAll(' מונ', ' מורה נבוכים') + .replaceAll(' מז', ' משבצות זהב') + .replaceAll(' ממ', ' מגיד משנה') + .replaceAll(' מסי', ' מסילת ישרים') + .replaceAll(' מפרג', ' מפראג') + .replaceAll(' מקוח', ' מקור חיים') + .replaceAll(' מרד', ' מרדכי') + .replaceAll(' משבז', ' משבצות זהב') + .replaceAll(' משנב', ' משנה ברורה') + .replaceAll(' משנה תורה', ' רמבם') + .replaceAll(' משנה', ' משניות') + .replaceAll(' נהמ', ' נתיבות המשפט') + .replaceAll(' נובי', ' נודע ביהודה') + .replaceAll(' נובית', ' נודע ביהודה תניא') + .replaceAll(' נועא', ' נועם אלימלך') + .replaceAll(' נפהח', ' נפש החיים') + .replaceAll(' נפש החים', ' נפש החיים') + .replaceAll(' נתיבוש', ' נתיבות שלום') + .replaceAll(' נתיהמ', ' נתיבות המשפט') + .replaceAll(' ס', ' סעיף') + .replaceAll(' סדצ', ' ספרא דצניעותא') + .replaceAll(' סהמ', ' ספר המצוות') + .replaceAll(' סהמצ', ' ספר המצוות') + .replaceAll(' סי', ' סימן') + .replaceAll(' סמע', ' מאירת עינים') + .replaceAll(' סע', ' סעיף') + .replaceAll(' סעי', ' סעיף') + .replaceAll(' ספדצ', ' ספרא דצניעותא') + .replaceAll(' ספהמצ', ' ספר המצוות') + .replaceAll(' ספר המצות', ' ספר המצוות') + .replaceAll(' ספרא', ' תורת כהנים') + .replaceAll(' ע"מ', ' עמוד') + .replaceAll(' עא', ' עמוד א') + .replaceAll(' עב', ' עמוד ב') + .replaceAll(' עהש', ' ערוך השולחן') + .replaceAll(' עח', ' עץ חיים') + .replaceAll(' עי', ' עין יעקב') + .replaceAll(' ערהש', ' ערוך השולחן') + .replaceAll(' ערוך השלחן', ' ערוך השולחן') + .replaceAll(' פ', ' פרק') + .replaceAll(' פי', ' פירוש') + .replaceAll(' פיהמ', ' פירוש המשניות') + .replaceAll(' פיהמש', ' פירוש המשניות') + .replaceAll(' פיסקי', ' פסקי') + .replaceAll(' פירו', ' פירוש') + .replaceAll(' פירוש המשנה', ' פירוש המשניות') + .replaceAll(' פמג', ' פרי מגדים') + .replaceAll(' פסז', ' פסיקתא זוטרתא') + .replaceAll(' פסיקתא זוטא', ' פסיקתא זוטרתא') + .replaceAll(' פסיקתא רבה', ' פסיקתא רבתי') + .replaceAll(' פסר', ' פסיקתא רבתי') + .replaceAll(' פעח', ' פרי עץ חיים') .replaceAll(' פרח', ' פרי חדש') - .replaceAll(' שע', ' שולחן ערוך'); + .replaceAll(' צפנפ', ' צפנת פענח') + .replaceAll(' קדושל', ' קדושת לוי') + .replaceAll(' קוא', ' קול אליהו') + .replaceAll(' קידושין', ' קדושין') + .replaceAll(' קיצור', ' קצור') + .replaceAll(' קצהח', ' קצות החושן') + .replaceAll(' קצוהח', ' קצות החושן') + .replaceAll(' קצור', ' קיצור') + .replaceAll(' קצשוע', ' קיצור שולחן ערוך') + .replaceAll(' קשוע', ' קיצור שולחן ערוך') + .replaceAll(' ר חיים', ' הגרח') + .replaceAll(' ר', ' רבי') + .replaceAll(' רא בהרמ', ' רבי אברהם בן הרמבם') + .replaceAll(' ראבע', ' אבן עזרא') + .replaceAll(' ראשיח', ' ראשית חכמה') + .replaceAll(' רבה', ' מדרש רבה') + .replaceAll(' רבה', ' רבא') + .replaceAll(' רבי חיים', ' הגרח') + .replaceAll(' רבי נחמן', ' מוהרן') + .replaceAll(' רבי נתן', ' מוהרנת') + .replaceAll(' רבינו חיים', ' הגרח') + .replaceAll(' רבינו', ' רבי') + .replaceAll(' רבנו', ' רבי') + .replaceAll(' רבנו', ' רבינו') + .replaceAll(' רח', ' רבנו חננאל') + .replaceAll(' ריהל', ' רבי יהודה הלוי') + .replaceAll(' רעא', ' רבי עקיבא איגר') + .replaceAll(' רעמ', ' רעיא מהימנא') + .replaceAll(' רעקא', ' רבי עקיבא איגר') + .replaceAll(' שבהל', ' שבלי הלקט') + .replaceAll(' שהג', ' שער הגלגולים') + .replaceAll(' שהש', ' שיר השירים') + .replaceAll(' שולחן ערוך הגרז', ' שולחן ערוך הרב') + .replaceAll(' שוע הגאון רבי זלמן', ' שוע הגרז') + .replaceAll(' שוע הגאון רבי זלמן', ' שוע הרב') + .replaceAll(' שוע הגרז', ' שוע הרב') + .replaceAll(' שוע הרב', ' שולחן ערוך הרב') + .replaceAll(' שוע הרב', ' שוע הגרז') + .replaceAll(' שוע', ' שולחן ערוך') + .replaceAll(' שורש', ' שרש') + .replaceAll(' שורשים', ' שרשים') + .replaceAll(' שות', ' תשו') + .replaceAll(' שות', ' תשובה') + .replaceAll(' שות', ' תשובות') + .replaceAll(' שטה מקובצת', ' שיטה מקובצת') + .replaceAll(' שטמק', ' שיטה מקובצת') + .replaceAll(' שיהש', ' שיר השירים') + .replaceAll(' שיטמק', ' שיטה מקובצת') + .replaceAll(' שך', ' שפתי כהן') + .replaceAll(' שלחן ערוך', ' שולחן ערוך') + .replaceAll(' שמור', ' שמות רבה') + .replaceAll(' שמטה', ' שמיטה') + .replaceAll(' שמיהל', ' שמירת הלשון') + .replaceAll(' שע', ' שולחן ערוך') + .replaceAll(' שעק', ' שערי קדושה') + .replaceAll(' שעת', ' שערי תשובה') + .replaceAll(' שפח', ' שפתי חכמים') + .replaceAll(' שפתח', ' שפתי חכמים') + .replaceAll(' תבואש', ' תבואות שור') + .replaceAll(' תבוש', ' תבואות שור') + .replaceAll(' תהילים', ' תהלים') + .replaceAll(' תהלים', ' תהילים') + .replaceAll(' תוכ', ' תורת כהנים') + .replaceAll(' תומד', ' תומר דבורה') + .replaceAll(' תוס', ' תוספות') + .replaceAll(' תוס', ' תוספתא') + .replaceAll(' תוספ', ' תוספתא') + .replaceAll(' תנדא', ' תנא דבי אליהו') + .replaceAll(' תנדבא', ' תנא דבי אליהו') + .replaceAll(' תנח', ' תנחומא') + .replaceAll(' תקוז', ' תיקוני זוהר') + .replaceAll(' תשו', ' שות') + .replaceAll(' תשו', ' תשובה') + .replaceAll(' תשו', ' תשובות') + .replaceAll(' תשובה', ' שות') + .replaceAll(' תשובה', ' תשו') + .replaceAll(' תשובה', ' תשובות') + .replaceAll(' תשובות', ' שות') + .replaceAll(' תשובות', ' תשו') + .replaceAll(' תשובות', ' תשובה') + .replaceAll(' תשובת', ' שות') + .replaceAll(' תשובת', ' תשו') + .replaceAll(' תשובת', ' תשובה') + .replaceAll(' תשובת', ' תשובות') + .replaceAll('משנב', ' משנה ברורה ') + .replaceAll('פרמג', ' פרי מגדים ') + .replaceAll('פתש', ' פתחי תשובה ') + .replaceAll('שטמק', ' שיטה מקובצת '); if (s.startsWith("טז")) { s = s.replaceFirst("טז", "טורי זהב"); From 43085985d195e30d5db8e2fb5a4d3b22dcfce1b2 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 18 Jul 2025 00:14:32 +0300 Subject: [PATCH 011/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=A2?= =?UTF-8?q?=D7=9C=D7=99=D7=99=D7=AA=20=D7=94=D7=93=D7=92=D7=A9=D7=AA=20?= =?UTF-8?q?=D7=94=D7=9B=D7=95=D7=AA=D7=A8=D7=AA=20=D7=9C=D7=9E=D7=A2=D7=9C?= =?UTF-8?q?=D7=94=20=D7=9E=D7=9E=D7=A7=D7=95=D7=9E=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/text_book/view/toc_navigator_screen.dart | 49 ++++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/lib/text_book/view/toc_navigator_screen.dart b/lib/text_book/view/toc_navigator_screen.dart index e4e305215..81818b179 100644 --- a/lib/text_book/view/toc_navigator_screen.dart +++ b/lib/text_book/view/toc_navigator_screen.dart @@ -107,10 +107,12 @@ final TextEditingController searchController = TextEditingController(); return Padding( padding: EdgeInsets.fromLTRB( 0, 0, 10 * allEntries[index].level.toDouble(), 0), - child: allEntries[index].children.isEmpty - ? ListTile( - title: Text(allEntries[index].fullText), - onTap: () { + child: allEntries[index].children.isEmpty + ? Material( + color: Colors.transparent, + child: ListTile( + title: Text(allEntries[index].fullText), + onTap: () { setState(() { _isManuallyScrolling = false; _lastScrolledTocIndex = null; @@ -124,7 +126,8 @@ final TextEditingController searchController = TextEditingController(); widget.closeLeftPaneCallback(); } }, - ) + ), + ) : _buildTocItem(allEntries[index], showFullText: true), ); }); @@ -163,13 +166,16 @@ final TextEditingController searchController = TextEditingController(); ((state.selectedIndex != null && state.selectedIndex == entry.index) || autoIndex == entry.index); - return ListTile( - title: Text(entry.text), - selected: selected, - selectedColor: Theme.of(context).colorScheme.onSecondaryContainer, - selectedTileColor: - Theme.of(context).colorScheme.secondaryContainer, - onTap: navigateToEntry, + return Material( + color: Colors.transparent, + child: ListTile( + title: Text(entry.text), + selected: selected, + selectedColor: Theme.of(context).colorScheme.onSecondaryContainer, + selectedTileColor: + Theme.of(context).colorScheme.secondaryContainer, + onTap: navigateToEntry, + ), ); }, ), @@ -196,14 +202,17 @@ final TextEditingController searchController = TextEditingController(); ((state.selectedIndex != null && state.selectedIndex == entry.index) || autoIndex == entry.index); - return ListTile( - title: Text(showFullText ? entry.fullText : entry.text), - selected: selected, - selectedColor: Theme.of(context).colorScheme.onSecondary, - selectedTileColor: - Theme.of(context).colorScheme.secondary.withOpacity(0.2), - onTap: navigateToEntry, - contentPadding: EdgeInsets.zero, + return Material( + color: Colors.transparent, + child: ListTile( + title: Text(showFullText ? entry.fullText : entry.text), + selected: selected, + selectedColor: Theme.of(context).colorScheme.onSecondary, + selectedTileColor: + Theme.of(context).colorScheme.secondary.withOpacity(0.2), + onTap: navigateToEntry, + contentPadding: EdgeInsets.zero, + ), ); }, ), From e37e5fb47ab7d83f613474c51de286552529953e Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 18 Jul 2025 17:37:58 +0300 Subject: [PATCH 012/197] =?UTF-8?q?=D7=91=D7=99=D7=98=D7=95=D7=9C=20=D7=A1?= =?UTF-8?q?=D7=A0=D7=9B=D7=A8=D7=95=D7=9F=20=D7=A1=D7=A4=D7=A8=D7=99=20?= =?UTF-8?q?=D7=93=D7=99=D7=A7=D7=98=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/file_sync/file_sync_bloc.dart | 25 +-- lib/file_sync/file_sync_repository.dart | 285 +++++++++++------------- lib/library/view/library_browser.dart | 23 +- lib/settings/settings_screen.dart | 9 - 4 files changed, 143 insertions(+), 199 deletions(-) diff --git a/lib/file_sync/file_sync_bloc.dart b/lib/file_sync/file_sync_bloc.dart index 2b24502d3..2794e48e7 100644 --- a/lib/file_sync/file_sync_bloc.dart +++ b/lib/file_sync/file_sync_bloc.dart @@ -7,11 +7,9 @@ import 'package:otzaria/file_sync/file_sync_repository.dart'; class FileSyncBloc extends Bloc { final FileSyncRepository repository; - final FileSyncRepository? dictaRepository; Timer? _progressTimer; - FileSyncBloc({required this.repository, this.dictaRepository}) - : super(const FileSyncState()) { + FileSyncBloc({required this.repository}) : super(const FileSyncState()) { on(_onStartSync); on(_onStopSync); on(_onUpdateProgress); @@ -36,7 +34,7 @@ class FileSyncBloc extends Bloc { message: 'מסנכרן...', )); - // Set up a timer to update progress periodically for the main repository + // Set up a timer to update progress periodically _progressTimer?.cancel(); _progressTimer = Timer.periodic(const Duration(milliseconds: 100), (_) { if (repository.isSyncing && repository.totalFiles > 0) { @@ -48,25 +46,9 @@ class FileSyncBloc extends Bloc { }); try { - int successCount = await repository.syncFiles(); + final successCount = await repository.syncFiles(); _progressTimer?.cancel(); - // Sync Dicta books if enabled and repository provided - if (dictaRepository != null && - (Settings.getValue('key-sync-dicta-books') ?? false)) { - _progressTimer = Timer.periodic(const Duration(milliseconds: 100), (_) { - if (dictaRepository!.isSyncing && dictaRepository!.totalFiles > 0) { - add(UpdateProgress( - current: dictaRepository!.currentProgress, - total: dictaRepository!.totalFiles, - )); - } - }); - - successCount += await dictaRepository!.syncFiles(); - _progressTimer?.cancel(); - } - if (successCount > 0) { emit(state.copyWith( status: FileSyncStatus.completed, @@ -93,7 +75,6 @@ class FileSyncBloc extends Bloc { void _onStopSync(StopSync event, Emitter emit) { _progressTimer?.cancel(); repository.stopSyncing(); - dictaRepository?.stopSyncing(); emit(const FileSyncState()); } diff --git a/lib/file_sync/file_sync_repository.dart b/lib/file_sync/file_sync_repository.dart index 502e29c9c..92fc39f20 100644 --- a/lib/file_sync/file_sync_repository.dart +++ b/lib/file_sync/file_sync_repository.dart @@ -2,13 +2,11 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:http/http.dart' as http; -import 'package:path/path.dart' as p; class FileSyncRepository { final String githubOwner; final String repositoryName; final String branch; - final String manifestFileName; bool isSyncing = false; int _currentProgress = 0; int _totalFiles = 0; @@ -17,29 +15,18 @@ class FileSyncRepository { required this.githubOwner, required this.repositoryName, this.branch = 'main', - this.manifestFileName = 'files_manifest.json', }); int get currentProgress => _currentProgress; int get totalFiles => _totalFiles; - Future get _basePath async => - Settings.getValue('key-library-path') ?? 'C:/אוצריא'; - - // --- שיפור 2: שימוש ב-path package לבדיקה אמינה --- - Future get _targetDir async { - final base = await _basePath; - // בדיקה חסינה לשגיאות: לא רגישה לאותיות גדולות/קטנות או לקו נטוי בסוף - if (p.basename(base).toLowerCase() == 'אוצריא') { - return base; - } - // --- שיפור 3: שימוש ב-path.join ליצירת נתיב תקני --- - return p.join(base, 'אוצריא'); + Future get _localManifestPath async { + final directory = _localDirectory; + return '${await directory}${Platform.pathSeparator}files_manifest.json'; } - Future get _localManifestPath async { - // --- שיפור 3: שימוש ב-path.join --- - return p.join(await _targetDir, manifestFileName); + Future get _localDirectory async { + return Settings.getValue('key-library-path') ?? 'C:/אוצריא'; } Future> _getLocalManifest() async { @@ -47,24 +34,28 @@ class FileSyncRepository { final file = File(path); try { if (!await file.exists()) { + // ---- תוספת חשובה ---- // + // אם הקובץ הראשי לא קיים, בדוק אם נשאר גיבוי מתהליך שנכשל final oldFile = File('$path.old'); if (await oldFile.exists()) { print('Main manifest missing, restoring from .old backup...'); - await oldFile.rename(path); + await oldFile.rename(path); // שחזר את הגיבוי + // עכשיו הקובץ הראשי קיים, נמשיך כרגיל } else { - return {}; + return {}; // אם גם גיבוי אין, באמת אין מניפסט } } final content = await file.readAsString(encoding: utf8); return json.decode(content); } catch (e) { print('Error reading local manifest: $e'); - final oldFile = File('$path.old'); + // הלוגיקה שלך לגיבוי מ-.bak הייתה טובה, נתאים אותה ל-.old + final oldFile = File('$path.old'); // השתמש ב-.old במקום .bak if (await oldFile.exists()) { try { print('Main manifest is corrupt, restoring from .old backup...'); final backupContent = await oldFile.readAsString(encoding: utf8); - await oldFile.rename(path); + await oldFile.rename(path); // rename בטוח יותר מ-copy return json.decode(backupContent); } catch (_) {} } @@ -73,11 +64,18 @@ class FileSyncRepository { } Future> _getRemoteManifest() async { - final url = 'https://raw.githubusercontent.com/$githubOwner/$repositoryName/$branch/$manifestFileName'; + final url = + 'https://raw.githubusercontent.com/$githubOwner/$repositoryName/$branch/files_manifest.json'; try { - final response = await http.get(Uri.parse(url), - headers: {'Accept': 'application/json', 'Accept-Charset': 'utf-8'}); + final response = await http.get( + Uri.parse(url), + headers: { + 'Accept': 'application/json', + 'Accept-Charset': 'utf-8', + }, + ); if (response.statusCode == 200) { + // Explicitly decode as UTF-8 return json.decode(utf8.decode(response.bodyBytes)); } throw Exception('Failed to fetch remote manifest'); @@ -87,79 +85,36 @@ class FileSyncRepository { } } - Future downloadFile(String filePath) async { - // filePath מגיע מהמניפסט ומתחיל ב "דיקטה/..." - - // 1. הגדר את הקידומת האמיתית של הנתיב במאגר - const String remotePathPrefix = 'DictaToOtzaria/ספרים/לא ערוך/'; - - // 2. בנה את הנתיב המלא כפי שהוא קיים ב-GitHub - // אנחנו מורידים את ה'דיקטה' מההתחלה כי הוא כבר חלק מהקידומת - final String actualRemotePath = remotePathPrefix + filePath.replaceFirst('דיקטה/', ''); - - // 3. נרמל את הנתיב לשימוש ב-URL (החלף \ ב-/) - final urlPath = actualRemotePath.replaceAll('\\', '/'); - + Future downloadFile(String filePath) async { final url = - 'https://raw.githubusercontent.com/$githubOwner/$repositoryName/$branch/$urlPath'; - - print('--> Downloading: $url'); - + 'https://raw.githubusercontent.com/$githubOwner/$repositoryName/$branch/$filePath'; try { - final response = await http - .get(Uri.parse(url), headers: {'Accept-Charset': 'utf-8'}); - - print('<-- Response Status: ${response.statusCode} for $urlPath'); - + final response = await http.get( + Uri.parse(url), + headers: { + 'Accept-Charset': 'utf-8', + }, + ); if (response.statusCode == 200) { - // 4. שמור את הקובץ מקומית תחת הנתיב הקצר והיפה (filePath) - final directory = await _targetDir; - final file = File(p.join(directory, filePath)); // filePath הוא "דיקטה/אוצריא/..." + final directory = await _localDirectory; + final file = File('$directory/$filePath'); + // Create directories if they don't exist await file.parent.create(recursive: true); + // For text files, handle UTF-8 encoding explicitly if (filePath.endsWith('.txt') || filePath.endsWith('.json') || filePath.endsWith('.csv')) { await file.writeAsString(utf8.decode(response.bodyBytes), encoding: utf8); } else { + // For binary files, write bytes directly await file.writeAsBytes(response.bodyBytes); } - - return true; - } else { - return false; } } catch (e) { - print('!!! Network or File System Error for $urlPath: $e'); - return false; - } - } - - Future _updateLocalManifestForFile(String filePath, Map fileInfo) async { - try { - Map localManifest = await _getLocalManifest(); - localManifest[filePath] = fileInfo; - await _writeManifest(localManifest); - } catch (e) { - print('Error updating local manifest for file $filePath: $e'); - } - } - - Future _removeFromLocal(String filePath) async { - try { - final directory = await _targetDir; - // --- שיפור 3: שימוש ב-path.join --- - final file = File(p.join(directory, filePath)); - if (await file.exists()) { - await file.delete(); - } - Map localManifest = await _getLocalManifest(); - localManifest.remove(filePath); - await _writeManifest(localManifest); - } catch (e) { - print('Error removing file $filePath from local manifest: $e'); + print('Error downloading file $filePath: $e'); } } @@ -167,66 +122,110 @@ class FileSyncRepository { final path = await _localManifestPath; final file = File(path); final tempFile = File('$path.tmp'); - final oldFile = File('$path.old'); + // נשתמש ב- .old כפי שהוצע, זה עקבי וברור + final oldFile = File('$path.old'); try { - // ודא שהתיקייה קיימת בריצה ראשונה - await file.parent.create(recursive: true); - - // 1. כתיבה לקובץ זמני - await tempFile.writeAsString(json.encode(manifest), encoding: utf8); - - // 2. גיבוי הקובץ הישן (אם יש) + // 1. כותבים את המידע החדש לקובץ זמני. + // אם שלב זה נכשל, שום דבר לא קרה לקובץ המקורי. + await tempFile.writeAsString( + json.encode(manifest), + encoding: utf8, + ); + + // 2. אם הקובץ המקורי קיים, שנה את שמו לגיבוי. + // זו פעולה אטומית ומהירה. אם היא נכשלת, לא קרה כלום. + // אם היא מצליחה, המניפסט הישן בטוח בצד. if (await file.exists()) { await file.rename(oldFile.path); } - // 3. קידום הקובץ הזמני לשם הסופי (אטומי) + // 3. שנה את שם הקובץ הזמני לשם הקובץ הסופי. + // גם זו פעולה אטומית. אם היא נכשלת, המניפסט הישן עדיין קיים ב- .old + // וניתן לשחזר אותו. await tempFile.rename(path); - // 4. ניקוי הגיבוי אם הכול הצליח + // 4. אם הגענו לכאן, הכל הצליח. אפשר למחוק בבטחה את הגיבוי. if (await oldFile.exists()) { await oldFile.delete(); } } catch (e) { print('Error writing manifest: $e'); - - // ניסיון לשחזר את הגיבוי במידה והקובץ החדש לא נוצר + // במקרה של תקלה (למשל, אחרי ש-file.rename הצליח אבל tempFile.rename נכשל), + // ננסה לשחזר את המצב לקדמותו כדי למנוע מצב ללא מניפסט. try { if (await oldFile.exists() && !(await file.exists())) { print('Attempting to restore manifest from .old backup...'); await oldFile.rename(path); } } catch (restoreError) { - print('FATAL: Could not restore manifest: $restoreError'); + print('FATAL: Could not restore manifest from backup: $restoreError'); } - rethrow; + rethrow; // זרוק את השגיאה המקורית כדי שהפונקציה שקראה תדע שהעדכון נכשל. + } + } + + Future _updateLocalManifestForFile( + String filePath, Map fileInfo) async { + try { + Map localManifest = await _getLocalManifest(); + + // Update the manifest for this specific file + localManifest[filePath] = fileInfo; + + await _writeManifest(localManifest); + } catch (e) { + print('Error updating local manifest for file $filePath: $e'); + } + } + + Future _removeFromLocal(String filePath) async { + try { + // Try to remove the actual file if it exists + final directory = await _localDirectory; + final file = File('$directory/$filePath'); + if (await file.exists()) { + await file.delete(); + } + + //if successful, remove from manifest + Map localManifest = await _getLocalManifest(); + + // Remove the file from the manifest + localManifest.remove(filePath); + + await _writeManifest(localManifest); + } catch (e) { + print('Error removing file $filePath from local manifest: $e'); } } Future removeEmptyFolders() async { try { - final targetDirPath = await _targetDir; - final targetDir = Directory(targetDirPath); - if (!await targetDir.exists()) return; - - await _cleanEmptyDirectories(targetDir, targetDirPath); + final baseDir = Directory(await _localDirectory); + if (!await baseDir.exists()) return; + + // Bottom-up approach: process deeper directories first + await _cleanEmptyDirectories(baseDir); } catch (e) { print('Error removing empty folders: $e'); } } - Future _cleanEmptyDirectories(Directory dir, String rootPath) async { + Future _cleanEmptyDirectories(Directory dir) async { if (!await dir.exists()) return; + // First process all subdirectories await for (final entity in dir.list()) { if (entity is Directory) { - await _cleanEmptyDirectories(entity, rootPath); + await _cleanEmptyDirectories(entity); } } + // After cleaning subdirectories, check if this directory is now empty final contents = await dir.list().toList(); - if (contents.isEmpty && p.equals(dir.path, rootPath) == false) { // שימוש ב-p.equals לבדיקה בטוחה + final baseDir = await _localDirectory; + if (contents.isEmpty && dir.path != baseDir) { await dir.delete(); print('Removed empty directory: ${dir.path}'); } @@ -237,76 +236,56 @@ class FileSyncRepository { final remoteManifest = await _getRemoteManifest(); final filesToUpdate = []; + remoteManifest.forEach((filePath, remoteInfo) { - if (!localManifest.containsKey(filePath) || localManifest[filePath]['hash'] != remoteInfo['hash']) { + if (!localManifest.containsKey(filePath) || + localManifest[filePath]['hash'] != remoteInfo['hash']) { filesToUpdate.add(filePath); } }); - return filesToUpdate; - } - Future _migrateOldManifestIfNeeded() async { - try { - // --- שיפור 3: שימוש ב-path.join --- - final oldManifestPath = p.join(await _basePath, manifestFileName); - final newManifestPath = await _localManifestPath; - - final oldFile = File(oldManifestPath); - final newFile = File(newManifestPath); - - if (await oldFile.exists() && !(await newFile.exists())) { - print('Migrating old manifest to new location...'); - await newFile.parent.create(recursive: true); - await oldFile.rename(newManifestPath); - print('Migration successful.'); - } - } catch (e) { - print('Could not migrate old manifest: $e'); - } + return filesToUpdate; } Future syncFiles() async { - if (isSyncing) return 0; - + if (isSyncing) { + return 0; + } isSyncing = true; int count = 0; _currentProgress = 0; - await _migrateOldManifestIfNeeded(); - try { final remoteManifest = await _getRemoteManifest(); final localManifest = await _getLocalManifest(); + + // Find files to update or add final filesToUpdate = await checkForUpdates(); - - final filesToRemove = localManifest.keys.where((k) => !remoteManifest.containsKey(k)).toList(); - _totalFiles = filesToUpdate.length + filesToRemove.length; + _totalFiles = filesToUpdate.length; - // --- לוגיקה חדשה בלולאה --- + // Download and update manifest for each file individually for (final filePath in filesToUpdate) { - if (!isSyncing) return count; - - // קבל את תוצאת ההורדה - final bool downloadedSuccessfully = await downloadFile(filePath); - - // עדכן את המניפסט והמונה רק אם ההורדה באמת הצליחה - if (downloadedSuccessfully) { - await _updateLocalManifestForFile(filePath, remoteManifest[filePath]); - count++; - _currentProgress = count; + if (isSyncing == false) { + return count; } - // אם ההורדה נכשלה, לא נעשה כלום. המניפסט לא יתעדכן, - // והמערכת תנסה להוריד את הקובץ שוב בסנכרון הבא. - } - - for (final localFilePath in filesToRemove) { - if (!isSyncing) return count; - await _removeFromLocal(localFilePath); - // הערה: כאן המונה יכול להתקדם גם אם המחיקה לא הצליחה, זה פחות קריטי + await downloadFile(filePath); + await _updateLocalManifestForFile(filePath, remoteManifest[filePath]); count++; _currentProgress = count; } - + + // Remove files that exist locally but not in remote + for (final localFilePath in localManifest.keys.toList()) { + if (isSyncing == false) { + return count; + } + if (!remoteManifest.containsKey(localFilePath)) { + await _removeFromLocal(localFilePath); + count++; + _currentProgress = count; + } + } + // Clean up empty folders after sync await removeEmptyFolders(); } catch (e) { isSyncing = false; @@ -320,4 +299,4 @@ class FileSyncRepository { Future stopSyncing() async { isSyncing = false; } -} \ No newline at end of file +} diff --git a/lib/library/view/library_browser.dart b/lib/library/view/library_browser.dart index ba99225e6..615b6da8f 100644 --- a/lib/library/view/library_browser.dart +++ b/lib/library/view/library_browser.dart @@ -107,12 +107,6 @@ class _LibraryBrowserState extends State repositoryName: "otzaria-library", branch: "main", ), - dictaRepository: FileSyncRepository( - githubOwner: "zevisvei", - repositoryName: "otzaria-library", - branch: "main", - manifestFileName: 'dicta_files_manifest.json', - ), ), child: const SyncIconButton(), ), @@ -180,7 +174,8 @@ class _LibraryBrowserState extends State Expanded( child: TextField( controller: focusRepository.librarySearchController, - focusNode: context.read().librarySearchFocusNode, + focusNode: + context.read().librarySearchFocusNode, autofocus: true, decoration: InputDecoration( constraints: const BoxConstraints(maxWidth: 400), @@ -416,19 +411,17 @@ class _LibraryBrowserState extends State context.read().add(AddTab(PdfBookTab( book: book, pageNumber: 1, - openLeftPane: - (Settings.getValue('key-pin-sidebar') ?? false) || - (Settings.getValue('key-default-sidebar-open') ?? - false), + openLeftPane: (Settings.getValue('key-pin-sidebar') ?? + false) || + (Settings.getValue('key-default-sidebar-open') ?? false), ))); } else if (book is TextBook) { context.read().add(AddTab(TextBookTab( book: book, index: 0, - openLeftPane: - (Settings.getValue('key-pin-sidebar') ?? false) || - (Settings.getValue('key-default-sidebar-open') ?? - false), + openLeftPane: (Settings.getValue('key-pin-sidebar') ?? + false) || + (Settings.getValue('key-default-sidebar-open') ?? false), ))); } context.read().add(const NavigateToScreen(Screen.reading)); diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index ef7dd7e46..9040e8333 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -450,15 +450,6 @@ class _MySettingsScreenState extends State disabledLabel: 'מאגר הספרים לא יתעדכן אוטומטית.', activeColor: Theme.of(context).cardColor, ), - SwitchSettingsTile( - title: 'סנכרון ספרי דיקטה', - leading: Icon(Icons.book), - settingKey: 'key-sync-dicta-books', - defaultValue: false, - enabledLabel: 'ספרי דיקטה יסונכרנו יחד עם הספרייה', - disabledLabel: 'לא יסונכרנו ספרי דיקטה', - activeColor: Theme.of(context).cardColor, - ), _buildColumns(2, [ BlocBuilder( builder: (context, indexingState) { From 84947e07173bb5df0edb7f2b5ff28b188968f473 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sat, 19 Jul 2025 23:36:56 +0300 Subject: [PATCH 013/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=A9?= =?UTF-8?q?=D7=99=D7=A0=D7=95=D7=99=20=D7=A8=D7=95=D7=97=D7=91=20=D7=A1?= =?UTF-8?q?=D7=A8=D7=92=D7=9C=20=D7=A6=D7=93=20=D7=99=D7=9E=D7=A0=D7=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pdf_book/pdf_book_screen.dart | 2 +- lib/text_book/view/text_book_screen.dart | 216 ++++++++++++----------- 2 files changed, 111 insertions(+), 107 deletions(-) diff --git a/lib/pdf_book/pdf_book_screen.dart b/lib/pdf_book/pdf_book_screen.dart index b7bf56222..d0d4afea1 100644 --- a/lib/pdf_book/pdf_book_screen.dart +++ b/lib/pdf_book/pdf_book_screen.dart @@ -344,7 +344,7 @@ class _PdfBookScreenState extends State child: GestureDetector( behavior: HitTestBehavior.translucent, onHorizontalDragUpdate: (details) { - final newWidth = (_sidebarWidth.value + + final newWidth = (_sidebarWidth.value - details.delta.dx) .clamp(200.0, 600.0); _sidebarWidth.value = newWidth; diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index 4ac6fe43a..0a4418a86 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -567,7 +567,8 @@ class _TextBookViewerBlocState extends State style: TextStyle( fontSize: fontSize, fontFamily: - Settings.getValue('key-font-family') ?? 'candara', + Settings.getValue('key-font-family') ?? + 'candara', ), onSelectionChanged: (selection, cause) { if (selection.start != selection.end) { @@ -591,7 +592,10 @@ class _TextBookViewerBlocState extends State alignment: Alignment.centerRight, child: Text( 'פירוט הטעות (אופציונלי):', - style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontWeight: FontWeight.bold), ), ), const SizedBox(height: 4), @@ -643,25 +647,25 @@ class _TextBookViewerBlocState extends State return AlertDialog( title: const Text('דיווח על טעות בספר'), content: SingleChildScrollView( - child: Column( + child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'הטקסט שנבחר:', - style: TextStyle(fontWeight: FontWeight.bold), - ), - Text(reportData.selectedText), - const SizedBox(height: 16), - if (reportData.errorDetails.isNotEmpty) ...[ + children: [ const Text( - 'פירוט הטעות:', + 'הטקסט שנבחר:', style: TextStyle(fontWeight: FontWeight.bold), ), - Text(reportData.errorDetails), + Text(reportData.selectedText), + const SizedBox(height: 16), + if (reportData.errorDetails.isNotEmpty) ...[ + const Text( + 'פירוט הטעות:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(reportData.errorDetails), + ], ], - ], - ), + ), ), actions: [ TextButton( @@ -707,106 +711,104 @@ class _TextBookViewerBlocState extends State '''; } -/// שמירת דיווח לקובץ בתיקייה הראשית של הספרייה (libraryPath). -Future _saveReportToFile(String reportContent) async { - try { - final libraryPath = Settings.getValue('key-library-path'); + /// שמירת דיווח לקובץ בתיקייה הראשית של הספרייה (libraryPath). + Future _saveReportToFile(String reportContent) async { + try { + final libraryPath = Settings.getValue('key-library-path'); + + if (libraryPath == null || libraryPath.isEmpty) { + debugPrint('libraryPath not set; cannot save report.'); + return false; + } + + final filePath = '$libraryPath${Platform.pathSeparator}$_reportFileName'; + final file = File(filePath); + + final exists = await file.exists(); - if (libraryPath == null || libraryPath.isEmpty) { - debugPrint('libraryPath not set; cannot save report.'); + final sink = file.openWrite( + mode: exists ? FileMode.append : FileMode.write, + encoding: utf8, + ); + + // אם יש כבר תוכן קודם בקובץ קיים -> הוסף מפריד לפני הרשומה החדשה + if (exists && (await file.length()) > 0) { + sink.writeln(''); // שורת רווח + sink.writeln(_reportSeparator); + sink.writeln(''); // שורת רווח + } + + sink.write(reportContent); + await sink.flush(); + await sink.close(); + return true; + } catch (e) { + debugPrint('Failed saving report: $e'); return false; } + } - final filePath = - '$libraryPath${Platform.pathSeparator}$_reportFileName'; - final file = File(filePath); + /// סופר כמה דיווחים יש בקובץ – לפי המפריד. + Future _countReportsInFile() async { + try { + final libraryPath = Settings.getValue('key-library-path'); + if (libraryPath == null || libraryPath.isEmpty) return 0; - final exists = await file.exists(); + final filePath = '$libraryPath${Platform.pathSeparator}$_reportFileName'; + final file = File(filePath); + if (!await file.exists()) return 0; - final sink = file.openWrite( - mode: exists ? FileMode.append : FileMode.write, - encoding: utf8, - ); + final content = await file.readAsString(encoding: utf8); + if (content.trim().isEmpty) return 0; - // אם יש כבר תוכן קודם בקובץ קיים -> הוסף מפריד לפני הרשומה החדשה - if (exists && (await file.length()) > 0) { - sink.writeln(''); // שורת רווח - sink.writeln(_reportSeparator); - sink.writeln(''); // שורת רווח + final occurrences = _reportSeparator.allMatches(content).length; + return occurrences + 1; + } catch (e) { + debugPrint('countReports error: $e'); + return 0; } - - sink.write(reportContent); - await sink.flush(); - await sink.close(); - return true; - } catch (e) { - debugPrint('Failed saving report: $e'); - return false; } -} -/// סופר כמה דיווחים יש בקובץ – לפי המפריד. -Future _countReportsInFile() async { - try { - final libraryPath = Settings.getValue('key-library-path'); - if (libraryPath == null || libraryPath.isEmpty) return 0; - - final filePath = - '$libraryPath${Platform.pathSeparator}$_reportFileName'; - final file = File(filePath); - if (!await file.exists()) return 0; - - final content = await file.readAsString(encoding: utf8); - if (content.trim().isEmpty) return 0; - - final occurrences = _reportSeparator.allMatches(content).length; - return occurrences + 1; - } catch (e) { - debugPrint('countReports error: $e'); - return 0; + void _showSimpleSnack(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); } -} -void _showSimpleSnack(String message) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message)), - ); -} + /// SnackBar לאחר שמירה: מציג מונה + פעולה לפתיחת דוא"ל (mailto). + void _showSavedSnack(int count) { + if (!mounted) return; -/// SnackBar לאחר שמירה: מציג מונה + פעולה לפתיחת דוא"ל (mailto). -void _showSavedSnack(int count) { - if (!mounted) return; - - final message = - "הדיווח נשמר בהצלחה לקובץ '$_reportFileName', הנמצא בתיקייה הראשית של אוצריא.\n" - "יש לך כבר $count דיווחים, וכעת תוכל לשלוח את הקובץ למייל: $_fallbackMail!"; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - duration: const Duration(seconds: 8), - content: Text(message, textDirection: TextDirection.rtl), - action: SnackBarAction( - label: 'שלח', - onPressed: () { - _launchMail(_fallbackMail); - }, + final message = + "הדיווח נשמר בהצלחה לקובץ '$_reportFileName', הנמצא בתיקייה הראשית של אוצריא.\n" + "יש לך כבר $count דיווחים, וכעת תוכל לשלוח את הקובץ למייל: $_fallbackMail!"; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 8), + content: Text(message, textDirection: TextDirection.rtl), + action: SnackBarAction( + label: 'שלח', + onPressed: () { + _launchMail(_fallbackMail); + }, + ), ), - ), - ); -} + ); + } -Future _launchMail(String email) async { - final emailUri = Uri( - scheme: 'mailto', - path: email, - ); - try { - await launchUrl(emailUri, mode: LaunchMode.externalApplication); - } catch (e) { - _showSimpleSnack('לא ניתן לפתוח את תוכנת הדואר'); + Future _launchMail(String email) async { + final emailUri = Uri( + scheme: 'mailto', + path: email, + ); + try { + await launchUrl(emailUri, mode: LaunchMode.externalApplication); + } catch (e) { + _showSimpleSnack('לא ניתן לפתוח את תוכנת הדואר'); + } } -} Future> _getBookDetails(String bookTitle) async { try { @@ -895,7 +897,7 @@ Future _launchMail(String email) async { behavior: HitTestBehavior.translucent, onHorizontalDragUpdate: (details) { final newWidth = - (_sidebarWidth.value + details.delta.dx) + (_sidebarWidth.value - details.delta.dx) .clamp(200.0, 600.0); _sidebarWidth.value = newWidth; }, @@ -998,12 +1000,13 @@ Future _launchMail(String email) async { } } }); - return AnimatedSize( - duration: const Duration(milliseconds: 300), - child: SizedBox( - // קובעים את הרוחב כדי שהאנימציה תפעל על שינוי הרוחב - width: state.showLeftPane ? _sidebarWidth.value : 0, - child: Padding( + return ValueListenableBuilder( + valueListenable: _sidebarWidth, + builder: (context, width, child) => AnimatedSize( + duration: const Duration(milliseconds: 300), + child: SizedBox( + width: state.showLeftPane ? width : 0, + child: Padding( padding: const EdgeInsets.fromLTRB(1, 0, 4, 0), child: Column( children: [ @@ -1070,6 +1073,7 @@ Future _launchMail(String email) async { ), ), ), + ), ); } From c8ce8093c96dc9fcc542bb5ff0098bb568915e9b Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 20 Jul 2025 00:18:06 +0300 Subject: [PATCH 014/197] =?UTF-8?q?=D7=A7=D7=99=D7=9E=D7=A4=D7=95=D7=9C=20?= =?UTF-8?q?=D7=9C=D7=99=D7=94=D7=95=D7=93=D7=99=20=D7=A6=D7=A2=D7=99=D7=A8?= =?UTF-8?q?...?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/flutter.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index 3582d657f..12020fce2 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -9,6 +9,7 @@ on: branches: - main - dev + - dev_dev2 pull_request: types: [opened, synchronize, reopened] workflow_dispatch: From 11ced073fcab1ab4c48c3faeb451178e8474b783 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 20 Jul 2025 03:29:03 +0300 Subject: [PATCH 015/197] =?UTF-8?q?=D7=A4=D7=AA=D7=99=D7=97=D7=94=20=D7=90?= =?UTF-8?q?=D7=95=D7=98=D7=95=D7=9E=D7=98=D7=99=D7=AA=20=D7=A9=D7=9C=20?= =?UTF-8?q?=D7=9B=D7=95=D7=AA=D7=A8=D7=95=D7=AA=20=D7=91=D7=A8=D7=9E=D7=94?= =?UTF-8?q?=203=20=D7=95=D7=9E=D7=A2=D7=9C=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/text_book/view/toc_navigator_screen.dart | 332 +++++++++++-------- 1 file changed, 197 insertions(+), 135 deletions(-) diff --git a/lib/text_book/view/toc_navigator_screen.dart b/lib/text_book/view/toc_navigator_screen.dart index 81818b179..4560e2a3c 100644 --- a/lib/text_book/view/toc_navigator_screen.dart +++ b/lib/text_book/view/toc_navigator_screen.dart @@ -29,11 +29,13 @@ class _TocViewerState extends State @override bool get wantKeepAlive => true; -final TextEditingController searchController = TextEditingController(); + final Map _controllers = {}; + final TextEditingController searchController = TextEditingController(); final ScrollController _tocScrollController = ScrollController(); final Map _tocItemKeys = {}; bool _isManuallyScrolling = false; int? _lastScrolledTocIndex; + final Map _expanded = {}; @override void dispose() { @@ -42,6 +44,34 @@ final TextEditingController searchController = TextEditingController(); super.dispose(); } + void _ensureParentsOpen(List entries, int targetIndex) { + final path = _findPath(entries, targetIndex); + if (path.isEmpty) return; + + for (final entry in path) { + if (entry.children.isNotEmpty && _expanded[entry.index] != true) { + _expanded[entry.index] = true; + _controllers[entry.index]?.expand(); + } + } + } + + List _findPath(List entries, int targetIndex) { + for (final entry in entries) { + if (entry.index == targetIndex) { + return [entry]; + } + + final subPath = _findPath(entry.children, targetIndex); + if (subPath.isNotEmpty) { + return [entry, ...subPath]; + } + } + return []; + } + + + void _scrollToActiveItem(TextBookLoaded state) { if (_isManuallyScrolling) return; @@ -50,38 +80,49 @@ final TextEditingController searchController = TextEditingController(); ? closestTocEntryIndex( state.tableOfContents, state.visibleIndices.first) : null); - + if (activeIndex == null || activeIndex == _lastScrolledTocIndex) return; - // נחכה פריים אחד נוסף כדי שה-setState יסיים וה-UI יתעדכן + _ensureParentsOpen(state.tableOfContents, activeIndex); + SchedulerBinding.instance.addPostFrameCallback((_) { - if (!mounted || _isManuallyScrolling) return; + if (!mounted) return; - final key = _tocItemKeys[activeIndex]; - final itemContext = key?.currentContext; - if (itemContext == null) return; - - final itemRenderObject = itemContext.findRenderObject(); - if (itemRenderObject is! RenderBox) return; + SchedulerBinding.instance.addPostFrameCallback((_) { + if (!mounted || _isManuallyScrolling) return; - final scrollableBox = _tocScrollController.position.context.storageContext.findRenderObject() as RenderBox; - - final itemOffset = itemRenderObject.localToGlobal(Offset.zero, ancestor: scrollableBox).dy; - final viewportHeight = scrollableBox.size.height; - final itemHeight = itemRenderObject.size.height; - - final target = _tocScrollController.offset + itemOffset - (viewportHeight / 2) + (itemHeight / 2); - - _tocScrollController.animateTo( - target.clamp( - 0.0, - _tocScrollController.position.maxScrollExtent, - ), - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); + final key = _tocItemKeys[activeIndex]; + final itemContext = key?.currentContext; + if (itemContext == null) return; + + final itemRenderObject = itemContext.findRenderObject(); + if (itemRenderObject is! RenderBox) return; + + final scrollableBox = _tocScrollController.position.context.storageContext + .findRenderObject() as RenderBox; + + final itemOffset = itemRenderObject + .localToGlobal(Offset.zero, ancestor: scrollableBox) + .dy; + final viewportHeight = scrollableBox.size.height; + final itemHeight = itemRenderObject.size.height; + + final target = _tocScrollController.offset + + itemOffset - + (viewportHeight / 2) + + (itemHeight / 2); + + _tocScrollController.animateTo( + target.clamp( + 0.0, + _tocScrollController.position.maxScrollExtent, + ), + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); - _lastScrolledTocIndex = activeIndex; + _lastScrolledTocIndex = activeIndex; + }); }); } @@ -107,27 +148,27 @@ final TextEditingController searchController = TextEditingController(); return Padding( padding: EdgeInsets.fromLTRB( 0, 0, 10 * allEntries[index].level.toDouble(), 0), - child: allEntries[index].children.isEmpty - ? Material( - color: Colors.transparent, - child: ListTile( - title: Text(allEntries[index].fullText), - onTap: () { - setState(() { - _isManuallyScrolling = false; - _lastScrolledTocIndex = null; - }); - widget.scrollController.scrollTo( - index: allEntries[index].index, - duration: const Duration(milliseconds: 250), - curve: Curves.ease, - ); - if (Platform.isAndroid) { - widget.closeLeftPaneCallback(); - } - }, - ), - ) + child: allEntries[index].children.isEmpty + ? Material( + color: Colors.transparent, + child: ListTile( + title: Text(allEntries[index].fullText), + onTap: () { + setState(() { + _isManuallyScrolling = false; + _lastScrolledTocIndex = null; + }); + widget.scrollController.scrollTo( + index: allEntries[index].index, + duration: const Duration(milliseconds: 250), + curve: Curves.ease, + ); + if (Platform.isAndroid) { + widget.closeLeftPaneCallback(); + } + }, + ), + ) : _buildTocItem(allEntries[index], showFullText: true), ); }); @@ -171,7 +212,8 @@ final TextEditingController searchController = TextEditingController(); child: ListTile( title: Text(entry.text), selected: selected, - selectedColor: Theme.of(context).colorScheme.onSecondaryContainer, + selectedColor: + Theme.of(context).colorScheme.onSecondaryContainer, selectedTileColor: Theme.of(context).colorScheme.secondaryContainer, onTap: navigateToEntry, @@ -181,6 +223,17 @@ final TextEditingController searchController = TextEditingController(); ), ); } else { + final controller = _controllers.putIfAbsent(entry.index, () => ExpansionTileController()); + final bool isExpanded = _expanded[entry.index] ?? (entry.level == 1); + +if (controller.isExpanded != isExpanded) { + if (isExpanded) { + controller.expand(); + } else { + controller.collapse(); + } +} + return Padding( key: itemKey, padding: EdgeInsets.fromLTRB(0, 0, 10 * entry.level.toDouble(), 0), @@ -189,7 +242,13 @@ final TextEditingController searchController = TextEditingController(); dividerColor: Colors.transparent, ), child: ExpansionTile( - initiallyExpanded: entry.level == 1, + controller: controller, + key: ValueKey(entry.index), + onExpansionChanged: (val) { + setState(() { + _expanded[entry.index] = val; + }); + }, title: BlocBuilder( builder: (context, state) { final int? autoIndex = state is TextBookLoaded && @@ -208,8 +267,10 @@ final TextEditingController searchController = TextEditingController(); title: Text(showFullText ? entry.fullText : entry.text), selected: selected, selectedColor: Theme.of(context).colorScheme.onSecondary, - selectedTileColor: - Theme.of(context).colorScheme.secondary.withOpacity(0.2), + selectedTileColor: Theme.of(context) + .colorScheme + .secondary + .withValues(alpha: 0.2), onTap: navigateToEntry, contentPadding: EdgeInsets.zero, ), @@ -238,91 +299,92 @@ final TextEditingController searchController = TextEditingController(); } } -@override -Widget build(BuildContext context) { - super.build(context); - // הוספנו BlocListener שעוטף את כל מה שהיה קודם - return BlocListener( - // listenWhen קובע מתי ה-listener יופעל, כדי למנוע הפעלות מיותרות - listenWhen: (previous, current) { - if (current is! TextBookLoaded) return false; - if (previous is! TextBookLoaded) return true; // הפעלה ראשונה - - // הפעל רק אם האינדקס הנבחר או האינדקס הנראה השתנו - final prevVisibleIndex = previous.visibleIndices.isNotEmpty ? previous.visibleIndices.first : -1; - final currVisibleIndex = current.visibleIndices.isNotEmpty ? current.visibleIndices.first : -1; - - return previous.selectedIndex != current.selectedIndex || prevVisibleIndex != currVisibleIndex; - }, - // listener היא הפונקציה שתרוץ כשהתנאי ב-listenWhen מתקיים - listener: (context, state) { - if (state is TextBookLoaded) { - _scrollToActiveItem(state); - } - }, - // ה-child הוא ה-UI הקיים שלא השתנה - child: BlocBuilder( - bloc: context.read(), - builder: (context, state) { - if (state is! TextBookLoaded) return const Center(); - // שימו לב שכאן כבר אין קריאה ל-_scrollToActiveItem - return Column( - children: [ - TextField( - controller: searchController, - onChanged: (value) => setState(() {}), - focusNode: widget.focusNode, - autofocus: true, - onSubmitted: (_) { - widget.focusNode.requestFocus(); - }, - decoration: InputDecoration( - hintText: 'איתור כותרת...', - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - setState(() { - searchController.clear(); - }); - }, - ), - ], + @override + Widget build(BuildContext context) { + super.build(context); + return BlocListener( + listenWhen: (previous, current) { + if (current is! TextBookLoaded) return false; + if (previous is! TextBookLoaded) return true; + + // הפעל רק אם האינדקס הנבחר או האינדקס הנראה השתנו + final prevVisibleIndex = previous.visibleIndices.isNotEmpty + ? previous.visibleIndices.first + : -1; + final currVisibleIndex = current.visibleIndices.isNotEmpty + ? current.visibleIndices.first + : -1; + + return previous.selectedIndex != current.selectedIndex || + prevVisibleIndex != currVisibleIndex; + }, + listener: (context, state) { + if (state is TextBookLoaded) { + _scrollToActiveItem(state); + } + }, + child: BlocBuilder( + bloc: context.read(), + builder: (context, state) { + if (state is! TextBookLoaded) return const Center(); + return Column( + children: [ + TextField( + controller: searchController, + onChanged: (value) => setState(() {}), + focusNode: widget.focusNode, + autofocus: true, + onSubmitted: (_) { + widget.focusNode.requestFocus(); + }, + decoration: InputDecoration( + hintText: 'איתור כותרת...', + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + setState(() { + searchController.clear(); + }); + }, + ), + ], + ), ), ), - ), - Expanded( - child: NotificationListener( - onNotification: (notification) { - if (notification is ScrollStartNotification && notification.dragDetails != null) { - setState(() { - _isManuallyScrolling = true; - }); - } else if (notification is ScrollEndNotification) { - setState(() { - _isManuallyScrolling = false; - }); - } - return false; - }, - child: SingleChildScrollView( // אנחנו עדיין משאירים את המבנה המקורי שלך - controller: _tocScrollController, - child: searchController.text.isEmpty - ? ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: state.tableOfContents.length, - itemBuilder: (context, index) => - _buildTocItem(state.tableOfContents[index])) - : _buildFilteredList(state.tableOfContents, context), + Expanded( + child: NotificationListener( + onNotification: (notification) { + if (notification is ScrollStartNotification && + notification.dragDetails != null) { + setState(() { + _isManuallyScrolling = true; + }); + } else if (notification is ScrollEndNotification) { + setState(() { + _isManuallyScrolling = false; + }); + } + return false; + }, + child: SingleChildScrollView( + controller: _tocScrollController, + child: searchController.text.isEmpty + ? ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: state.tableOfContents.length, + itemBuilder: (context, index) => + _buildTocItem(state.tableOfContents[index])) + : _buildFilteredList(state.tableOfContents, context), + ), + ), ), - ), - ), - ], - ); - }), - ); + ], + ); + }), + ); } } From c0c1fd07a20759dda697609e4dd03b556790a5d9 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 20 Jul 2025 04:18:22 +0300 Subject: [PATCH 016/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20ZIP+MS?= =?UTF-8?q?IX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/flutter.yml | 56 +++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index 3582d657f..544fb4b00 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -34,7 +34,17 @@ jobs: run: | flutter build windows --release - + - name: Zip Windows build + shell: pwsh + run: | + $relDir = Get-ChildItem -Directory build\windows | + Where-Object { Test-Path "$($_.FullName)\runner\Release" } | + Select-Object -First 1 -ExpandProperty FullName + if (-not $relDir) { throw 'Release directory not found' } + Compress-Archive -Path "$relDir\runner\Release\*" -DestinationPath otzaria-windows.zip + + - name: Build MSIX package + run: dart run msix:create --install-certificate false - name: Build Inno Setup installer run: | @@ -46,6 +56,18 @@ jobs: name: otzaria-windows-installer path: installer/otzaria-*-windows.exe + - name: Upload Windows ZIP + uses: actions/upload-artifact@v4 + with: + name: otzaria-windows-zip + path: otzaria-windows.zip + + - name: Upload Windows MSIX + uses: actions/upload-artifact@v4 + with: + name: otzaria.msix + path: build/windows/x64/runner/release/*.msix + build_linux: runs-on: ubuntu-latest steps: @@ -192,9 +214,13 @@ jobs: else echo "tag=v$VERSION" >> $GITHUB_OUTPUT echo "prerelease=false" >> $GITHUB_OUTPUT - echo "title=Otzaria v$VERSION" >> $GITHUB_OUTPUT + echo "title=Otzaria v$VERSION" >> $GITHUB_OUTPUT fi - + + - name: Get commit message + id: commit + run: echo "message=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT + - name: Organize release files run: | mkdir -p release-files @@ -204,6 +230,16 @@ jobs: cp artifacts/otzaria-windows-installer/*.exe release-files/ || true fi + # Windows ZIP + if [ -d "artifacts/otzaria-windows-zip" ]; then + cp artifacts/otzaria-windows-zip/*.zip release-files/ || true + fi + + # Windows MSIX + if [ -d "artifacts/otzaria.msix" ]; then + cp artifacts/otzaria.msix/*.msix release-files/ || true + fi + # Linux DEB package if [ -d "artifacts/otzaria-linux-deb" ]; then cp artifacts/otzaria-linux-deb/*.deb release-files/ || true @@ -247,11 +283,13 @@ jobs: body: | ## Changes in this release - Built from commit: ${{ github.sha }} + Built from commit: ${{ github.sha }} - ${{ steps.commit.outputs.message }} Branch: ${{ github.ref_name }} ### Downloads: - **Windows**: Download the `.exe` installer + - **Windows (.msix)**: Download the `.msix` package + - **Windows (.zip)**: Download the `.zip` archive - **Linux (Debian/Ubuntu)**: Download the `.deb` package and install with `sudo dpkg -i .deb` - **Linux (Fedora/RHEL)**: Download the `.rpm` package and install with `sudo dnf localinstall .rpm` - **Linux (Generic)**: Download the `.zip` file and extract @@ -298,7 +336,11 @@ jobs: echo "version=$VERSION" >> $GITHUB_OUTPUT echo "tag=v$VERSION-pr-${{ github.event.number }}-${{ github.run_number }}" >> $GITHUB_OUTPUT echo "title=Otzaria v$VERSION - PR #${{ github.event.number }} Preview" >> $GITHUB_OUTPUT - + + - name: Get commit message + id: commit + run: echo "message=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT + - name: Organize release files run: | mkdir -p release-files @@ -364,10 +406,12 @@ jobs: --- - Built from commit: ${{ github.sha }} + Built from commit: ${{ github.sha }} - ${{ steps.commit.outputs.message }} ### Downloads: - **Windows**: Download the `.exe` installer + - **Windows (.msix)**: Download the `.msix` package + - **Windows (.zip)**: Download the `.zip` archive - **Linux (Debian/Ubuntu)**: Download the `.deb` package and install with `sudo dpkg -i .deb` - **Linux (Fedora/RHEL)**: Download the `.rpm` package and install with `sudo dnf localinstall .rpm` - **Linux (Generic)**: Download the `.zip` file and extract From c6437380fdc37874321d6e579c4ad7d4c2afc027 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 20 Jul 2025 04:57:20 +0300 Subject: [PATCH 017/197] =?UTF-8?q?=D7=A9=D7=99=D7=A4=D7=95=D7=A8=20=D7=91?= =?UTF-8?q?=D7=94=D7=A8=D7=97=D7=91=D7=AA/=D7=94=D7=A6=D7=A8=D7=AA=20?= =?UTF-8?q?=D7=97=D7=9C=D7=95=D7=A0=D7=99=D7=AA=20=D7=94=D7=A4=D7=A8=D7=A9?= =?UTF-8?q?=D7=A0=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../splited_view/splited_view_screen.dart | 114 +++++++++++++----- 1 file changed, 82 insertions(+), 32 deletions(-) diff --git a/lib/text_book/view/splited_view/splited_view_screen.dart b/lib/text_book/view/splited_view/splited_view_screen.dart index f15d511b6..634c274ff 100644 --- a/lib/text_book/view/splited_view/splited_view_screen.dart +++ b/lib/text_book/view/splited_view/splited_view_screen.dart @@ -9,8 +9,8 @@ import 'package:otzaria/text_book/bloc/text_book_state.dart'; import 'package:otzaria/text_book/view/splited_view/simple_book_view.dart'; import 'package:otzaria/text_book/view/splited_view/commentary_list_for_splited_view.dart'; -class SplitedViewScreen extends StatelessWidget { - SplitedViewScreen({ +class SplitedViewScreen extends StatefulWidget { + const SplitedViewScreen({ super.key, required this.content, required this.openBookCallback, @@ -18,19 +18,43 @@ class SplitedViewScreen extends StatelessWidget { required this.openLeftPaneTab, required this.tab, }); + final List content; final void Function(OpenedTab) openBookCallback; final TextEditingValue searchTextController; final void Function(int) openLeftPaneTab; final TextBookTab tab; + @override + State createState() => _SplitedViewScreenState(); +} + +class _SplitedViewScreenState extends State { + late final MultiSplitViewController _controller; static final GlobalKey _selectionKey = GlobalKey(); + @override + void initState() { + super.initState(); + _controller = MultiSplitViewController(areas: [ + Area(weight: 0.4, minimalSize: 200), + Area(weight: 0.6, minimalSize: 200), + ]); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + ContextMenu _buildContextMenu(TextBookLoaded state) { return ContextMenu( entries: [ - MenuItem(label: 'חיפוש', onSelected: () => openLeftPaneTab(1)), + MenuItem( + label: 'חיפוש', + onSelected: () => widget.openLeftPaneTab(1)), const MenuDivider(), MenuItem( label: 'בחר את כל הטקסט', @@ -44,38 +68,64 @@ class SplitedViewScreen extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder( - builder: (context, state) => MultiSplitView( - controller: MultiSplitViewController(areas: Area.weights([0.4, 0.6])), - axis: Axis.horizontal, - resizable: true, - dividerBuilder: - (axis, index, resizable, dragging, highlighted, themeData) => - const VerticalDivider(), - children: [ - ContextMenuRegion( - contextMenu: _buildContextMenu(state as TextBookLoaded), - child: SelectionArea( - key: _selectionKey, - child: CommentaryList( - index: - 0, // we don't need the index here, b/c we listen to the selected index in the commentary list + buildWhen: (previous, current) { + if (previous is TextBookLoaded && current is TextBookLoaded) { + return previous.fontSize != current.fontSize || + previous.showSplitView != current.showSplitView || + previous.activeCommentators != current.activeCommentators; + } + return true; + }, + builder: (context, state) { + if (state is! TextBookLoaded) { + return const Center(child: CircularProgressIndicator()); + } - fontSize: (state as TextBookLoaded).fontSize, - openBookCallback: openBookCallback, - showSplitView: state.showSplitView, + return MultiSplitView( + controller: _controller, + axis: Axis.horizontal, + resizable: true, + dividerBuilder: + (axis, index, resizable, dragging, highlighted, themeData) { + final color = dragging + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor; + return MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + child: Container( + width: 8, + alignment: Alignment.center, + child: Container( + width: 1.5, + color: color, + ), + ), + ); + }, + children: [ + ContextMenuRegion( + contextMenu: _buildContextMenu(state), + child: SelectionArea( + key: _selectionKey, + child: CommentaryList( + index: 0, + fontSize: state.fontSize, + openBookCallback: widget.openBookCallback, + showSplitView: state.showSplitView, + ), ), ), - ), - SimpleBookView( - data: content, - textSize: state.fontSize, - openBookCallback: openBookCallback, - openLeftPaneTab: openLeftPaneTab, - showSplitedView: state.showSplitView, - tab: tab, - ), - ], - ), + SimpleBookView( + data: widget.content, + textSize: state.fontSize, + openBookCallback: widget.openBookCallback, + openLeftPaneTab: widget.openLeftPaneTab, + showSplitedView: state.showSplitView, + tab: widget.tab, + ), + ], + ); + }, ); } } From 57a47e5aeb9b08ec1d7b18f12780d1ff5ed4dde0 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 20 Jul 2025 15:34:43 +0300 Subject: [PATCH 018/197] =?UTF-8?q?=D7=A9=D7=9E=D7=99=D7=A8=D7=AA=20=D7=94?= =?UTF-8?q?=D7=99=D7=A1=D7=98=D7=95=D7=A8=D7=99=D7=94=20-=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bookmarks/models/bookmark.dart | 6 +++--- lib/widgets/keyboard_shortcuts.dart | 20 +++++++++++++++++-- .../settings/history/bookmark_model_test.dart | 15 ++++++++++++++ 3 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 test/unit/settings/history/bookmark_model_test.dart diff --git a/lib/bookmarks/models/bookmark.dart b/lib/bookmarks/models/bookmark.dart index 56f588cd2..26c5d87eb 100644 --- a/lib/bookmarks/models/bookmark.dart +++ b/lib/bookmarks/models/bookmark.dart @@ -32,13 +32,13 @@ class Bookmark { /// /// The JSON object must have 'ref', 'title', and 'index' keys. factory Bookmark.fromJson(Map json) { + final rawCommentators = json['commentatorsToShow'] as List?; return Bookmark( ref: json['ref'] as String, index: json['index'] as int, book: Book.fromJson(json['book'] as Map), - commentatorsToShow: (json['commentatorsToShow'] as List) - .map((e) => e.toString()) - .toList(), + commentatorsToShow: + (rawCommentators ?? []).map((e) => e.toString()).toList(), ); } diff --git a/lib/widgets/keyboard_shortcuts.dart b/lib/widgets/keyboard_shortcuts.dart index 0cf326bb3..76a28d04d 100644 --- a/lib/widgets/keyboard_shortcuts.dart +++ b/lib/widgets/keyboard_shortcuts.dart @@ -7,6 +7,8 @@ import 'package:otzaria/navigation/bloc/navigation_event.dart'; import 'package:otzaria/navigation/bloc/navigation_state.dart'; import 'package:otzaria/tabs/bloc/tabs_bloc.dart'; import 'package:otzaria/tabs/bloc/tabs_event.dart'; +import 'package:otzaria/history/bloc/history_bloc.dart'; +import 'package:otzaria/history/bloc/history_event.dart'; import 'package:otzaria/tabs/models/searching_tab.dart'; import 'package:provider/provider.dart'; @@ -117,11 +119,25 @@ class KeyboardShortcuts extends StatelessWidget { }, shortcuts[Settings.getValue('key-shortcut-close-tab') ?? 'ctrl+w']!: () { - context.read().add(const CloseCurrentTab()); + final tabsBloc = context.read(); + final historyBloc = context.read(); + if (tabsBloc.state.tabs.isNotEmpty) { + final currentTab = + tabsBloc.state.tabs[tabsBloc.state.currentTabIndex]; + historyBloc.add(AddHistory(currentTab)); + } + tabsBloc.add(const CloseCurrentTab()); }, shortcuts[Settings.getValue('key-shortcut-close-all-tabs') ?? 'ctrl+x']!: () { - context.read().add(CloseAllTabs()); + final tabsBloc = context.read(); + final historyBloc = context.read(); + for (final tab in tabsBloc.state.tabs) { + if (tab is! SearchingTab) { + historyBloc.add(AddHistory(tab)); + } + } + tabsBloc.add(CloseAllTabs()); }, shortcuts[ Settings.getValue('key-shortcut-open-reading-screen') ?? diff --git a/test/unit/settings/history/bookmark_model_test.dart b/test/unit/settings/history/bookmark_model_test.dart new file mode 100644 index 000000000..fe84730ea --- /dev/null +++ b/test/unit/settings/history/bookmark_model_test.dart @@ -0,0 +1,15 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:otzaria/bookmarks/models/bookmark.dart'; +import 'package:otzaria/models/books.dart'; + +void main() { + test('Bookmark.fromJson handles missing commentators field', () { + final json = { + 'ref': 'test ref', + 'index': 1, + 'book': {'title': 'Book A', 'type': 'TextBook'} + }; + final bookmark = Bookmark.fromJson(json); + expect(bookmark.commentatorsToShow, isEmpty); + }); +} \ No newline at end of file From a2daf77000ff9a6b291b3ebbcf5c2e767fe9f6d7 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 20 Jul 2025 22:09:22 +0300 Subject: [PATCH 019/197] =?UTF-8?q?=D7=A1=D7=99=D7=91=D7=95=D7=91=20=D7=A0?= =?UTF-8?q?=D7=9B=D7=95=D7=9F=20=D7=A9=D7=9C=20=D7=90=D7=99=D7=99=D7=A7?= =?UTF-8?q?=D7=95=D7=9F=20=D7=94=D7=A1=D7=A0=D7=9B=D7=A8=D7=95=D7=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/file_sync/file_sync_widget.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/file_sync/file_sync_widget.dart b/lib/file_sync/file_sync_widget.dart index 43d34bf03..47938f565 100644 --- a/lib/file_sync/file_sync_widget.dart +++ b/lib/file_sync/file_sync_widget.dart @@ -95,7 +95,7 @@ class _SyncIconButtonState extends State child: IconButton( onPressed: () => _handlePress(context, state), icon: RotationTransition( - turns: _controller, + turns: Tween(begin: 0.0, end: -1.0).animate(_controller), child: Icon( iconData, color: iconColor, From a65935bc4a5642154e24e665d28e5c4260fc2949 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 21 Jul 2025 01:02:34 +0300 Subject: [PATCH 020/197] =?UTF-8?q?=D7=A9=D7=9E=D7=99=D7=A8=D7=AA=20=D7=94?= =?UTF-8?q?=D7=99=D7=A1=D7=98=D7=95=D7=A8=D7=99=D7=94=20=D7=91=D7=A1=D7=92?= =?UTF-8?q?=D7=99=D7=A8=D7=AA=20=D7=94=D7=AA=D7=95=D7=9B=D7=A0=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bookmarks/models/bookmark.dart | 40 ++--- lib/history/bloc/history_bloc.dart | 138 +++++++++++++---- lib/history/bloc/history_event.dart | 13 ++ lib/tabs/reading_screen.dart | 225 ++++++++++++++-------------- lib/utils/open_book.dart | 21 ++- 5 files changed, 259 insertions(+), 178 deletions(-) diff --git a/lib/bookmarks/models/bookmark.dart b/lib/bookmarks/models/bookmark.dart index 26c5d87eb..d4c7d1ee9 100644 --- a/lib/bookmarks/models/bookmark.dart +++ b/lib/bookmarks/models/bookmark.dart @@ -1,56 +1,38 @@ import 'package:otzaria/models/books.dart'; /// Represents a bookmark in the application. -/// -/// A `Bookmark` object has a [ref] which is a reference to a specific -/// part of a text (can be a word, a phrase, a sentence, etc.), a [title] -/// which is the name of the book, and an [index] which is the index -/// of the bookmark in the text. class Bookmark { - /// The reference to a specific part of a text. final String ref; - - //the book final Book book; - - //the commentators to show final List commentatorsToShow; - - /// The index of the bookmark in the text. final int index; - /// Creates a new `Bookmark` instance. - /// - /// The [ref], [title], and [index] parameters must not be null. - Bookmark( - {required this.ref, - required this.book, - required this.index, - this.commentatorsToShow = const []}); + /// A stable key for history management, unique per book title. + String get historyKey => book.title; + + Bookmark({ + required this.ref, + required this.book, + required this.index, + this.commentatorsToShow = const [], + }); - /// Creates a new `Bookmark` instance from a JSON object. - /// - /// The JSON object must have 'ref', 'title', and 'index' keys. factory Bookmark.fromJson(Map json) { final rawCommentators = json['commentatorsToShow'] as List?; return Bookmark( ref: json['ref'] as String, index: json['index'] as int, book: Book.fromJson(json['book'] as Map), - commentatorsToShow: - (rawCommentators ?? []).map((e) => e.toString()).toList(), + commentatorsToShow: (rawCommentators ?? []).map((e) => e.toString()).toList(), ); } - /// Converts the `Bookmark` instance into a JSON object. - /// - /// Returns a JSON object with 'ref', 'title', and 'index' keys. Map toJson() { return { 'ref': ref, 'book': book.toJson(), 'index': index, - 'commentatorsToShow': commentatorsToShow + 'commentatorsToShow': commentatorsToShow, }; } } diff --git a/lib/history/bloc/history_bloc.dart b/lib/history/bloc/history_bloc.dart index 3c0f4b7b7..b73027398 100644 --- a/lib/history/bloc/history_bloc.dart +++ b/lib/history/bloc/history_bloc.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:otzaria/bookmarks/models/bookmark.dart'; import 'package:otzaria/history/bloc/history_event.dart'; @@ -5,22 +6,113 @@ import 'package:otzaria/history/bloc/history_state.dart'; import 'package:otzaria/history/history_repository.dart'; import 'package:otzaria/tabs/models/pdf_tab.dart'; import 'package:otzaria/tabs/models/searching_tab.dart'; +import 'package:otzaria/tabs/models/tab.dart'; import 'package:otzaria/tabs/models/text_tab.dart'; import 'package:otzaria/text_book/bloc/text_book_state.dart'; import 'package:otzaria/utils/ref_helper.dart'; class HistoryBloc extends Bloc { final HistoryRepository _repository; + Timer? _debounce; + final Map _pendingSnapshots = {}; HistoryBloc(this._repository) : super(HistoryInitial()) { on(_onLoadHistory); on(_onAddHistory); + on(_onBulkAddHistory); on(_onRemoveHistory); on(_onClearHistory); + on(_onCaptureStateForHistory); + on(_onFlushHistory); add(LoadHistory()); } + @override + Future close() { + _debounce?.cancel(); + if (_pendingSnapshots.isNotEmpty) { + final snapshots = _pendingSnapshots.values.toList(); + _pendingSnapshots.clear(); + _saveSnapshotsToHistory(snapshots); + } + return super.close(); + } + + Future _saveSnapshotsToHistory(List snapshots) async { + final updatedHistory = List.from(state.history); + + for (final bookmark in snapshots) { + final existingIndex = + updatedHistory.indexWhere((b) => b.historyKey == bookmark.historyKey); + if (existingIndex >= 0) { + updatedHistory.removeAt(existingIndex); + } + updatedHistory.insert(0, bookmark); + } + + const maxHistorySize = 200; + if (updatedHistory.length > maxHistorySize) { + updatedHistory.removeRange(maxHistorySize, updatedHistory.length); + } + + await _repository.saveHistory(updatedHistory); + if (!isClosed) { + emit(HistoryLoaded(updatedHistory)); + } + } + + Future _bookmarkFromTab(OpenedTab tab) async { + if (tab is SearchingTab) return null; + + if (tab is TextBookTab) { + final blocState = tab.bloc.state; + if (blocState is TextBookLoaded && blocState.visibleIndices.isNotEmpty) { + final index = blocState.visibleIndices.first; + final ref = + await refFromIndex(index, Future.value(blocState.tableOfContents)); + return Bookmark( + ref: ref, + book: blocState.book, + index: index, + commentatorsToShow: blocState.activeCommentators, + ); + } + } else if (tab is PdfBookTab) { + if (!tab.pdfViewerController.isReady) return null; + final page = tab.pdfViewerController.pageNumber ?? 1; + return Bookmark( + ref: '${tab.title} עמוד $page', + book: tab.book, + index: page, + ); + } + return null; + } + + Future _onCaptureStateForHistory( + CaptureStateForHistory event, Emitter emit) async { + _debounce?.cancel(); + final bookmark = await _bookmarkFromTab(event.tab); + if (bookmark != null) { + _pendingSnapshots[bookmark.historyKey] = bookmark; + } + _debounce = Timer(const Duration(milliseconds: 1500), () { + if (_pendingSnapshots.isNotEmpty) { + add(BulkAddHistory(List.from(_pendingSnapshots.values))); + _pendingSnapshots.clear(); + } + }); + } + + void _onFlushHistory(FlushHistory event, Emitter emit) { + _debounce?.cancel(); + if (_pendingSnapshots.isNotEmpty) { + add(BulkAddHistory(List.from(_pendingSnapshots.values))); + _pendingSnapshots.clear(); + } + } + Future _onLoadHistory( LoadHistory event, Emitter emit) async { try { @@ -34,35 +126,20 @@ class HistoryBloc extends Bloc { Future _onAddHistory( AddHistory event, Emitter emit) async { - Bookmark? bookmark; try { - if (event.tab is SearchingTab) return; - if (event.tab is TextBookTab) { - final tab = event.tab as TextBookTab; - final bloc = tab.bloc; - if (bloc.state is TextBookLoaded) { - final state = bloc.state as TextBookLoaded; - bookmark = Bookmark( - ref: await refFromIndex(state.visibleIndices.first, - Future.value(state.tableOfContents)), - book: state.book, - index: state.visibleIndices.first, - commentatorsToShow: state.activeCommentators, - ); - } - } else { - final tab = event.tab as PdfBookTab; - bookmark = Bookmark( - ref: '${tab.title} עמוד ${tab.pdfViewerController.pageNumber ?? 1}', - book: tab.book, - index: tab.pdfViewerController.pageNumber ?? 1, - ); - } - if (state.history.any((b) => b.ref == bookmark?.ref)) return; + final bookmark = await _bookmarkFromTab(event.tab); if (bookmark == null) return; - final updatedHistory = [bookmark, ...state.history]; - await _repository.saveHistory(updatedHistory); - emit(HistoryLoaded(updatedHistory)); + add(BulkAddHistory([bookmark])); + } catch (e) { + emit(HistoryError(state.history, e.toString())); + } + } + + Future _onBulkAddHistory( + BulkAddHistory event, Emitter emit) async { + if (event.snapshots.isEmpty) return; + try { + await _saveSnapshotsToHistory(event.snapshots); } catch (e) { emit(HistoryError(state.history, e.toString())); } @@ -71,9 +148,10 @@ class HistoryBloc extends Bloc { Future _onRemoveHistory( RemoveHistory event, Emitter emit) async { try { - await _repository.removeHistoryItem(event.index); - final history = await _repository.loadHistory(); - emit(HistoryLoaded(history)); + final updatedHistory = List.from(state.history) + ..removeAt(event.index); + await _repository.saveHistory(updatedHistory); + emit(HistoryLoaded(updatedHistory)); } catch (e) { emit(HistoryError(state.history, e.toString())); } diff --git a/lib/history/bloc/history_event.dart b/lib/history/bloc/history_event.dart index 09fc87ae8..5a9f2b594 100644 --- a/lib/history/bloc/history_event.dart +++ b/lib/history/bloc/history_event.dart @@ -1,3 +1,4 @@ +import 'package:otzaria/bookmarks/models/bookmark.dart'; import 'package:otzaria/tabs/models/tab.dart'; abstract class HistoryEvent {} @@ -9,6 +10,18 @@ class AddHistory extends HistoryEvent { AddHistory(this.tab); } +class CaptureStateForHistory extends HistoryEvent { + final OpenedTab tab; + CaptureStateForHistory(this.tab); +} + +class FlushHistory extends HistoryEvent {} + +class BulkAddHistory extends HistoryEvent { + final List snapshots; + BulkAddHistory(this.snapshots); +} + class RemoveHistory extends HistoryEvent { final int index; RemoveHistory(this.index); diff --git a/lib/tabs/reading_screen.dart b/lib/tabs/reading_screen.dart index 057ead3b8..3de3e689b 100644 --- a/lib/tabs/reading_screen.dart +++ b/lib/tabs/reading_screen.dart @@ -1,5 +1,3 @@ -// ignore_for_file: unused_import - import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_context_menu/flutter_context_menu.dart'; @@ -17,11 +15,7 @@ import 'package:otzaria/tabs/models/searching_tab.dart'; import 'package:otzaria/tabs/models/tab.dart'; import 'package:otzaria/tabs/models/text_tab.dart'; import 'package:otzaria/search/view/full_text_search_screen.dart'; -import 'package:otzaria/text_book/bloc/text_book_bloc.dart'; -import 'package:otzaria/text_book/bloc/text_book_event.dart'; -import 'package:otzaria/text_book/bloc/text_book_state.dart'; import 'package:otzaria/text_book/view/text_book_screen.dart'; -import 'package:otzaria/daf_yomi/calendar.dart'; import 'package:otzaria/utils/text_manipulation.dart'; import 'package:otzaria/workspaces/view/workspace_switcher_dialog.dart'; import 'package:otzaria/history/history_dialog.dart'; @@ -39,12 +33,13 @@ class _ReadingScreenState extends State with TickerProviderStateMixin, WidgetsBindingObserver { @override void initState() { - WidgetsBinding.instance.addObserver(this); super.initState(); + WidgetsBinding.instance.addObserver(this); } @override void dispose() { + context.read().add(FlushHistory()); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -53,73 +48,86 @@ class _ReadingScreenState extends State void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.hidden || state == AppLifecycleState.inactive || - state == AppLifecycleState.detached) { - BlocProvider.of(context, listen: false).add(const SaveTabs()); + state == AppLifecycleState.paused) { + context.read().add(FlushHistory()); + context.read().add(const SaveTabs()); } } @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - if (!state.hasOpenTabs) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Padding( - padding: EdgeInsets.all(8.0), - child: Text('לא נבחרו ספרים'), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: TextButton( - onPressed: () { - context.read().add( - const NavigateToScreen(Screen.library), - ); - }, - child: const Text('דפדף בספרייה'), + return BlocListener( + listener: (context, state) { + if(state.hasOpenTabs) { + context.read().add(CaptureStateForHistory(state.currentTab!)); + } + }, + listenWhen: (previous, current) => previous.currentTabIndex != current.currentTabIndex, + child: BlocBuilder( + builder: (context, state) { + if (!state.hasOpenTabs) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.all(8.0), + child: Text('לא נבחרו ספרים'), ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: TextButton( - onPressed: () { - _showHistoryDialog(context); - }, - child: const Text('הצג היסטוריה'), + Padding( + padding: const EdgeInsets.all(8.0), + child: TextButton( + onPressed: () { + context.read().add( + const NavigateToScreen(Screen.library), + ); + }, + child: const Text('דפדף בספרייה'), + ), ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: TextButton( - onPressed: () { - _showSaveWorkspaceDialog(context); - }, - child: const Text('החלף שולחן עבודה'), + Padding( + padding: const EdgeInsets.all(8.0), + child: TextButton( + onPressed: () { + _showHistoryDialog(context); + }, + child: const Text('הצג היסטוריה'), + ), ), - ) - ], - ), - ); - } - - return Builder( - builder: (context) { - final controller = TabController( - length: state.tabs.length, - vsync: this, - initialIndex: state.currentTabIndex, + Padding( + padding: const EdgeInsets.all(8.0), + child: TextButton( + onPressed: () { + _showSaveWorkspaceDialog(context); + }, + child: const Text('החלף שולחן עבודה'), + ), + ) + ], + ), ); - - controller.addListener(() { - if (controller.index != state.currentTabIndex) { - context.read().add(SetCurrentTab(controller.index)); - } - }); - - try { + } + + return Builder( + builder: (context) { + final controller = TabController( + length: state.tabs.length, + vsync: this, + initialIndex: state.currentTabIndex, + ); + + controller.addListener(() { + if (controller.indexIsChanging && + state.currentTabIndex < state.tabs.length) { + context + .read() + .add(CaptureStateForHistory(state.tabs[state.currentTabIndex])); + } + if (controller.index != state.currentTabIndex) { + context.read().add(SetCurrentTab(controller.index)); + } + }); + return Scaffold( appBar: AppBar( title: Container( @@ -148,16 +156,40 @@ class _ReadingScreenState extends State ), ), ); - } catch (e) { - return Text(e.toString()); - } - }, - ); - }, + }, + ); + }, + ), ); } - Widget _buildTab(BuildContext context, OpenedTab tab, TabsState state) { + Widget _buildTabView(OpenedTab tab) { + if (tab is PdfBookTab) { + return PdfBookScreen( + key: PageStorageKey(tab), + tab: tab, + ); + } else if (tab is TextBookTab) { + return BlocProvider.value( + value: tab.bloc, + child: TextBookViewerBloc( + openBookCallback: (tab, {int index = 1}) { + context.read().add(AddTab(tab)); + }, + tab: tab, + )); + } else if (tab is SearchingTab) { + return FullTextSearchScreen( + tab: tab, + openBookCallback: (tab, {int index = 1}) { + context.read().add(AddTab(tab)); + }, + ); + } + return const SizedBox.shrink(); + } + + Widget _buildTab(BuildContext context, OpenedTab tab, TabsState state) { return Listener( onPointerDown: (PointerDownEvent event) { if (event.buttons == 4) { @@ -170,12 +202,7 @@ class _ReadingScreenState extends State MenuItem(label: 'סגור', onSelected: () => closeTab(tab, context)), MenuItem( label: 'סגור הכל', - onSelected: () { - for (final tab in state.tabs) { - context.read().add(AddHistory(tab)); - } - context.read().add(CloseAllTabs()); - }), + onSelected: () => closeAllTabs(state, context)), MenuItem( label: 'סגור את האחרים', onSelected: () => closeAllTabsButCurrent(state, context), @@ -193,7 +220,7 @@ class _ReadingScreenState extends State child: Draggable( axis: Axis.horizontal, data: tab, - childWhenDragging: SizedBox.fromSize(size: const Size(0, 0)), + childWhenDragging: const SizedBox.shrink(), feedback: Container( decoration: const BoxDecoration( borderRadius: BorderRadius.only( @@ -273,32 +300,6 @@ class _ReadingScreenState extends State ); } - Widget _buildTabView(OpenedTab tab) { - if (tab is PdfBookTab) { - return PdfBookScreen( - key: PageStorageKey(tab), - tab: tab, - ); - } else if (tab is TextBookTab) { - return BlocProvider.value( - value: tab.bloc, - child: TextBookViewerBloc( - openBookCallback: (tab, {int index = 1}) { - context.read().add(AddTab(tab)); - }, - tab: tab, - )); - } else if (tab is SearchingTab) { - return FullTextSearchScreen( - tab: tab, - openBookCallback: (tab, {int index = 1}) { - context.read().add(AddTab(tab)); - }, - ); - } - return const SizedBox.shrink(); - } - List _getMenuItems( List tabs, BuildContext context) { List items = tabs @@ -316,6 +317,7 @@ class _ReadingScreenState extends State } void _showSaveWorkspaceDialog(BuildContext context) { + context.read().add(FlushHistory()); showDialog( context: context, builder: (context) => const WorkspaceSwitcherDialog(), @@ -335,17 +337,16 @@ class _ReadingScreenState extends State } void closeAllTabsButCurrent(TabsState state, BuildContext context) { - for (final tab in state.tabs) { - if (tab is! SearchingTab && tab != state.tabs[state.currentTabIndex]) { - context.read().add(AddHistory(tab)); - } - context - .read() - .add(CloseOtherTabs(state.tabs[state.currentTabIndex])); + final current = state.tabs[state.currentTabIndex]; + final toClose = state.tabs.where((t) => t != current).toList(); + for (final tab in toClose) { + context.read().add(AddHistory(tab)); } + context.read().add(CloseOtherTabs(current)); } void _showHistoryDialog(BuildContext context) { + context.read().add(FlushHistory()); showDialog( context: context, builder: (context) => const HistoryDialog(), diff --git a/lib/utils/open_book.dart b/lib/utils/open_book.dart index 2f70e137b..e693cd02a 100644 --- a/lib/utils/open_book.dart +++ b/lib/utils/open_book.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:otzaria/history/bloc/history_bloc.dart'; import 'package:otzaria/navigation/bloc/navigation_bloc.dart'; import 'package:otzaria/navigation/bloc/navigation_event.dart'; import 'package:otzaria/navigation/bloc/navigation_state.dart'; @@ -9,27 +10,33 @@ import "package:flutter_bloc/flutter_bloc.dart"; import 'package:otzaria/tabs/models/pdf_tab.dart'; import 'package:otzaria/tabs/models/text_tab.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; +import 'package:collection/collection.dart'; void openBook(BuildContext context, Book book, int index, String searchQuery) { - // שלב 1: חישוב הערך הבוליאני ושמירתו במשתנה נפרד - // זה הופך את הקוד לקריא יותר ומונע את השגיאה + final historyState = context.read().state; + final lastOpened = historyState.history + .firstWhereOrNull((b) => b.book.title == book.title); + + final int initialIndex = lastOpened?.index ?? index; + final List? initialCommentators = lastOpened?.commentatorsToShow; + final bool shouldOpenLeftPane = (Settings.getValue('key-pin-sidebar') ?? false) || (Settings.getValue('key-default-sidebar-open') ?? false); - // שלב 2: שימוש במשתנה החדש בשני המקרים if (book is TextBook) { context.read().add(AddTab(TextBookTab( book: book, - index: index, + index: initialIndex, searchText: searchQuery, - openLeftPane: shouldOpenLeftPane, // שימוש במשתנה הפשוט + commentators: initialCommentators, + openLeftPane: shouldOpenLeftPane, ))); } else if (book is PdfBook) { context.read().add(AddTab(PdfBookTab( book: book, - pageNumber: index, - openLeftPane: shouldOpenLeftPane, // שימוש באותו משתנה פשוט + pageNumber: initialIndex, + openLeftPane: shouldOpenLeftPane, ))); } From c001ecf0e04b50169efd174b295a144f08f6b8ae Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 22 Jul 2025 01:14:01 +0300 Subject: [PATCH 021/197] =?UTF-8?q?=D7=A9=D7=99=D7=A4=D7=95=D7=A8=20=D7=9E?= =?UTF-8?q?=D7=94=D7=99=D7=A8=D7=95=D7=AA=20=D7=98=D7=A2=D7=99=D7=A0=D7=AA?= =?UTF-8?q?=20=D7=94=D7=A1=D7=A4=D7=A8=D7=99=D7=99=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file_system_data_provider.dart | 77 ++++++++++++++++++- lib/library/models/library.dart | 46 +++++++++++ 2 files changed, 119 insertions(+), 4 deletions(-) diff --git a/lib/data/data_providers/file_system_data_provider.dart b/lib/data/data_providers/file_system_data_provider.dart index 6d2fddc6c..29338a996 100644 --- a/lib/data/data_providers/file_system_data_provider.dart +++ b/lib/data/data_providers/file_system_data_provider.dart @@ -44,12 +44,81 @@ class FileSystemData { /// Reads the library from the configured path and combines it with metadata /// to create a full [Library] object containing all categories and books. Future getLibrary() async { - titleToPath = _getTitleToPath(); - metadata = _getMetadata(); - return _getLibraryFromDirectory( - '$libraryPath${Platform.pathSeparator}אוצריא', await metadata); + // --- הגדרת נתיבים --- + final cachePath = '$libraryPath${Platform.pathSeparator}library_cache.json'; + final cacheFile = File(cachePath); + final metadataPath = '$libraryPath${Platform.pathSeparator}metadata.json'; + final metadataFile = File(metadataPath); + + // --- בדיקת תוקף המטמון --- + bool isCacheValid = await cacheFile.exists(); // 1. האם המטמון קיים? + + if (isCacheValid) { + try { + final cacheLastModified = await cacheFile.stat(); + final libraryDirLastModified = await Directory(libraryPath).stat(); + + // 2. בדוק אם המטמון ישן יותר משינוי במבנה תיקיית הספרייה (הוספה/מחיקה) + if (cacheLastModified.modified.isBefore(libraryDirLastModified.modified)) { + isCacheValid = false; // אם כן, המטמון לא תקין + } + + // 3. בדוק אם המטמון ישן יותר מקובץ המטא-דאטה + if (isCacheValid && await metadataFile.exists()) { + final metadataLastModified = await metadataFile.stat(); + if (cacheLastModified.modified.isBefore(metadataLastModified.modified)) { + isCacheValid = false; // אם כן, המטמון לא תקין + } + } + } catch (_) { + isCacheValid = false; // אם יש שגיאה בבדיקה, נניח שהמטמון לא תקין + } } + // --- טעינה מהמטמון (רק אם הוא קיים ותקין) --- + if (isCacheValid) { + try { + final jsonString = await cacheFile.readAsString(); + final jsonMap = await Isolate.run(() => jsonDecode(jsonString)); + + // טוען גם את הנתיבים וגם את המטא-דאטה מהמטמון + titleToPath = Future.value(Map.from(jsonMap['titleToPath'] ?? {})); + + // המפתח 'metadata' עדיין לא קיים במטמונים ישנים, לכן צריך בדיקה + if (jsonMap.containsKey('metadata')) { + metadata = Future.value(Map>.from(jsonMap['metadata'])); + } else { + metadata = _getMetadata(); // טעינה רגילה אם המפתח חסר + } + + return Library.fromJson(Map.from(jsonMap['library'])); + } catch (_) { + // אם יש שגיאה בקריאה מהמטמון, נסרוק מחדש + } + } + + // --- סריקה מלאה (אם המטמון לא קיים או לא תקין) --- + titleToPath = _getTitleToPath(); + metadata = _getMetadata(); // טעינה טרייה של המטא-דאטה + final lib = await _getLibraryFromDirectory( + '$libraryPath${Platform.pathSeparator}אוצריא', await metadata); + + // --- יצירת קובץ מטמון חדש --- + try { + // כותב לקובץ גם את המטא-דאטה כדי לחסוך קריאה בפעם הבאה + final jsonMap = { + 'library': lib.toJson(), + 'titleToPath': await titleToPath, + 'metadata': await metadata, // הוספנו את המטא-דאטה! + }; + await cacheFile.writeAsString(jsonEncode(jsonMap)); + } catch (_) { + // מתעלם משגיאות כתיבה למטמון + } + + return lib; +} + /// Recursively builds the library structure from a directory. /// /// Creates a hierarchical structure of categories and books by traversing diff --git a/lib/library/models/library.dart b/lib/library/models/library.dart index 8261f3a5b..5c7be08a5 100644 --- a/lib/library/models/library.dart +++ b/lib/library/models/library.dart @@ -90,8 +90,40 @@ class Category { required this.books, required this.parent, }); + + Map toJson() { + return { + 'title': title, + 'description': description, + 'shortDescription': shortDescription, + 'order': order, + 'books': books.map((b) => b.toJson()).toList(), + 'subCategories': subCategories.map((c) => c.toJson()).toList(), + }; + } + + factory Category.fromJson(Map json, [Category? parent]) { + final category = Category( + title: json['title'] as String, + description: json['description'] ?? '', + shortDescription: json['shortDescription'] ?? '', + order: json['order'] ?? 999, + subCategories: [], + books: [], + parent: parent, + ); + + category.books = (json['books'] as List? ?? []) + .map((e) => Book.fromJson(Map.from(e))) + .toList(); + category.subCategories = (json['subCategories'] as List? ?? []) + .map((e) => Category.fromJson(Map.from(e), category)) + .toList(); + return category; + } } + /// Represents a library of categories and books. /// /// A library is a top level category that contains other categories. @@ -114,6 +146,20 @@ class Library extends Category { parent = this; } + Map toJson() { + return { + 'subCategories': subCategories.map((c) => c.toJson()).toList(), + }; + } + + factory Library.fromJson(Map json) { + final lib = Library(categories: []); + lib.subCategories = (json['subCategories'] as List? ?? []) + .map((e) => Category.fromJson(Map.from(e), lib)) + .toList(); + return lib; + } + /// Finds a book by its title in the library. /// /// Searches through all books in the library and its subcategories From f652b4b18aec00c428d17979d596d7ad8e7cbed4 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 22 Jul 2025 01:32:29 +0300 Subject: [PATCH 022/197] TEST --- lib/data/data_providers/file_system_data_provider.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/data/data_providers/file_system_data_provider.dart b/lib/data/data_providers/file_system_data_provider.dart index 29338a996..3e8d9966d 100644 --- a/lib/data/data_providers/file_system_data_provider.dart +++ b/lib/data/data_providers/file_system_data_provider.dart @@ -51,7 +51,7 @@ class FileSystemData { final metadataFile = File(metadataPath); // --- בדיקת תוקף המטמון --- - bool isCacheValid = await cacheFile.exists(); // 1. האם המטמון קיים? + bool isCacheValid = await cacheFile.exists(); if (isCacheValid) { try { @@ -99,17 +99,16 @@ class FileSystemData { // --- סריקה מלאה (אם המטמון לא קיים או לא תקין) --- titleToPath = _getTitleToPath(); - metadata = _getMetadata(); // טעינה טרייה של המטא-דאטה + metadata = _getMetadata(); final lib = await _getLibraryFromDirectory( '$libraryPath${Platform.pathSeparator}אוצריא', await metadata); // --- יצירת קובץ מטמון חדש --- try { - // כותב לקובץ גם את המטא-דאטה כדי לחסוך קריאה בפעם הבאה final jsonMap = { 'library': lib.toJson(), 'titleToPath': await titleToPath, - 'metadata': await metadata, // הוספנו את המטא-דאטה! + 'metadata': await metadata, }; await cacheFile.writeAsString(jsonEncode(jsonMap)); } catch (_) { From 43b9079d87e9d99ce8c458c4c1062e308a288691 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 22 Jul 2025 08:38:59 +0300 Subject: [PATCH 023/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=9C?= =?UTF-8?q?=D7=97=D7=A6=D7=9F=20=D7=94=D7=97=D7=9C=D7=A3=20=D7=A9=D7=95?= =?UTF-8?q?=D7=9C=D7=97=D7=9F=20=D7=A2=D7=91=D7=95=D7=93=D7=94,=20=D7=91?= =?UTF-8?q?=D7=9E=D7=A1=D7=9A=20=D7=A1=D7=A4=D7=A8=D7=99=D7=99=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/library/view/library_browser.dart | 466 ++++++++++++++------------ 1 file changed, 250 insertions(+), 216 deletions(-) diff --git a/lib/library/view/library_browser.dart b/lib/library/view/library_browser.dart index 615b6da8f..a912db74a 100644 --- a/lib/library/view/library_browser.dart +++ b/lib/library/view/library_browser.dart @@ -26,6 +26,7 @@ import 'package:otzaria/widgets/filter_list/src/filter_list_dialog.dart'; import 'package:otzaria/widgets/filter_list/src/theme/filter_list_theme.dart'; import 'package:otzaria/library/view/grid_items.dart'; import 'package:otzaria/library/view/otzar_book_dialog.dart'; +import 'package:otzaria/workspaces/view/workspace_switcher_dialog.dart'; class LibraryBrowser extends StatefulWidget { const LibraryBrowser({Key? key}) : super(key: key); @@ -55,164 +56,179 @@ class _LibraryBrowserState extends State Widget build(BuildContext context) { super.build(context); return BlocBuilder( - builder: (context, settingsState) { - return BlocBuilder( - builder: (context, state) { - if (state.isLoading) { - return const Center( + builder: (context, settingsState) { + return BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return const Center( child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator(), - Text('טוען ספרייה...'), - ], - )); - } - - if (state.error != null) { - return Center(child: Text('Error: ${state.error}')); - } - - if (state.library == null) { - return const Center(child: Text('No library data available')); - } - - return Scaffold( - appBar: AppBar( - title: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Align( - alignment: Alignment.centerRight, - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.home), - tooltip: 'חזרה לתיקיה הראשית', - onPressed: () { - setState(() => _depth = 0); - context.read().add(LoadLibrary()); - context - .read() - .librarySearchController - .clear(); - _update(context, state, settingsState); - _refocusSearchBar(selectAll: true); - }, - ), - BlocProvider( - create: (context) => FileSyncBloc( - repository: FileSyncRepository( - githubOwner: "zevisvei", - repositoryName: "otzaria-library", - branch: "main", + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + Text('טוען ספרייה...'), + ], + ), + ); + } + + if (state.error != null) { + return Center(child: Text('Error: ${state.error}')); + } + + if (state.library == null) { + return const Center(child: Text('No library data available')); + } + + return Scaffold( + appBar: AppBar( + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Align( + alignment: Alignment.centerRight, + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.home), + tooltip: 'חזרה לתיקיה הראשית', + onPressed: () { + setState(() => _depth = 0); + context.read().add(LoadLibrary()); + context + .read() + .librarySearchController + .clear(); + _update(context, state, settingsState); + _refocusSearchBar(selectAll: true); + }, + ), + BlocProvider( + create: (context) => FileSyncBloc( + repository: FileSyncRepository( + githubOwner: "zevisvei", + repositoryName: "otzaria-library", + branch: "main", + ), ), + child: const SyncIconButton(), + ), + IconButton( + icon: const Icon(Icons.add_to_queue), + tooltip: 'החלף שולחן עבודה', + onPressed: () => + _showSwitchWorkspaceDialog(context), + ), + ], + ), + ), + Expanded( + child: Center( + child: Text( + state.currentCategory?.title ?? '', + style: TextStyle( + color: Theme.of(context).colorScheme.secondary, + fontSize: 20, + fontWeight: FontWeight.bold, ), - child: const SyncIconButton(), ), - ], + ), ), - ), - Expanded( - child: Center( - child: Text(state.currentCategory?.title ?? '', - style: TextStyle( - color: Theme.of(context).colorScheme.secondary, - fontSize: 20, - fontWeight: FontWeight.bold, - ))), - ), - DafYomi( - onDafYomiTap: (tractate, daf) { - openDafYomiBook(context, tractate, ' $daf.'); - }, - ) - ], + DafYomi( + onDafYomiTap: (tractate, daf) { + openDafYomiBook(context, tractate, ' $daf.'); + }, + ), + ], + ), + leading: IconButton( + icon: const Icon(Icons.arrow_upward), + tooltip: 'חזרה לתיקיה הקודמת', + onPressed: () { + if (state.currentCategory?.parent != null) { + setState(() => _depth = _depth > 0 ? _depth - 1 : 0); + context.read().add(NavigateUp()); + context.read().add(const SearchBooks()); + _refocusSearchBar(selectAll: true); + } + }, + ), ), - leading: IconButton( - icon: const Icon(Icons.arrow_upward), - tooltip: 'חזרה לתיקיה הקודמת', - onPressed: () { - if (state.currentCategory?.parent != null) { - setState(() => _depth = _depth > 0 ? _depth - 1 : 0); - context.read().add(NavigateUp()); - context.read().add(const SearchBooks()); - _refocusSearchBar(selectAll: true); - } - }, + body: Column( + children: [ + _buildSearchBar(state), + if (context + .read() + .librarySearchController + .text + .length > + 2) + _buildTopicsSelection(context, state, settingsState), + Expanded(child: _buildContent(state)), + ], ), - ), - body: Column( - children: [ - _buildSearchBar(state), - if (context - .read() - .librarySearchController - .text - .length > - 2) - _buildTopicsSelection(context, state, settingsState), - Expanded( - child: _buildContent(state), - ), - ], - ), - ); - }, - ); - }); + ); + }, + ); + }, + ); } Widget _buildSearchBar(LibraryState state) { return Padding( padding: const EdgeInsets.all(8.0), child: BlocBuilder( - builder: (context, settingsState) { - final focusRepository = context.read(); - return Row( - children: [ - Expanded( - child: TextField( - controller: focusRepository.librarySearchController, - focusNode: - context.read().librarySearchFocusNode, - autofocus: true, - decoration: InputDecoration( - constraints: const BoxConstraints(maxWidth: 400), - prefixIcon: const Icon(Icons.search), - suffixIcon: IconButton( - onPressed: () { - focusRepository.librarySearchController.clear(); - _update(context, state, settingsState); - _refocusSearchBar(); - }, - icon: const Icon(Icons.cancel), - ), - border: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(8.0)), + builder: (context, settingsState) { + final focusRepository = context.read(); + return Row( + children: [ + Expanded( + child: TextField( + controller: focusRepository.librarySearchController, + focusNode: context + .read() + .librarySearchFocusNode, + autofocus: true, + decoration: InputDecoration( + constraints: const BoxConstraints(maxWidth: 400), + prefixIcon: const Icon(Icons.search), + suffixIcon: IconButton( + onPressed: () { + focusRepository.librarySearchController.clear(); + _update(context, state, settingsState); + _refocusSearchBar(); + }, + icon: const Icon(Icons.cancel), + ), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + ), + hintText: + 'איתור ספר ב${state.currentCategory?.title ?? ""}', ), - hintText: 'איתור ספר ב${state.currentCategory?.title ?? ""}', + onChanged: (value) { + context.read().add(UpdateSearchQuery(value)); + context.read().add(const SelectTopics([])); + _update(context, state, settingsState); + }, ), - onChanged: (value) { - context.read().add(UpdateSearchQuery(value)); - context.read().add(const SelectTopics([])); - _update(context, state, settingsState); - }, ), - ), - if (settingsState.showExternalBooks) - IconButton( - icon: const Icon(Icons.filter_list), - onPressed: () => _showFilterDialog(context, state), - ), - ], - ); - }), + if (settingsState.showExternalBooks) + IconButton( + icon: const Icon(Icons.filter_list), + onPressed: () => _showFilterDialog(context, state), + ), + ], + ); + }, + ), ); } Widget _buildTopicsSelection( - BuildContext context, LibraryState state, SettingsState settingsState) { + BuildContext context, + LibraryState state, + SettingsState settingsState, + ) { if (state.searchResults == null) { return const SizedBox.shrink(); } @@ -232,13 +248,14 @@ class _LibraryBrowserState extends State "שות", "ראשונים", "אחרונים", - "מחברי זמננו" + "מחברי זמננו", ]; final allTopics = _getAllTopics(state.searchResults!); - final relevantTopics = - categoryTopics.where((element) => allTopics.contains(element)).toList(); + final relevantTopics = categoryTopics + .where((element) => allTopics.contains(element)) + .toList(); return FilterListWidget( hideSearchField: true, @@ -259,17 +276,16 @@ class _LibraryBrowserState extends State choiceChipLabel: (p0) => p0, hideSelectedTextCount: true, choiceChipBuilder: (context, item, isSelected) => Padding( - padding: const EdgeInsets.symmetric( - horizontal: 3, - vertical: 2, - ), + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2), child: Chip( label: Text(item), - backgroundColor: - isSelected! ? Theme.of(context).colorScheme.secondary : null, + backgroundColor: isSelected! + ? Theme.of(context).colorScheme.secondary + : null, labelStyle: TextStyle( - color: - isSelected ? Theme.of(context).colorScheme.onSecondary : null, + color: isSelected + ? Theme.of(context).colorScheme.onSecondary + : null, fontSize: 11, ), labelPadding: const EdgeInsets.all(0), @@ -355,34 +371,30 @@ class _LibraryBrowserState extends State items.add(Center(child: HeaderItem(category: subCategory))); items.add( MyGridView( - items: Future.value( - [ - ...subCategory.books.map((book) => _buildBookItem(book)), - ...subCategory.subCategories.map( - (cat) => CategoryGridItem( - category: cat, - onCategoryClickCallback: () => _openCategory(cat), - ), + items: Future.value([ + ...subCategory.books.map((book) => _buildBookItem(book)), + ...subCategory.subCategories.map( + (cat) => CategoryGridItem( + category: cat, + onCategoryClickCallback: () => _openCategory(cat), ), - ], - ), + ), + ]), ), ); } } else { items.add( MyGridView( - items: Future.value( - [ - ...category.books.map((book) => _buildBookItem(book)), - ...category.subCategories.map( - (cat) => CategoryGridItem( - category: cat, - onCategoryClickCallback: () => _openCategory(cat), - ), + items: Future.value([ + ...category.books.map((book) => _buildBookItem(book)), + ...category.subCategories.map( + (cat) => CategoryGridItem( + category: cat, + onCategoryClickCallback: () => _openCategory(cat), ), - ], - ), + ), + ]), ), ); } @@ -408,21 +420,29 @@ class _LibraryBrowserState extends State void _openBook(Book book) { if (book is PdfBook) { - context.read().add(AddTab(PdfBookTab( + context.read().add( + AddTab( + PdfBookTab( book: book, pageNumber: 1, - openLeftPane: (Settings.getValue('key-pin-sidebar') ?? - false) || + openLeftPane: + (Settings.getValue('key-pin-sidebar') ?? false) || (Settings.getValue('key-default-sidebar-open') ?? false), - ))); + ), + ), + ); } else if (book is TextBook) { - context.read().add(AddTab(TextBookTab( + context.read().add( + AddTab( + TextBookTab( book: book, index: 0, - openLeftPane: (Settings.getValue('key-pin-sidebar') ?? - false) || + openLeftPane: + (Settings.getValue('key-pin-sidebar') ?? false) || (Settings.getValue('key-default-sidebar-open') ?? false), - ))); + ), + ), + ); } context.read().add(const NavigateToScreen(Screen.reading)); } @@ -443,42 +463,50 @@ class _LibraryBrowserState extends State _refocusSearchBar(); } + void _showSwitchWorkspaceDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => const WorkspaceSwitcherDialog(), + ); + } + void _showFilterDialog(BuildContext context, LibraryState state) { showDialog( context: context, builder: (context) => AlertDialog( content: BlocBuilder( - builder: (context, settingsState) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - CheckboxListTile( - title: const Text('הצג ספרים מאוצר החכמה'), - value: settingsState.showOtzarHachochma, - onChanged: (bool? value) { - setState(() { - context - .read() - .add(UpdateShowOtzarHachochma(value!)); - _update(context, state, settingsState); - }); - }, - ), - CheckboxListTile( - title: const Text('הצג ספרים מהיברובוקס'), - value: settingsState.showHebrewBooks, - onChanged: (bool? value) { - setState(() { - context - .read() - .add(UpdateShowHebrewBooks(value!)); - _update(context, state, settingsState); - }); - }, - ), - ], - ); - }), + builder: (context, settingsState) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + CheckboxListTile( + title: const Text('הצג ספרים מאוצר החכמה'), + value: settingsState.showOtzarHachochma, + onChanged: (bool? value) { + setState(() { + context.read().add( + UpdateShowOtzarHachochma(value!), + ); + _update(context, state, settingsState); + }); + }, + ), + CheckboxListTile( + title: const Text('הצג ספרים מהיברובוקס'), + value: settingsState.showHebrewBooks, + onChanged: (bool? value) { + setState(() { + context.read().add( + UpdateShowHebrewBooks(value!), + ); + _update(context, state, settingsState); + }); + }, + ), + ], + ); + }, + ), ), ).then((_) => _refocusSearchBar()); } @@ -492,15 +520,21 @@ class _LibraryBrowserState extends State } void _update( - BuildContext context, LibraryState state, SettingsState settingsState) { - context.read().add(UpdateSearchQuery( - context.read().librarySearchController.text)); + BuildContext context, + LibraryState state, + SettingsState settingsState, + ) { context.read().add( - SearchBooks( - showHebrewBooks: settingsState.showHebrewBooks, - showOtzarHachochma: settingsState.showOtzarHachochma, - ), - ); + UpdateSearchQuery( + context.read().librarySearchController.text, + ), + ); + context.read().add( + SearchBooks( + showHebrewBooks: settingsState.showHebrewBooks, + showOtzarHachochma: settingsState.showOtzarHachochma, + ), + ); setState(() {}); _refocusSearchBar(); } From 283b475983aa235e43a9027ae07715be9ca9b9e5 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 22 Jul 2025 08:49:37 +0300 Subject: [PATCH 024/197] =?UTF-8?q?=D7=9C=D7=97=D7=A6=D7=9F=20=D7=9C=D7=A1?= =?UTF-8?q?=D7=92=D7=99=D7=A8=D7=AA=20=D7=95=D7=A4=D7=AA=D7=99=D7=97=D7=AA?= =?UTF-8?q?=20=D7=97=D7=9C=D7=95=D7=A0=D7=99=D7=AA=20=D7=94=D7=A4=D7=A8?= =?UTF-8?q?=D7=A9=D7=A0=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../splited_view/splited_view_screen.dart | 143 ++++++++++++------ 1 file changed, 99 insertions(+), 44 deletions(-) diff --git a/lib/text_book/view/splited_view/splited_view_screen.dart b/lib/text_book/view/splited_view/splited_view_screen.dart index 634c274ff..477078ee4 100644 --- a/lib/text_book/view/splited_view/splited_view_screen.dart +++ b/lib/text_book/view/splited_view/splited_view_screen.dart @@ -34,13 +34,42 @@ class _SplitedViewScreenState extends State { static final GlobalKey _selectionKey = GlobalKey(); + bool _paneOpen = true; + @override void initState() { super.initState(); - _controller = MultiSplitViewController(areas: [ - Area(weight: 0.4, minimalSize: 200), - Area(weight: 0.6, minimalSize: 200), - ]); + _controller = MultiSplitViewController(areas: _openAreas()); + } + + List _openAreas() => [ + Area(weight: 0.4, minimalSize: 200), + Area(weight: 0.6, minimalSize: 200), + ]; + + List _closedAreas() => [ + Area(weight: 0, minimalSize: 0), + Area(weight: 1, minimalSize: 200), + ]; + + void _updateAreas() { + _controller.areas = _paneOpen ? _openAreas() : _closedAreas(); + } + + void _togglePane() { + setState(() { + _paneOpen = !_paneOpen; + _updateAreas(); + }); + } + + void _openPane() { + if (!_paneOpen) { + setState(() { + _paneOpen = true; + _updateAreas(); + }); + } } @override @@ -67,7 +96,17 @@ class _SplitedViewScreenState extends State { @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocConsumer( + listenWhen: (previous, current) { + return previous is TextBookLoaded && + current is TextBookLoaded && + previous.activeCommentators != current.activeCommentators; + }, + listener: (context, state) { + if (state is TextBookLoaded) { + _openPane(); + } + }, buildWhen: (previous, current) { if (previous is TextBookLoaded && current is TextBookLoaded) { return previous.fontSize != current.fontSize || @@ -80,49 +119,65 @@ class _SplitedViewScreenState extends State { if (state is! TextBookLoaded) { return const Center(child: CircularProgressIndicator()); } - - return MultiSplitView( - controller: _controller, - axis: Axis.horizontal, - resizable: true, - dividerBuilder: - (axis, index, resizable, dragging, highlighted, themeData) { - final color = dragging - ? Theme.of(context).colorScheme.primary - : Theme.of(context).dividerColor; - return MouseRegion( - cursor: SystemMouseCursors.resizeColumn, - child: Container( - width: 8, - alignment: Alignment.center, - child: Container( - width: 1.5, - color: color, - ), - ), - ); - }, + return Stack( children: [ - ContextMenuRegion( - contextMenu: _buildContextMenu(state), - child: SelectionArea( - key: _selectionKey, - child: CommentaryList( - index: 0, - fontSize: state.fontSize, + MultiSplitView( + controller: _controller, + axis: Axis.horizontal, + resizable: true, + dividerBuilder: + (axis, index, resizable, dragging, highlighted, themeData) { + final color = dragging + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor; + return MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + child: Container( + width: 8, + alignment: Alignment.center, + child: Container( + width: 1.5, + color: color, + ), + ), + ); + }, + children: [ + ContextMenuRegion( + contextMenu: _buildContextMenu(state), + child: SelectionArea( + key: _selectionKey, + child: _paneOpen + ? CommentaryList( + index: 0, + fontSize: state.fontSize, + openBookCallback: widget.openBookCallback, + showSplitView: state.showSplitView, + ) + : const SizedBox.shrink(), + ), + ), + SimpleBookView( + data: widget.content, + textSize: state.fontSize, openBookCallback: widget.openBookCallback, - showSplitView: state.showSplitView, + openLeftPaneTab: widget.openLeftPaneTab, + showSplitedView: state.showSplitView, + tab: widget.tab, ), - ), - ), - SimpleBookView( - data: widget.content, - textSize: state.fontSize, - openBookCallback: widget.openBookCallback, - openLeftPaneTab: widget.openLeftPaneTab, - showSplitedView: state.showSplitView, - tab: widget.tab, + ], ), + Positioned( + left: 0, + top: 0, + child: IconButton( + iconSize: 16, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + icon: Icon(_paneOpen ? Icons.close : Icons.drag_handle), + onPressed: _togglePane, + ), + ) ], ); }, From a9d6f6dd2c0119d01731801dbe2521edcef53d56 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 22 Jul 2025 09:14:43 +0300 Subject: [PATCH 025/197] =?UTF-8?q?=D7=A9=D7=99=D7=A4=D7=95=D7=A8=20=D7=9C?= =?UTF-8?q?=D7=97=D7=A6=D7=9F=20=D7=9C=D7=A1=D7=92=D7=99=D7=A8=D7=AA=20?= =?UTF-8?q?=D7=95=D7=A4=D7=AA=D7=99=D7=97=D7=AA=20=D7=97=D7=9C=D7=95=D7=A0?= =?UTF-8?q?=D7=99=D7=AA=20=D7=94=D7=A4=D7=A8=D7=A9=D7=A0=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../commentary_list_for_splited_view.dart | 80 ++++++++++++++----- .../splited_view/splited_view_screen.dart | 48 ++++++++--- 2 files changed, 93 insertions(+), 35 deletions(-) diff --git a/lib/text_book/view/splited_view/commentary_list_for_splited_view.dart b/lib/text_book/view/splited_view/commentary_list_for_splited_view.dart index 06c20d0c0..2405be329 100644 --- a/lib/text_book/view/splited_view/commentary_list_for_splited_view.dart +++ b/lib/text_book/view/splited_view/commentary_list_for_splited_view.dart @@ -13,6 +13,7 @@ class CommentaryList extends StatefulWidget { final double fontSize; final int index; final bool showSplitView; + final VoidCallback? onClosePane; const CommentaryList({ super.key, @@ -20,6 +21,7 @@ class CommentaryList extends StatefulWidget { required this.fontSize, required this.index, required this.showSplitView, + this.onClosePane, }); @override @@ -51,28 +53,61 @@ class _CommentaryListState extends State { children: [ Padding( padding: const EdgeInsets.all(8.0), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'חפש בתוך הפרשנים המוצגים...', - prefixIcon: const Icon(Icons.search), - suffixIcon: _searchQuery.isNotEmpty - ? IconButton( - icon: const Icon(Icons.close), - onPressed: () { - _searchController.clear(); - setState(() => _searchQuery = ''); - }, - ) - : null, - isDense: true, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'חפש בתוך הפרשנים המוצגים...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.close), + onPressed: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + ) + : null, + isDense: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + ), + ), + onChanged: (value) { + setState(() => _searchQuery = value); + }, + ), ), - ), - onChanged: (value) { - setState(() => _searchQuery = value); - }, + const SizedBox(width: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surface + .withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: IconButton( + iconSize: 18, + padding: const EdgeInsets.all(8), + constraints: const BoxConstraints( + minWidth: 36, + minHeight: 36, + ), + icon: const Icon(Icons.close), + onPressed: widget.onClosePane, + ), + ), + ], ), ), Expanded( @@ -117,4 +152,5 @@ class _CommentaryListState extends State { ], ); }); - }} \ No newline at end of file + } +} diff --git a/lib/text_book/view/splited_view/splited_view_screen.dart b/lib/text_book/view/splited_view/splited_view_screen.dart index 477078ee4..27b958277 100644 --- a/lib/text_book/view/splited_view/splited_view_screen.dart +++ b/lib/text_book/view/splited_view/splited_view_screen.dart @@ -81,9 +81,7 @@ class _SplitedViewScreenState extends State { ContextMenu _buildContextMenu(TextBookLoaded state) { return ContextMenu( entries: [ - MenuItem( - label: 'חיפוש', - onSelected: () => widget.openLeftPaneTab(1)), + MenuItem(label: 'חיפוש', onSelected: () => widget.openLeftPaneTab(1)), const MenuDivider(), MenuItem( label: 'בחר את כל הטקסט', @@ -153,6 +151,7 @@ class _SplitedViewScreenState extends State { fontSize: state.fontSize, openBookCallback: widget.openBookCallback, showSplitView: state.showSplitView, + onClosePane: _togglePane, ) : const SizedBox.shrink(), ), @@ -167,17 +166,40 @@ class _SplitedViewScreenState extends State { ), ], ), - Positioned( - left: 0, - top: 0, - child: IconButton( - iconSize: 16, - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - icon: Icon(_paneOpen ? Icons.close : Icons.drag_handle), - onPressed: _togglePane, + if (!_paneOpen) + Positioned( + left: 8, + top: 8, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surface + .withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: IconButton( + iconSize: 18, + padding: const EdgeInsets.all(8), + constraints: const BoxConstraints( + minWidth: 36, + minHeight: 36, + ), + icon: Icon( + Icons.menu_open, + color: Theme.of(context).colorScheme.onSurface, + ), + onPressed: _togglePane, + ), + ), ), - ) ], ); }, From 5078647859cac2ae8390f638078af7adb6959a91 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 25 Jul 2025 14:50:31 +0300 Subject: [PATCH 026/197] =?UTF-8?q?=D7=94=D7=9B=D7=A0=D7=AA=20=D7=94=D7=9E?= =?UTF-8?q?=D7=9E=D7=A9=D7=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../view/full_text_facet_filtering.dart | 151 +++++++++++------- .../view/full_text_settings_widgets.dart | 58 ++++--- lib/search/view/tantivy_full_text_search.dart | 4 + lib/search/view/tantivy_search_results.dart | 42 +++-- 4 files changed, 162 insertions(+), 93 deletions(-) diff --git a/lib/search/view/full_text_facet_filtering.dart b/lib/search/view/full_text_facet_filtering.dart index 36016e948..836051c27 100644 --- a/lib/search/view/full_text_facet_filtering.dart +++ b/lib/search/view/full_text_facet_filtering.dart @@ -16,6 +16,21 @@ const double _kTreeLevelIndent = 10.0; const double _kMinQueryLength = 2; const double _kBackgroundOpacity = 0.1; +/// A reusable divider widget that creates a line with a consistent height, +/// color, and margin to match other dividers in the UI. +class ThinDivider extends StatelessWidget { + const ThinDivider({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + height: 1, // 1 logical pixel is sufficient here + color: Colors.grey.shade300, + margin: const EdgeInsets.symmetric(horizontal: 8.0), + ); + } +} + class SearchFacetFiltering extends StatefulWidget { final SearchingTab tab; @@ -51,6 +66,14 @@ class _SearchFacetFilteringState extends State super.initState(); } + void _onQueryChanged(String query) { + if (query.length >= _kMinQueryLength) { + context.read().add(UpdateFilterQuery(query)); + } else if (query.isEmpty) { + context.read().add(ClearFilter()); + } + } + void _handleFacetToggle(BuildContext context, String facet) { final searchBloc = context.read(); final state = searchBloc.state; @@ -66,23 +89,30 @@ class _SearchFacetFilteringState extends State } Widget _buildSearchField() { - return TextField( - controller: _filterQuery, - decoration: InputDecoration( - hintText: "איתור ספר...", - prefixIcon: const Icon(Icons.filter_list_alt), - suffixIcon: IconButton( - onPressed: _clearFilter, - icon: const Icon(Icons.close), + return Container( + height: 60, // Same height as the container on the right + alignment: Alignment.center, // Vertically centers the TextField + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: TextField( + controller: _filterQuery, + decoration: InputDecoration( + hintText: 'איתור ספר…', + prefixIcon: const Icon(Icons.filter_list_alt), + suffixIcon: IconButton( + onPressed: _clearFilter, + icon: const Icon(Icons.close), + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + ), ), + onChanged: _onQueryChanged, ), - onChanged: (query) { - if (query.length >= 3) { - context.read().add(UpdateFilterQuery(query)); - } else if (query.isEmpty) { - context.read().add(ClearFilter()); - } - }, ); } @@ -127,9 +157,9 @@ class _SearchFacetFilteringState extends State final facet = "/${book.topics.replaceAll(', ', '/')}/${book.title}"; return Builder( builder: (context) { - final count = context.read().countForFacet(facet); + final countFuture = context.read().countForFacet(facet); return FutureBuilder( - future: count, + future: countFuture, builder: (context, snapshot) { if (snapshot.hasData) { return _buildBookTile(book, snapshot.data!, 0); @@ -182,7 +212,6 @@ class _SearchFacetFilteringState extends State tilePadding: EdgeInsets.only( right: _kTreePadding + (level * _kTreeLevelIndent), ), - onExpansionChanged: (_) {}, children: _buildCategoryChildren(category, level), ), ); @@ -193,10 +222,10 @@ class _SearchFacetFilteringState extends State List _buildCategoryChildren(Category category, int level) { return [ ...category.subCategories.map((subCategory) { - final count = + final countFuture = context.read().countForFacet(subCategory.path); return FutureBuilder( - future: count, + future: countFuture, builder: (context, snapshot) { if (snapshot.hasData) { return _buildCategoryTile(subCategory, snapshot.data!, level + 1); @@ -207,9 +236,9 @@ class _SearchFacetFilteringState extends State }), ...category.books.map((book) { final facet = "/${book.topics.replaceAll(', ', '/')}/${book.title}"; - final count = context.read().countForFacet(facet); + final countFuture = context.read().countForFacet(facet); return FutureBuilder( - future: count, + future: countFuture, builder: (context, snapshot) { if (snapshot.hasData) { return _buildBookTile(book, snapshot.data!, level + 1); @@ -221,50 +250,54 @@ class _SearchFacetFilteringState extends State ]; } + Widget _buildFacetTree() { + return BlocBuilder( + builder: (context, libraryState) { + if (libraryState.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (libraryState.error != null) { + return Center(child: Text('Error: ${libraryState.error}')); + } + + if (_filterQuery.text.length >= _kMinQueryLength) { + return _buildBooksList( + context.read().state.filteredBooks ?? []); + } + + if (libraryState.library == null) { + return const Center(child: Text('No library data available')); + } + + final rootCategory = libraryState.library!; + final countFuture = + context.read().countForFacet(rootCategory.path); + return FutureBuilder( + future: countFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + return SingleChildScrollView( + key: PageStorageKey(widget.tab), + child: _buildCategoryTile(rootCategory, snapshot.data!, 0), + ); + } + return const Center(child: CircularProgressIndicator()); + }, + ); + }, + ); + } + @override Widget build(BuildContext context) { super.build(context); return Column( children: [ _buildSearchField(), + const ThinDivider(), // Now perfectly aligned Expanded( - child: BlocBuilder( - builder: (context, libraryState) { - if (libraryState.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - - if (libraryState.error != null) { - return Center(child: Text('Error: ${libraryState.error}')); - } - - if (_filterQuery.text.length >= _kMinQueryLength) { - return _buildBooksList( - context.read().state.filteredBooks ?? []); - } - - if (libraryState.library == null) { - return const Center(child: Text('No library data available')); - } - - final rootCategory = libraryState.library!; - final count = - context.read().countForFacet(rootCategory.path); - return FutureBuilder( - future: count, - builder: (context, snapshot) { - if (snapshot.hasData) { - return SingleChildScrollView( - key: PageStorageKey(widget.tab), - child: - _buildCategoryTile(rootCategory, snapshot.data!, 0), - ); - } - return const Center(child: CircularProgressIndicator()); - }, - ); - }, - ), + child: _buildFacetTree(), ), ], ); diff --git a/lib/search/view/full_text_settings_widgets.dart b/lib/search/view/full_text_settings_widgets.dart index dac7f7846..0c8bb40d5 100644 --- a/lib/search/view/full_text_settings_widgets.dart +++ b/lib/search/view/full_text_settings_widgets.dart @@ -87,9 +87,10 @@ class NumOfResults extends StatelessWidget { return BlocBuilder( builder: (context, state) { return SizedBox( - width: 200, + width: 180, + height: 52, // גובה קבוע child: Padding( - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), child: SpinBox( value: state.numResults.toDouble(), onChanged: (value) => context @@ -97,7 +98,11 @@ class NumOfResults extends StatelessWidget { .add(UpdateNumResults(value.toInt())), min: 10, max: 10000, - decoration: const InputDecoration(labelText: 'מספר תוצאות'), + decoration: const InputDecoration( + labelText: 'מספר תוצאות', + contentPadding: + EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + ), ), ), ); @@ -119,25 +124,34 @@ class OrderOfResults extends StatelessWidget { return BlocBuilder( builder: (context, state) { return SizedBox( - width: 300, - child: Center( - child: DropdownButton( - value: state.sortBy, - items: const [ - DropdownMenuItem( - value: ResultsOrder.relevance, - child: Text('מיון לפי רלוונטיות'), - ), - DropdownMenuItem( - value: ResultsOrder.catalogue, - child: Text('מיון לפי סדר קטלוגי'), - ), - ], - onChanged: (value) { - if (value != null) { - context.read().add(UpdateSortOrder(value)); - } - }), + width: 220, // רוחב גדול יותר לטקסט הארוך + height: 52, // גובה קבוע כמו NumOfResults + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: DropdownButtonFormField( + value: state.sortBy, + decoration: const InputDecoration( + labelText: 'מיון', + border: OutlineInputBorder(), + contentPadding: + EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + ), + items: const [ + DropdownMenuItem( + value: ResultsOrder.relevance, + child: Text('לפי רלוונטיות'), + ), + DropdownMenuItem( + value: ResultsOrder.catalogue, + child: Text('לפי סדר קטלוגי'), + ), + ], + onChanged: (value) { + if (value != null) { + context.read().add(UpdateSortOrder(value)); + } + }, + ), ), ); }, diff --git a/lib/search/view/tantivy_full_text_search.dart b/lib/search/view/tantivy_full_text_search.dart index 4f69703f8..a54ca5c78 100644 --- a/lib/search/view/tantivy_full_text_search.dart +++ b/lib/search/view/tantivy_full_text_search.dart @@ -163,6 +163,10 @@ class _TantivyFullTextSearchState extends State width: 350, child: SearchFacetFiltering(tab: widget.tab), ), + Container( + width: 1, + color: Colors.grey.shade300, + ), Expanded( child: Builder(builder: (context) { if (state.isLoading) { diff --git a/lib/search/view/tantivy_search_results.dart b/lib/search/view/tantivy_search_results.dart index 6d0f90889..7fb2c2f3c 100644 --- a/lib/search/view/tantivy_search_results.dart +++ b/lib/search/view/tantivy_search_results.dart @@ -262,26 +262,44 @@ class _TantivySearchResultsState extends State { return Column( children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Center( - child: Row( - children: [ - Expanded( + // פס עליון עם הבקרות - גובה קבוע + Container( + height: 60, // גובה קבוע + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: Row( + children: [ + const Spacer(), // דוחף הכל לצד ימין + // ספירת התוצאות במלבן + Container( + height: 52, // אותו גובה כמו הבקרות האחרות + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(4.0), + ), + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), child: Center( child: Text( '${state.results.length} תוצאות מתוך ${state.totalResults}', + style: const TextStyle(fontSize: 14), ), ), ), - if (constrains.maxWidth > 450) - OrderOfResults(widget: widget), - if (constrains.maxWidth > 450) - NumOfResults(tab: widget.tab), - ], - ), + ), + if (constrains.maxWidth > 450) + OrderOfResults(widget: widget), + if (constrains.maxWidth > 450) + NumOfResults(tab: widget.tab), + ], ), ), + // פס מפריד מתחת לשורת הבקרות + Container( + height: 1, + color: Colors.grey.shade300, + margin: const EdgeInsets.symmetric(horizontal: 8.0), + ), Expanded( child: ListView.builder( shrinkWrap: true, From 0b480e622f1f7ab88cf9828a40b5f12e6ff9480b Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 25 Jul 2025 16:08:55 +0300 Subject: [PATCH 027/197] =?UTF-8?q?=D7=AA=D7=A4=D7=A8=D7=99=D7=98=20=D7=90?= =?UTF-8?q?=D7=A4=D7=A9=D7=A8=D7=95=D7=99=D7=95=D7=AA=20=D7=97=D7=99=D7=A4?= =?UTF-8?q?=D7=95=D7=A9=20=D7=A0=D7=95=D7=A1=D7=A4=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/search_options_dropdown.dart | 112 +++++++++++++++++++ lib/search/view/tantivy_search_field.dart | 19 +++- 2 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 lib/search/view/search_options_dropdown.dart diff --git a/lib/search/view/search_options_dropdown.dart b/lib/search/view/search_options_dropdown.dart new file mode 100644 index 000000000..8d66ea17a --- /dev/null +++ b/lib/search/view/search_options_dropdown.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; + +class SearchOptionsDropdown extends StatefulWidget { + const SearchOptionsDropdown({super.key}); + + @override + State createState() => _SearchOptionsDropdownState(); +} + +class _SearchOptionsDropdownState extends State { + final Map _options = { + 'קידומות': false, + 'סיומות': false, + 'קידומות דקדוקיות': false, + 'סיומות דקדוקיות': false, + 'כתיב חסר': false, + 'שורש': false, + }; + + @override + Widget build(BuildContext context) { + return PopupMenuButton( + icon: const Icon(Icons.keyboard_arrow_down), + tooltip: 'אפשרויות חיפוש', + offset: const Offset(0, 40), + constraints: const BoxConstraints(minWidth: 200, maxWidth: 250), + color: Theme.of(context).popupMenuTheme.color ?? Theme.of(context).canvasColor, + itemBuilder: (BuildContext context) { + return [ + // כותרת התפריט + PopupMenuItem( + enabled: false, + height: 30, + child: Text( + 'אפשרויות חיפוש', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Theme.of(context).textTheme.bodyLarge?.color, + ), + ), + ), + const PopupMenuDivider(), + // האפשרויות + ..._options.keys.map((String option) { + return PopupMenuItem( + value: option, + enabled: false, // מונע סגירה של התפריט בלחיצה + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setMenuState) { + return InkWell( + onTap: () { + setMenuState(() { + _options[option] = !_options[option]!; + }); + setState(() {}); // עדכון המצב הכללי + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + option, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Theme.of(context).textTheme.bodyLarge?.color, + ), + ), + ), + const SizedBox(width: 16), + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + border: Border.all( + color: _options[option]! + ? Theme.of(context).primaryColor + : Colors.grey.shade600, + width: 2, + ), + borderRadius: BorderRadius.circular(4), + color: _options[option]! + ? Theme.of(context).primaryColor.withValues(alpha: 0.1) + : Colors.transparent, + ), + child: _options[option]! + ? Icon( + Icons.check, + size: 16, + color: Theme.of(context).textTheme.bodyLarge?.color ?? Theme.of(context).primaryColor, + ) + : null, + ), + ], + ), + ), + ); + }, + ), + ); + }).toList(), + ]; + }, + onSelected: (String value) { + // כרגע לא נעשה כלום - כפי שביקשת + }, + ); + } +} \ No newline at end of file diff --git a/lib/search/view/tantivy_search_field.dart b/lib/search/view/tantivy_search_field.dart index 373d01a23..36c66adce 100644 --- a/lib/search/view/tantivy_search_field.dart +++ b/lib/search/view/tantivy_search_field.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:otzaria/search/bloc/search_bloc.dart'; import 'package:otzaria/search/bloc/search_event.dart'; import 'package:otzaria/search/view/tantivy_full_text_search.dart'; +import 'package:otzaria/search/view/search_options_dropdown.dart'; class TantivySearchField extends StatelessWidget { const TantivySearchField({ @@ -35,12 +36,18 @@ class TantivySearchField extends StatelessWidget { }, icon: const Icon(Icons.search), ), - suffixIcon: IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - widget.tab.queryController.clear(); - context.read().add(UpdateSearchQuery('')); - }, + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SearchOptionsDropdown(), + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + widget.tab.queryController.clear(); + context.read().add(UpdateSearchQuery('')); + }, + ), + ], ), ), ), From c9b3a6014544a72e996d4e0469d526265e66745b Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 25 Jul 2025 16:57:48 +0300 Subject: [PATCH 028/197] =?UTF-8?q?=D7=94=D7=9B=D7=A0=D7=AA=20=D7=94=D7=9E?= =?UTF-8?q?=D7=9E=D7=A9=D7=A7=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../view/full_text_settings_widgets.dart | 54 +++++++- lib/search/view/search_options_dropdown.dart | 125 ++++++++++-------- lib/search/view/tantivy_search_results.dart | 12 +- 3 files changed, 128 insertions(+), 63 deletions(-) diff --git a/lib/search/view/full_text_settings_widgets.dart b/lib/search/view/full_text_settings_widgets.dart index 0c8bb40d5..5a835e759 100644 --- a/lib/search/view/full_text_settings_widgets.dart +++ b/lib/search/view/full_text_settings_widgets.dart @@ -87,7 +87,7 @@ class NumOfResults extends StatelessWidget { return BlocBuilder( builder: (context, state) { return SizedBox( - width: 180, + width: 160, height: 52, // גובה קבוע child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), @@ -111,6 +111,56 @@ class NumOfResults extends StatelessWidget { } } +class SearchTermsDisplay extends StatelessWidget { + const SearchTermsDisplay({ + super.key, + required this.tab, + }); + + final SearchingTab tab; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + // חישוב רוחב מותאם לטקסט + final textLength = state.searchQuery.length; + const minWidth = 120.0; // רוחב מינימלי + const maxWidth = 300.0; // רוחב מקסימלי + final calculatedWidth = (textLength * 8.0 + 60).clamp(minWidth, maxWidth); + + return Container( + height: 52, // גובה קבוע כמו שאר הווידג'טים + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: minWidth, + maxWidth: calculatedWidth, + ), + child: TextField( + readOnly: true, + controller: TextEditingController(text: state.searchQuery), + textAlign: TextAlign.center, // ממרכז את הטקסט + decoration: const InputDecoration( + labelText: 'מילות החיפוש', + border: OutlineInputBorder(), + contentPadding: + EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + ), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ); + }, + ); + } +} + class OrderOfResults extends StatelessWidget { const OrderOfResults({ super.key, @@ -124,7 +174,7 @@ class OrderOfResults extends StatelessWidget { return BlocBuilder( builder: (context, state) { return SizedBox( - width: 220, // רוחב גדול יותר לטקסט הארוך + width: 175, // רוחב גדול יותר לטקסט הארוך height: 52, // גובה קבוע כמו NumOfResults child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), diff --git a/lib/search/view/search_options_dropdown.dart b/lib/search/view/search_options_dropdown.dart index 8d66ea17a..5e707a577 100644 --- a/lib/search/view/search_options_dropdown.dart +++ b/lib/search/view/search_options_dropdown.dart @@ -13,7 +13,7 @@ class _SearchOptionsDropdownState extends State { 'סיומות': false, 'קידומות דקדוקיות': false, 'סיומות דקדוקיות': false, - 'כתיב חסר': false, + 'כתיב מלא/חסר': false, 'שורש': false, }; @@ -24,7 +24,8 @@ class _SearchOptionsDropdownState extends State { tooltip: 'אפשרויות חיפוש', offset: const Offset(0, 40), constraints: const BoxConstraints(minWidth: 200, maxWidth: 250), - color: Theme.of(context).popupMenuTheme.color ?? Theme.of(context).canvasColor, + color: Theme.of(context).popupMenuTheme.color ?? + Theme.of(context).canvasColor, itemBuilder: (BuildContext context) { return [ // כותרת התפריט @@ -43,65 +44,75 @@ class _SearchOptionsDropdownState extends State { const PopupMenuDivider(), // האפשרויות ..._options.keys.map((String option) { - return PopupMenuItem( - value: option, - enabled: false, // מונע סגירה של התפריט בלחיצה - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setMenuState) { - return InkWell( - onTap: () { - setMenuState(() { - _options[option] = !_options[option]!; - }); - setState(() {}); // עדכון המצב הכללי - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - option, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Theme.of(context).textTheme.bodyLarge?.color, + return PopupMenuItem( + value: option, + enabled: false, // מונע סגירה של התפריט בלחיצה + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setMenuState) { + return InkWell( + onTap: () { + setMenuState(() { + _options[option] = !_options[option]!; + }); + setState(() {}); // עדכון המצב הכללי + }, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 12.0, horizontal: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + option, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Theme.of(context) + .textTheme + .bodyLarge + ?.color, + ), ), ), - ), - const SizedBox(width: 16), - Container( - width: 20, - height: 20, - decoration: BoxDecoration( - border: Border.all( - color: _options[option]! - ? Theme.of(context).primaryColor - : Colors.grey.shade600, - width: 2, + const SizedBox(width: 16), + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + border: Border.all( + color: _options[option]! + ? Theme.of(context).primaryColor + : Colors.grey.shade600, + width: 2, + ), + borderRadius: BorderRadius.circular(4), + color: _options[option]! + ? Theme.of(context) + .primaryColor + .withValues(alpha: 0.1) + : Colors.transparent, ), - borderRadius: BorderRadius.circular(4), - color: _options[option]! - ? Theme.of(context).primaryColor.withValues(alpha: 0.1) - : Colors.transparent, + child: _options[option]! + ? Icon( + Icons.check, + size: 16, + color: Theme.of(context) + .textTheme + .bodyLarge + ?.color ?? + Theme.of(context).primaryColor, + ) + : null, ), - child: _options[option]! - ? Icon( - Icons.check, - size: 16, - color: Theme.of(context).textTheme.bodyLarge?.color ?? Theme.of(context).primaryColor, - ) - : null, - ), - ], + ], + ), ), - ), - ); - }, - ), - ); - }).toList(), + ); + }, + ), + ); + }).toList(), ]; }, onSelected: (String value) { @@ -109,4 +120,4 @@ class _SearchOptionsDropdownState extends State { }, ); } -} \ No newline at end of file +} diff --git a/lib/search/view/tantivy_search_results.dart b/lib/search/view/tantivy_search_results.dart index 7fb2c2f3c..9b1813dcc 100644 --- a/lib/search/view/tantivy_search_results.dart +++ b/lib/search/view/tantivy_search_results.dart @@ -265,20 +265,24 @@ class _TantivySearchResultsState extends State { // פס עליון עם הבקרות - גובה קבוע Container( height: 60, // גובה קבוע - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + padding: + const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), child: Row( children: [ - const Spacer(), // דוחף הכל לצד ימין + // מילות החיפוש - מקבל את כל המקום הנותר + Expanded(child: SearchTermsDisplay(tab: widget.tab)), // ספירת התוצאות במלבן Container( height: 52, // אותו גובה כמו הבקרות האחרות - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + padding: const EdgeInsets.symmetric( + horizontal: 8.0, vertical: 4.0), child: Container( decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade400), borderRadius: BorderRadius.circular(4.0), ), - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + padding: const EdgeInsets.symmetric( + horizontal: 12.0, vertical: 8.0), child: Center( child: Text( '${state.results.length} תוצאות מתוך ${state.totalResults}', From 1a1d83c68bccd5266d93008535f902513228fe98 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 25 Jul 2025 18:53:47 +0300 Subject: [PATCH 029/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=9E?= =?UTF-8?q?=D7=9E=D7=A9=D7=A7=20=D7=9C=D7=9E=D7=99=D7=9C=D7=94=20=D7=97?= =?UTF-8?q?=D7=99=D7=9C=D7=95=D7=A4=D7=99=D7=AA=20-=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/models/search_terms_model.dart | 77 ++++ lib/search/view/enhanced_search_field.dart | 425 ++++++++++++++++++ .../view/full_text_settings_widgets.dart | 22 +- lib/search/view/tantivy_search_field.dart | 44 +- 4 files changed, 521 insertions(+), 47 deletions(-) create mode 100644 lib/search/models/search_terms_model.dart create mode 100644 lib/search/view/enhanced_search_field.dart diff --git a/lib/search/models/search_terms_model.dart b/lib/search/models/search_terms_model.dart new file mode 100644 index 000000000..0499fbdfa --- /dev/null +++ b/lib/search/models/search_terms_model.dart @@ -0,0 +1,77 @@ +class SearchTerm { + final String word; + final List alternatives; + + SearchTerm({ + required this.word, + this.alternatives = const [], + }); + + SearchTerm copyWith({ + String? word, + List? alternatives, + }) { + return SearchTerm( + word: word ?? this.word, + alternatives: alternatives ?? this.alternatives, + ); + } + + SearchTerm addAlternative(String alternative) { + return copyWith( + alternatives: [...alternatives, alternative], + ); + } + + SearchTerm removeAlternative(int index) { + final newAlternatives = List.from(alternatives); + if (index >= 0 && index < newAlternatives.length) { + newAlternatives.removeAt(index); + } + return copyWith(alternatives: newAlternatives); + } + + String get displayText { + if (alternatives.isEmpty) { + return word; + } + return '$word או ${alternatives.join(' או ')}'; + } +} + +class SearchQuery { + final List terms; + + SearchQuery({this.terms = const []}); + + SearchQuery copyWith({List? terms}) { + return SearchQuery(terms: terms ?? this.terms); + } + + SearchQuery updateTerm(int index, SearchTerm term) { + final newTerms = List.from(terms); + if (index >= 0 && index < newTerms.length) { + newTerms[index] = term; + } + return copyWith(terms: newTerms); + } + + String get displayText { + if (terms.isEmpty) return ''; + return terms.map((term) => term.displayText).join(' ו '); + } + + String get originalQuery { + return terms.map((term) => term.word).join(' '); + } + + static SearchQuery fromString(String query) { + if (query.trim().isEmpty) { + return SearchQuery(); + } + + final words = query.trim().split(RegExp(r'\s+')); + final terms = words.map((word) => SearchTerm(word: word)).toList(); + return SearchQuery(terms: terms); + } +} \ No newline at end of file diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart new file mode 100644 index 000000000..81de8be4e --- /dev/null +++ b/lib/search/view/enhanced_search_field.dart @@ -0,0 +1,425 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:otzaria/search/bloc/search_bloc.dart'; +import 'package:otzaria/search/bloc/search_event.dart'; +import 'package:otzaria/search/models/search_terms_model.dart'; +import 'package:otzaria/search/view/tantivy_full_text_search.dart'; +import 'package:otzaria/search/view/search_options_dropdown.dart'; + +class EnhancedSearchField extends StatefulWidget { + final TantivyFullTextSearch widget; + + const EnhancedSearchField({ + super.key, + required this.widget, + }); + + @override + State createState() => _EnhancedSearchFieldState(); +} + +class _EnhancedSearchFieldState extends State { + SearchQuery _searchQuery = SearchQuery(); + final GlobalKey _textFieldKey = GlobalKey(); + final GlobalKey _stackKey = GlobalKey(); + final List _wordPositions = []; + final Map> _alternativeControllers = {}; + final Map> _showAlternativeFields = {}; + + static const double _kInnerPadding = 12; // padding סטנדרטי של TextField + static const double _kSuffixWidth = 100; // רוחב suffixIcon (תפריט + clear) + static const double _kPlusYOffset = 15; // כמה פיקסלים מתחת לשדה יופיע ה + + static const double _kPlusRadius = 10; // רדיוס העיגול (למרכז-top) + + @override + void initState() { + super.initState(); + widget.widget.tab.queryController.addListener(_onTextChanged); + WidgetsBinding.instance.addPostFrameCallback((_) { + _calculateWordPositionsSimple(); + }); + } + + @override + void dispose() { + widget.widget.tab.queryController.removeListener(_onTextChanged); + _disposeControllers(); + super.dispose(); + } + + void _disposeControllers() { + for (final controllers in _alternativeControllers.values) { + for (final controller in controllers) { + controller.dispose(); + } + } + _alternativeControllers.clear(); + _showAlternativeFields.clear(); + } + + void _onTextChanged() { + final text = widget.widget.tab.queryController.text; + setState(() { + _searchQuery = SearchQuery.fromString(text); + _updateAlternativeControllers(); + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + _calculateWordPositionsSimple(); + }); + } + + void _updateAlternativeControllers() { + _disposeControllers(); + + for (int i = 0; i < _searchQuery.terms.length; i++) { + _alternativeControllers[i] = []; + _showAlternativeFields[i] = []; + + final term = _searchQuery.terms[i]; + for (int j = 0; j < term.alternatives.length; j++) { + _alternativeControllers[i]! + .add(TextEditingController(text: term.alternatives[j])); + _showAlternativeFields[i]!.add(true); + } + } + } + + void _calculateWordPositionsSimple() { + if (_textFieldKey.currentContext == null) return; + + final renderBox = _textFieldKey.currentContext!.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + _wordPositions.clear(); + + final text = widget.widget.tab.queryController.text; + if (text.isEmpty) { + setState(() {}); + return; + } + + final words = text.trim().split(RegExp(r'\s+')); + const textStyle = TextStyle(fontSize: 16); + + // גישה חדשה: נשתמש ב-getPositionForOffset כדי למצוא את המיקום המדויק של כל מילה + final textPainter = TextPainter( + text: TextSpan(text: text, style: textStyle), + textDirection: TextDirection.rtl, + )..layout(); + + final fieldWidth = renderBox.size.width; + final textStartX = _kInnerPadding + 48; // אחרי prefixIcon + final availableWidth = fieldWidth - textStartX - _kSuffixWidth - _kInnerPadding; + + // מיקום הטקסט בשדה (RTL) + final textX = textStartX + (availableWidth - textPainter.size.width); + + // עכשיו נמצא את המיקום של כל מילה + int currentIndex = 0; + + for (int i = 0; i < words.length; i++) { + final word = words[i]; + + // מצא את תחילת המילה בטקסט + final wordStart = text.indexOf(word, currentIndex); + final wordEnd = wordStart + word.length; + + // השתמש ב-getOffsetForCaret כדי למצוא את המיקום הפיזי + final startOffset = textPainter.getOffsetForCaret( + TextPosition(offset: wordStart), + Rect.zero, + ); + final endOffset = textPainter.getOffsetForCaret( + TextPosition(offset: wordEnd), + Rect.zero, + ); + + // מרכז המילה + final wordCenterX = textX + (startOffset.dx + endOffset.dx) / 2; + + _wordPositions.add(Offset( + wordCenterX, + renderBox.size.height + _kPlusYOffset, + )); + + currentIndex = wordEnd + 1; // +1 לרווח + } + + setState(() {}); + } + + void _calculateWordPositions() { + if (_textFieldKey.currentContext == null) return; + + final renderBox = + _textFieldKey.currentContext!.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + _wordPositions.clear(); + + final text = widget.widget.tab.queryController.text; + if (text.isEmpty) { + setState(() {}); // נקה את הכפתורים אם אין טקסט + return; + } + + final words = text.trim().split(RegExp(r'\s+')); + final stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?; + if (stackBox == null) return; + final stackOffset = stackBox.localToGlobal(Offset.zero); + + // נשתמש באותו סגנון טקסט כמו ב-TextField + const textStyle = TextStyle(fontSize: 16); // גודל ברירת מחדל של TextField + + final tpWord = TextPainter(textDirection: TextDirection.rtl); + final tpSpace = TextPainter( + text: const TextSpan(text: ' ', style: textStyle), + textDirection: TextDirection.rtl, + )..layout(); + final spaceWidth = tpSpace.size.width; + + // התחל מהצד הימני, אחרי הכפתורים (suffixIcon) + double cursorX = renderBox.size.width - _kSuffixWidth - _kInnerPadding; + + for (int i = 0; i < words.length; i++) { + final word = words[i]; + tpWord + ..text = TextSpan(text: word, style: textStyle) + ..layout(); + + // מרכז המילה = תחילת המילה + חצי רוחב + // מוסיפים חצי רווח קודם (space/2) כדי לקבל “אמצע אופטי” מדויק ב‑RTL + final centerX = cursorX - tpWord.size.width / 2 + spaceWidth / 2; + + final globalCenter = Offset( + renderBox.localToGlobal(Offset(centerX, 0)).dx, + renderBox.localToGlobal(Offset.zero).dy + + renderBox.size.height + + _kPlusYOffset, + ); + + _wordPositions.add(globalCenter - stackOffset); + + // זזים אחורה: מילה + רווח, חוץ מהשמאלית‑ביותר (האחרונה בלולאה) + cursorX -= tpWord.size.width; + if (i < words.length - 1) { + cursorX -= spaceWidth; + } + } + + setState(() {}); + } + + void _addAlternative(int termIndex) { + if (termIndex < _searchQuery.terms.length) { + setState(() { + if (_alternativeControllers[termIndex] == null) { + _alternativeControllers[termIndex] = []; + _showAlternativeFields[termIndex] = []; + } + + _alternativeControllers[termIndex]!.add(TextEditingController()); + _showAlternativeFields[termIndex]!.add(true); + }); + } + } + + void _removeAlternative(int termIndex, int altIndex) { + setState(() { + if (_alternativeControllers[termIndex] != null && + altIndex < _alternativeControllers[termIndex]!.length) { + _alternativeControllers[termIndex]![altIndex].dispose(); + _alternativeControllers[termIndex]!.removeAt(altIndex); + _showAlternativeFields[termIndex]!.removeAt(altIndex); + + // עדכן את המודל + final term = _searchQuery.terms[termIndex]; + final updatedTerm = term.removeAlternative(altIndex); + _searchQuery = _searchQuery.updateTerm(termIndex, updatedTerm); + } + }); + } + + Widget _buildPlusButton(int termIndex, Offset position) { + return Positioned( + left: position.dx - _kPlusRadius, + top: position.dy - _kPlusRadius, + child: GestureDetector( + onTap: () => _addAlternative(termIndex), + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withValues(alpha: 0.7), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: const Icon( + Icons.add, + size: 12, + color: Colors.white, + ), + ), + ), + ); + } + + Widget _buildAlternativeField(int termIndex, int altIndex, double topOffset) { + final controller = _alternativeControllers[termIndex]?[altIndex]; + if (controller == null) return const SizedBox.shrink(); + + // חישוב מיקום נכון עבור RTL + final wordPosition = _wordPositions.length > termIndex + ? _wordPositions[termIndex] + : Offset.zero; + + return Positioned( + right: MediaQuery.of(context).size.width - + wordPosition.dx - + 75, // RTL positioning + top: topOffset, + child: Container( + width: 150, + height: 35, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + controller: controller, + decoration: InputDecoration( + hintText: 'מילה חילופית', + border: InputBorder.none, + contentPadding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + suffixIcon: IconButton( + icon: const Icon(Icons.close, size: 16), + onPressed: () => _removeAlternative(termIndex, altIndex), + ), + ), + style: const TextStyle(fontSize: 12), + textAlign: TextAlign.right, // RTL text alignment + onSubmitted: (value) { + if (value.isNotEmpty) { + setState(() { + final term = _searchQuery.terms[termIndex]; + final updatedTerm = term.addAlternative(value); + _searchQuery = _searchQuery.updateTerm(termIndex, updatedTerm); + }); + } + }, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Stack( + key: _stackKey, + clipBehavior: Clip.none, + children: [ + // השדה הראשי - בגובה רגיל + Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + key: _textFieldKey, + focusNode: widget.widget.tab.searchFieldFocusNode, + controller: widget.widget.tab.queryController, + onSubmitted: (e) { + context.read().add(UpdateSearchQuery(e)); + widget.widget.tab.isLeftPaneOpen.value = false; + }, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: "חפש כאן..", + labelText: "לחיפוש הקש אנטר או לחץ על סמל החיפוש", + prefixIcon: IconButton( + onPressed: () { + context.read().add(UpdateSearchQuery( + widget.widget.tab.queryController.text)); + }, + icon: const Icon(Icons.search), + ), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SearchOptionsDropdown(), + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + widget.widget.tab.queryController.clear(); + context.read().add(UpdateSearchQuery('')); + setState(() { + _searchQuery = SearchQuery(); + _updateAlternativeControllers(); + _wordPositions.clear(); + }); + }, + ), + ], + ), + ), + ), + ), + + // כפתורי הפלוס - צפים מעל השדה + ..._wordPositions.asMap().entries.map((entry) { + final index = entry.key; + final position = entry.value; + if (index < _searchQuery.terms.length) { + return _buildPlusButton(index, position); + } + return const SizedBox.shrink(); + }).toList(), + + // שדות חילופיים - צפים מתחת לשדה + ..._alternativeControllers.entries.expand((entry) { + final termIndex = entry.key; + final controllers = entry.value; + + // חשב כמה שדות פעילים יש כבר למילה הזו + int activeFieldsCount = 0; + for (int i = 0; i < controllers.length; i++) { + if (controllers[i].text.isNotEmpty || + (_showAlternativeFields[termIndex]?[i] == true)) { + activeFieldsCount++; + } + } + + return controllers.asMap().entries.where((controllerEntry) { + final altIndex = controllerEntry.key; + final controller = controllerEntry.value; + + // הצג שדה אם: + // 1. יש בו תוכן + // 2. הוא השדה הפעיל הבא (רק אחד ריק בכל פעם) + if (controller.text.isNotEmpty) return true; + if (_showAlternativeFields[termIndex]?[altIndex] == true && + altIndex == activeFieldsCount - 1) return true; + + return false; + }).map((controllerEntry) { + final altIndex = controllerEntry.key; + final topOffset = 70.0 + (altIndex * 40.0); // מיקום אנכי + return _buildAlternativeField(termIndex, altIndex, topOffset); + }); + }).toList(), + ], + ); + } +} diff --git a/lib/search/view/full_text_settings_widgets.dart b/lib/search/view/full_text_settings_widgets.dart index 5a835e759..b8d6c3928 100644 --- a/lib/search/view/full_text_settings_widgets.dart +++ b/lib/search/view/full_text_settings_widgets.dart @@ -119,16 +119,26 @@ class SearchTermsDisplay extends StatelessWidget { final SearchingTab tab; + String _getDisplayText(String originalQuery) { + // כרגע נציג את הטקסט המקורי + // בעתיד נוסיף כאן לוגיקה להצגת החלופות + // למשל: "מאימתי או מתי ו קורין או קוראין" + return originalQuery; + } + @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - // חישוב רוחב מותאם לטקסט - final textLength = state.searchQuery.length; + final displayText = _getDisplayText(state.searchQuery); + + // חישוב רוחב מותאם לטקסט המלא (כולל חלופות) + final textLength = displayText.length; const minWidth = 120.0; // רוחב מינימלי - const maxWidth = 300.0; // רוחב מקסימלי - final calculatedWidth = (textLength * 8.0 + 60).clamp(minWidth, maxWidth); - + const maxWidth = 400.0; // רוחב מקסימלי מוגדל לחלופות + final calculatedWidth = + (textLength * 8.0 + 60).clamp(minWidth, maxWidth); + return Container( height: 52, // גובה קבוע כמו שאר הווידג'טים padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), @@ -140,7 +150,7 @@ class SearchTermsDisplay extends StatelessWidget { ), child: TextField( readOnly: true, - controller: TextEditingController(text: state.searchQuery), + controller: TextEditingController(text: displayText), textAlign: TextAlign.center, // ממרכז את הטקסט decoration: const InputDecoration( labelText: 'מילות החיפוש', diff --git a/lib/search/view/tantivy_search_field.dart b/lib/search/view/tantivy_search_field.dart index 36c66adce..827d603bc 100644 --- a/lib/search/view/tantivy_search_field.dart +++ b/lib/search/view/tantivy_search_field.dart @@ -1,9 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:otzaria/search/bloc/search_bloc.dart'; -import 'package:otzaria/search/bloc/search_event.dart'; import 'package:otzaria/search/view/tantivy_full_text_search.dart'; -import 'package:otzaria/search/view/search_options_dropdown.dart'; +import 'package:otzaria/search/view/enhanced_search_field.dart'; class TantivySearchField extends StatelessWidget { const TantivySearchField({ @@ -15,42 +12,7 @@ class TantivySearchField extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: TextField( - focusNode: widget.tab.searchFieldFocusNode, - controller: widget.tab.queryController, - onSubmitted: (e) { - context.read().add(UpdateSearchQuery(e)); - widget.tab.isLeftPaneOpen.value = false; - }, - decoration: InputDecoration( - border: const OutlineInputBorder(), - hintText: "חפש כאן..", - labelText: "לחיפוש הקש אנטר או לחץ על סמל החיפוש", - prefixIcon: IconButton( - onPressed: () { - context - .read() - .add(UpdateSearchQuery(widget.tab.queryController.text)); - }, - icon: const Icon(Icons.search), - ), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SearchOptionsDropdown(), - IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - widget.tab.queryController.clear(); - context.read().add(UpdateSearchQuery('')); - }, - ), - ], - ), - ), - ), - ); + // נבדוק את השדה החדש + return EnhancedSearchField(widget: widget); } } From 64f2dcf23241c1cfc1dbcbaf50293dfc696e7371 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 25 Jul 2025 19:28:07 +0300 Subject: [PATCH 030/197] =?UTF-8?q?=D7=A1=D7=99=D7=9E=D7=95=D7=A0=D7=99=20?= =?UTF-8?q?+=20=D7=91=D7=90=D7=9E=D7=A6=D7=A2=20=D7=94=D7=9E=D7=99=D7=9C?= =?UTF-8?q?=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/enhanced_search_field.dart | 82 +++++++++++----------- 1 file changed, 40 insertions(+), 42 deletions(-) diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 81de8be4e..561742ddd 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:otzaria/search/bloc/search_bloc.dart'; import 'package:otzaria/search/bloc/search_event.dart'; @@ -87,8 +88,25 @@ class _EnhancedSearchFieldState extends State { void _calculateWordPositionsSimple() { if (_textFieldKey.currentContext == null) return; - final renderBox = _textFieldKey.currentContext!.findRenderObject() as RenderBox?; - if (renderBox == null) return; + // 1. מאתרים את RenderEditable שבתוך ה‑TextField + RenderEditable? editable; + void _findEditable(RenderObject child) { + if (child is RenderEditable) { + editable = child; + } else { + child.visitChildren(_findEditable); + } + } + + _textFieldKey.currentContext! + .findRenderObject()! + .visitChildren(_findEditable); + if (editable == null) return; + + // 2. קואורדינטות בסיס + final stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?; + if (stackBox == null) return; + final stackGlobal = stackBox.localToGlobal(Offset.zero); _wordPositions.clear(); @@ -98,51 +116,31 @@ class _EnhancedSearchFieldState extends State { return; } + // 3. עבור כל מילה – מוצאים את Rect שלה בעזרת getEndpointsForSelection final words = text.trim().split(RegExp(r'\s+')); - const textStyle = TextStyle(fontSize: 16); - - // גישה חדשה: נשתמש ב-getPositionForOffset כדי למצוא את המיקום המדויק של כל מילה - final textPainter = TextPainter( - text: TextSpan(text: text, style: textStyle), - textDirection: TextDirection.rtl, - )..layout(); - - final fieldWidth = renderBox.size.width; - final textStartX = _kInnerPadding + 48; // אחרי prefixIcon - final availableWidth = fieldWidth - textStartX - _kSuffixWidth - _kInnerPadding; - - // מיקום הטקסט בשדה (RTL) - final textX = textStartX + (availableWidth - textPainter.size.width); - - // עכשיו נמצא את המיקום של כל מילה int currentIndex = 0; - - for (int i = 0; i < words.length; i++) { - final word = words[i]; - - // מצא את תחילת המילה בטקסט + for (final word in words) { final wordStart = text.indexOf(word, currentIndex); + if (wordStart == -1) continue; // הגנה final wordEnd = wordStart + word.length; - - // השתמש ב-getOffsetForCaret כדי למצוא את המיקום הפיזי - final startOffset = textPainter.getOffsetForCaret( - TextPosition(offset: wordStart), - Rect.zero, - ); - final endOffset = textPainter.getOffsetForCaret( - TextPosition(offset: wordEnd), - Rect.zero, + + final endpoints = editable!.getEndpointsForSelection( + TextSelection(baseOffset: wordStart, extentOffset: wordEnd)); + if (endpoints.length < 2) continue; + + final left = endpoints[0].point.dx; + final right = endpoints[1].point.dx; + final centerLocal = Offset( + (left + right) / 2, + editable!.size.height + _kPlusYOffset, ); - - // מרכז המילה - final wordCenterX = textX + (startOffset.dx + endOffset.dx) / 2; - - _wordPositions.add(Offset( - wordCenterX, - renderBox.size.height + _kPlusYOffset, - )); - - currentIndex = wordEnd + 1; // +1 לרווח + + // 4. המרה לקואורדינטות של ה‑Stack + final centerGlobal = editable!.localToGlobal(centerLocal); + final centerInStack = centerGlobal - stackGlobal; + + _wordPositions.add(centerInStack); + currentIndex = wordEnd + 1; } setState(() {}); From d854802eaeb97896962ddc0a5d3eed3ad925cfc3 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 27 Jul 2025 03:17:07 +0300 Subject: [PATCH 031/197] =?UTF-8?q?=D7=AA=D7=95=D7=A1=D7=A4=D7=95=D7=AA=20?= =?UTF-8?q?=D7=95=D7=A2=D7=93=D7=9B=D7=95=D7=A0=D7=99=D7=9D=20=D7=9C=D7=9C?= =?UTF-8?q?=D7=97=D7=A6=D7=9F=20=D7=94OR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/enhanced_search_field.dart | 327 ++++++++++++++------- 1 file changed, 221 insertions(+), 106 deletions(-) diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 561742ddd..ea6e42095 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -7,6 +7,183 @@ import 'package:otzaria/search/models/search_terms_model.dart'; import 'package:otzaria/search/view/tantivy_full_text_search.dart'; import 'package:otzaria/search/view/search_options_dropdown.dart'; +// הווידג'ט החדש לניהול מצבי הכפתור +class _PlusButton extends StatefulWidget { + final bool active; + final VoidCallback onTap; + + const _PlusButton({ + required this.active, + required this.onTap, + }); + + @override + State<_PlusButton> createState() => _PlusButtonState(); +} + +class _PlusButtonState extends State<_PlusButton> { + bool _isHovering = false; + + @override + Widget build(BuildContext context) { + final bool isHighlighted = widget.active || _isHovering; + final primaryColor = Theme.of(context).primaryColor; + + // MouseRegion מזהה ריחוף עכבר + return MouseRegion( + onEnter: (_) => setState(() => _isHovering = true), + onExit: (_) => setState(() => _isHovering = false), + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + // אנימציה למעבר חלק + duration: const Duration(milliseconds: 200), + width: 20, + height: 20, + decoration: BoxDecoration( + color: isHighlighted + ? primaryColor // מצב מודגש: צבע מלא + : primaryColor.withOpacity(0.5), // מצב רגיל: חצי שקוף + shape: BoxShape.circle, + boxShadow: [ + if (isHighlighted) // הוספת צל רק במצב מודגש + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: const Icon( + Icons.add, + size: 12, + color: Colors.white, + ), + ), + ), + ); + } +} + +class _AlternativeField extends StatefulWidget { + final TextEditingController controller; + final VoidCallback onRemove; + + const _AlternativeField({ + super.key, + required this.controller, + required this.onRemove, + }); + + @override + State<_AlternativeField> createState() => _AlternativeFieldState(); +} + +class _AlternativeFieldState extends State<_AlternativeField> { + final FocusNode _focus = FocusNode(); + + @override + void initState() { + super.initState(); + // הבקשה הראשונית לפוקוס כשהשדה נוצר + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _focus.requestFocus(); + } + }); + _focus.addListener(_handleFocus); + } + + void _handleFocus() { + // איבוד / קבלת פוקוס משפיע רק על “עמעום” + setState(() {}); + } + + @override + void dispose() { + _focus.removeListener(_handleFocus); + _focus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bool dim = + !_focus.hasFocus && widget.controller.text.trim().isNotEmpty; + + return Material( + elevation: _focus.hasFocus ? 8 : 2, + borderRadius: BorderRadius.circular(8), + clipBehavior: Clip.hardEdge, // ☑ לא מאפשר לקו לרוץ “מאחורי” הרקע + color: Colors.white, // ☑ רקע לבן אטום + child: SizedBox( + width: 160, // טיפה רחב – הסתרת קו במלואו + height: 40, + child: TextField( + controller: widget.controller, + focusNode: _focus, + decoration: InputDecoration( + filled: true, // ☑ שכבת מילוי פנימית + fillColor: Colors.white, + hintText: 'מילה חילופית', + hintStyle: TextStyle( + fontSize: 12, + color: dim ? Colors.black45 : Colors.black54, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: + Theme.of(context).dividerColor.withOpacity(dim ? 0.4 : 1.0), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: + Theme.of(context).dividerColor.withOpacity(dim ? 0.4 : 1.0), + ), + ), + suffixIcon: Material( + // ☑ ריפל ברור סביב ה‑X + type: MaterialType.transparency, + shape: const CircleBorder(), + child: InkResponse( + splashFactory: InkRipple.splashFactory, + onTap: widget.onRemove, + customBorder: const CircleBorder(), + splashColor: Theme.of(context).primaryColor.withOpacity(0.25), + highlightColor: + Theme.of(context).primaryColor.withOpacity(0.12), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Icon( + Icons.close, + size: 16, + color: dim ? Colors.black45 : Colors.black87, + ), + ), + ), + ), + ), + style: TextStyle( + fontSize: 12, + color: dim ? Colors.black54 : Colors.black87, + ), + textAlign: TextAlign.right, + onSubmitted: (_) { + if (widget.controller.text.trim().isEmpty) { + widget.onRemove(); + } + }, + ), + ), + ); + } +} + class EnhancedSearchField extends StatefulWidget { final TantivyFullTextSearch widget; @@ -29,7 +206,7 @@ class _EnhancedSearchFieldState extends State { static const double _kInnerPadding = 12; // padding סטנדרטי של TextField static const double _kSuffixWidth = 100; // רוחב suffixIcon (תפריט + clear) - static const double _kPlusYOffset = 15; // כמה פיקסלים מתחת לשדה יופיע ה + + static const double _kPlusYOffset = 10; // כמה פיקסלים מתחת לשדה יופיע ה + static const double _kPlusRadius = 10; // רדיוס העיגול (למרכז-top) @override @@ -209,118 +386,78 @@ class _EnhancedSearchFieldState extends State { } void _addAlternative(int termIndex) { - if (termIndex < _searchQuery.terms.length) { - setState(() { - if (_alternativeControllers[termIndex] == null) { - _alternativeControllers[termIndex] = []; - _showAlternativeFields[termIndex] = []; - } + if (termIndex >= _searchQuery.terms.length) return; - _alternativeControllers[termIndex]!.add(TextEditingController()); - _showAlternativeFields[termIndex]!.add(true); + setState(() { + // 1️⃣ קודם כול – נקה תיבות ריקות בכל המילים + _alternativeControllers.forEach((ti, list) { + for (int i = list.length - 1; i >= 0; i--) { + if (list[i].text.trim().isEmpty) { + list[i].dispose(); + list.removeAt(i); + _showAlternativeFields[ti]?.removeAt(i); + } + } }); - } + + // 2️⃣ הוסף תיבה חדשה למילה הנוכחית + _alternativeControllers.putIfAbsent(termIndex, () => []); + _showAlternativeFields.putIfAbsent(termIndex, () => []); + + _alternativeControllers[termIndex]!.add(TextEditingController()); + _showAlternativeFields[termIndex]!.add(true); + }); } void _removeAlternative(int termIndex, int altIndex) { setState(() { if (_alternativeControllers[termIndex] != null && altIndex < _alternativeControllers[termIndex]!.length) { - _alternativeControllers[termIndex]![altIndex].dispose(); - _alternativeControllers[termIndex]!.removeAt(altIndex); - _showAlternativeFields[termIndex]!.removeAt(altIndex); - - // עדכן את המודל + // עדכון המודל לפני הסרת הקונטרולר final term = _searchQuery.terms[termIndex]; final updatedTerm = term.removeAlternative(altIndex); _searchQuery = _searchQuery.updateTerm(termIndex, updatedTerm); + + // הסרת הקונטרולר והשדה + _alternativeControllers[termIndex]![altIndex].dispose(); + _alternativeControllers[termIndex]!.removeAt(altIndex); + _showAlternativeFields[termIndex]!.removeAt(altIndex); } }); } Widget _buildPlusButton(int termIndex, Offset position) { + // הכפתור "פעיל" אם יש לו לפחות שדה חלופי אחד פתוח. + final bool isActive = + _alternativeControllers[termIndex]?.isNotEmpty ?? false; + return Positioned( left: position.dx - _kPlusRadius, top: position.dy - _kPlusRadius, - child: GestureDetector( + // שימוש בווידג'ט החדש + child: _PlusButton( + active: isActive, onTap: () => _addAlternative(termIndex), - child: Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: Theme.of(context).primaryColor.withValues(alpha: 0.7), - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.2), - blurRadius: 2, - offset: const Offset(0, 1), - ), - ], - ), - child: const Icon( - Icons.add, - size: 12, - color: Colors.white, - ), - ), ), ); } - Widget _buildAlternativeField(int termIndex, int altIndex, double topOffset) { + Widget _buildAlternativeField(int termIndex, int altIndex) { final controller = _alternativeControllers[termIndex]?[altIndex]; if (controller == null) return const SizedBox.shrink(); - // חישוב מיקום נכון עבור RTL - final wordPosition = _wordPositions.length > termIndex + final wordPos = _wordPositions.length > termIndex ? _wordPositions[termIndex] : Offset.zero; + final topPosition = (wordPos.dy + 22) + (altIndex * 45.0); + return Positioned( - right: MediaQuery.of(context).size.width - - wordPosition.dx - - 75, // RTL positioning - top: topOffset, - child: Container( - width: 150, - height: 35, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade300), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: TextField( - controller: controller, - decoration: InputDecoration( - hintText: 'מילה חילופית', - border: InputBorder.none, - contentPadding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - suffixIcon: IconButton( - icon: const Icon(Icons.close, size: 16), - onPressed: () => _removeAlternative(termIndex, altIndex), - ), - ), - style: const TextStyle(fontSize: 12), - textAlign: TextAlign.right, // RTL text alignment - onSubmitted: (value) { - if (value.isNotEmpty) { - setState(() { - final term = _searchQuery.terms[termIndex]; - final updatedTerm = term.addAlternative(value); - _searchQuery = _searchQuery.updateTerm(termIndex, updatedTerm); - }); - } - }, - ), + left: wordPos.dx - 75, + top: topPosition, + child: _AlternativeField( + controller: controller, + onRemove: () => _removeAlternative(termIndex, altIndex), ), ); } @@ -390,31 +527,9 @@ class _EnhancedSearchFieldState extends State { final termIndex = entry.key; final controllers = entry.value; - // חשב כמה שדות פעילים יש כבר למילה הזו - int activeFieldsCount = 0; - for (int i = 0; i < controllers.length; i++) { - if (controllers[i].text.isNotEmpty || - (_showAlternativeFields[termIndex]?[i] == true)) { - activeFieldsCount++; - } - } - - return controllers.asMap().entries.where((controllerEntry) { - final altIndex = controllerEntry.key; - final controller = controllerEntry.value; - - // הצג שדה אם: - // 1. יש בו תוכן - // 2. הוא השדה הפעיל הבא (רק אחד ריק בכל פעם) - if (controller.text.isNotEmpty) return true; - if (_showAlternativeFields[termIndex]?[altIndex] == true && - altIndex == activeFieldsCount - 1) return true; - - return false; - }).map((controllerEntry) { + return controllers.asMap().entries.map((controllerEntry) { final altIndex = controllerEntry.key; - final topOffset = 70.0 + (altIndex * 40.0); // מיקום אנכי - return _buildAlternativeField(termIndex, altIndex, topOffset); + return _buildAlternativeField(termIndex, altIndex); }); }).toList(), ], From 5a13f6c966ffd243a19712928f23f13206aa9ca6 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 27 Jul 2025 09:36:59 +0300 Subject: [PATCH 032/197] =?UTF-8?q?=D7=A9=D7=99=D7=A0=D7=95=D7=99=20=D7=94?= =?UTF-8?q?=D7=AA=D7=A4=D7=A8=D7=99=D7=98=20=D7=9C=D7=9E=D7=92=D7=99=D7=A8?= =?UTF-8?q?=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/enhanced_search_field.dart | 520 ++++++++++--------- lib/search/view/search_options_dropdown.dart | 216 ++++---- 2 files changed, 386 insertions(+), 350 deletions(-) diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index ea6e42095..e2f0c4b87 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -42,6 +42,7 @@ class _PlusButtonState extends State<_PlusButton> { width: 20, height: 20, decoration: BoxDecoration( + // --- תוקן כאן --- color: isHighlighted ? primaryColor // מצב מודגש: צבע מלא : primaryColor.withOpacity(0.5), // מצב רגיל: חצי שקוף @@ -49,6 +50,7 @@ class _PlusButtonState extends State<_PlusButton> { boxShadow: [ if (isHighlighted) // הוספת צל רק במצב מודגש BoxShadow( + // --- ותוקן גם כאן --- color: Colors.black.withOpacity(0.3), blurRadius: 4, offset: const Offset(0, 2), @@ -69,12 +71,7 @@ class _PlusButtonState extends State<_PlusButton> { class _AlternativeField extends StatefulWidget { final TextEditingController controller; final VoidCallback onRemove; - - const _AlternativeField({ - super.key, - required this.controller, - required this.onRemove, - }); + const _AlternativeField({required this.controller, required this.onRemove}); @override State<_AlternativeField> createState() => _AlternativeFieldState(); @@ -86,98 +83,77 @@ class _AlternativeFieldState extends State<_AlternativeField> { @override void initState() { super.initState(); - // הבקשה הראשונית לפוקוס כשהשדה נוצר WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _focus.requestFocus(); } }); - _focus.addListener(_handleFocus); - } - - void _handleFocus() { - // איבוד / קבלת פוקוס משפיע רק על “עמעום” - setState(() {}); + // מאזין לשינויי פוקוס כדי לעדכן את המראה (צל) + _focus.addListener(() { + setState(() {}); + }); } @override void dispose() { - _focus.removeListener(_handleFocus); + _focus.removeListener(() { + setState(() {}); + }); _focus.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final bool dim = - !_focus.hasFocus && widget.controller.text.trim().isNotEmpty; - - return Material( - elevation: _focus.hasFocus ? 8 : 2, - borderRadius: BorderRadius.circular(8), - clipBehavior: Clip.hardEdge, // ☑ לא מאפשר לקו לרוץ “מאחורי” הרקע - color: Colors.white, // ☑ רקע לבן אטום - child: SizedBox( - width: 160, // טיפה רחב – הסתרת קו במלואו - height: 40, - child: TextField( - controller: widget.controller, - focusNode: _focus, - decoration: InputDecoration( - filled: true, // ☑ שכבת מילוי פנימית - fillColor: Colors.white, - hintText: 'מילה חילופית', - hintStyle: TextStyle( - fontSize: 12, - color: dim ? Colors.black45 : Colors.black54, - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 8), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: - Theme.of(context).dividerColor.withOpacity(dim ? 0.4 : 1.0), - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: - Theme.of(context).dividerColor.withOpacity(dim ? 0.4 : 1.0), - ), + return Container( + width: 160, + height: 40, + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _focus.hasFocus + ? Theme.of(context).primaryColor + : Theme.of(context).dividerColor, + width: _focus.hasFocus ? 1.5 : 1.0, + ), + boxShadow: [ + BoxShadow( + color: + Colors.black.withOpacity(_focus.hasFocus ? 0.15 : 0.08), + blurRadius: _focus.hasFocus ? 6 : 3, + offset: const Offset(0, 2), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Material( + type: MaterialType.transparency, + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.close, size: 16), + onPressed: widget.onRemove, + splashRadius: 18, ), - suffixIcon: Material( - // ☑ ריפל ברור סביב ה‑X - type: MaterialType.transparency, - shape: const CircleBorder(), - child: InkResponse( - splashFactory: InkRipple.splashFactory, - onTap: widget.onRemove, - customBorder: const CircleBorder(), - splashColor: Theme.of(context).primaryColor.withOpacity(0.25), - highlightColor: - Theme.of(context).primaryColor.withOpacity(0.12), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Icon( - Icons.close, - size: 16, - color: dim ? Colors.black45 : Colors.black87, - ), + Expanded( + child: TextField( + controller: widget.controller, + focusNode: _focus, + decoration: const InputDecoration( + hintText: 'מילה חילופית', + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.only(right: 8, bottom: 4), ), + style: const TextStyle(fontSize: 12, color: Colors.black87), + textAlign: TextAlign.right, + onSubmitted: (v) { + if (v.trim().isEmpty) widget.onRemove(); + }, ), ), - ), - style: TextStyle( - fontSize: 12, - color: dim ? Colors.black54 : Colors.black87, - ), - textAlign: TextAlign.right, - onSubmitted: (_) { - if (widget.controller.text.trim().isEmpty) { - widget.onRemove(); - } - }, + ], ), ), ); @@ -199,32 +175,46 @@ class EnhancedSearchField extends StatefulWidget { class _EnhancedSearchFieldState extends State { SearchQuery _searchQuery = SearchQuery(); final GlobalKey _textFieldKey = GlobalKey(); + // --- שלב 1: הוספת מפתח ל-Stack --- final GlobalKey _stackKey = GlobalKey(); final List _wordPositions = []; final Map> _alternativeControllers = {}; - final Map> _showAlternativeFields = {}; - static const double _kInnerPadding = 12; // padding סטנדרטי של TextField - static const double _kSuffixWidth = 100; // רוחב suffixIcon (תפריט + clear) - static const double _kPlusYOffset = 10; // כמה פיקסלים מתחת לשדה יופיע ה + - static const double _kPlusRadius = 10; // רדיוס העיגול (למרכז-top) + final Map> _alternativeOverlays = {}; + OverlayEntry? _searchOptionsOverlay; + + static const double _kPlusYOffset = 10; + static const double _kPlusRadius = 10; @override void initState() { super.initState(); widget.widget.tab.queryController.addListener(_onTextChanged); WidgetsBinding.instance.addPostFrameCallback((_) { - _calculateWordPositionsSimple(); + _calculateWordPositions(); }); } @override void dispose() { + _clearAllOverlays(); widget.widget.tab.queryController.removeListener(_onTextChanged); _disposeControllers(); super.dispose(); } + void _clearAllOverlays() { + for (final entries in _alternativeOverlays.values) { + for (final entry in entries) { + entry.remove(); + } + } + _alternativeOverlays.clear(); + + _searchOptionsOverlay?.remove(); + _searchOptionsOverlay = null; + } + void _disposeControllers() { for (final controllers in _alternativeControllers.values) { for (final controller in controllers) { @@ -232,209 +222,156 @@ class _EnhancedSearchFieldState extends State { } } _alternativeControllers.clear(); - _showAlternativeFields.clear(); } void _onTextChanged() { + _clearAllOverlays(); + final text = widget.widget.tab.queryController.text; setState(() { _searchQuery = SearchQuery.fromString(text); _updateAlternativeControllers(); }); WidgetsBinding.instance.addPostFrameCallback((_) { - _calculateWordPositionsSimple(); + _calculateWordPositions(); + for (int i = 0; i < _searchQuery.terms.length; i++) { + for (int j = 0; j < _searchQuery.terms[i].alternatives.length; j++) { + _showAlternativeOverlay(i, j); + } + } }); } void _updateAlternativeControllers() { _disposeControllers(); - for (int i = 0; i < _searchQuery.terms.length; i++) { - _alternativeControllers[i] = []; - _showAlternativeFields[i] = []; - final term = _searchQuery.terms[i]; - for (int j = 0; j < term.alternatives.length; j++) { - _alternativeControllers[i]! - .add(TextEditingController(text: term.alternatives[j])); - _showAlternativeFields[i]!.add(true); - } + _alternativeControllers[i] = term.alternatives + .map((alt) => TextEditingController(text: alt)) + .toList(); } } - void _calculateWordPositionsSimple() { + // --- שלב 3: החלפת הלוגיקה של חישוב המיקום --- + void _calculateWordPositions() { if (_textFieldKey.currentContext == null) return; - // 1. מאתרים את RenderEditable שבתוך ה‑TextField + // 1. מוצאים את RenderEditable RenderEditable? editable; - void _findEditable(RenderObject child) { + void findEditable(RenderObject child) { if (child is RenderEditable) { editable = child; } else { - child.visitChildren(_findEditable); + child.visitChildren(findEditable); } } - _textFieldKey.currentContext! .findRenderObject()! - .visitChildren(_findEditable); + .visitChildren(findEditable); if (editable == null) return; - // 2. קואורדינטות בסיס - final stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?; + // 2. בסיס ה‑Stack בגלובלי + final stackBox = + _stackKey.currentContext?.findRenderObject() as RenderBox?; if (stackBox == null) return; - final stackGlobal = stackBox.localToGlobal(Offset.zero); + final stackOrigin = stackBox.localToGlobal(Offset.zero); + // 3. מחשבים מרכז של כל מילה → גלובלי → יחסית ל‑Stack _wordPositions.clear(); - final text = widget.widget.tab.queryController.text; if (text.isEmpty) { setState(() {}); return; } - // 3. עבור כל מילה – מוצאים את Rect שלה בעזרת getEndpointsForSelection final words = text.trim().split(RegExp(r'\s+')); - int currentIndex = 0; - for (final word in words) { - final wordStart = text.indexOf(word, currentIndex); - if (wordStart == -1) continue; // הגנה - final wordEnd = wordStart + word.length; - - final endpoints = editable!.getEndpointsForSelection( - TextSelection(baseOffset: wordStart, extentOffset: wordEnd)); - if (endpoints.length < 2) continue; - - final left = endpoints[0].point.dx; - final right = endpoints[1].point.dx; - final centerLocal = Offset( - (left + right) / 2, - editable!.size.height + _kPlusYOffset, + int idx = 0; + for (final w in words) { + final start = text.indexOf(w, idx); + if (start == -1) continue; + final end = start + w.length; + + final pts = editable!.getEndpointsForSelection( + TextSelection(baseOffset: start, extentOffset: end), ); + if (pts.length < 2) continue; - // 4. המרה לקואורדינטות של ה‑Stack - final centerGlobal = editable!.localToGlobal(centerLocal); - final centerInStack = centerGlobal - stackGlobal; - - _wordPositions.add(centerInStack); - currentIndex = wordEnd + 1; - } - - setState(() {}); - } - - void _calculateWordPositions() { - if (_textFieldKey.currentContext == null) return; - - final renderBox = - _textFieldKey.currentContext!.findRenderObject() as RenderBox?; - if (renderBox == null) return; - - _wordPositions.clear(); - - final text = widget.widget.tab.queryController.text; - if (text.isEmpty) { - setState(() {}); // נקה את הכפתורים אם אין טקסט - return; - } - - final words = text.trim().split(RegExp(r'\s+')); - final stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?; - if (stackBox == null) return; - final stackOffset = stackBox.localToGlobal(Offset.zero); - - // נשתמש באותו סגנון טקסט כמו ב-TextField - const textStyle = TextStyle(fontSize: 16); // גודל ברירת מחדל של TextField - - final tpWord = TextPainter(textDirection: TextDirection.rtl); - final tpSpace = TextPainter( - text: const TextSpan(text: ' ', style: textStyle), - textDirection: TextDirection.rtl, - )..layout(); - final spaceWidth = tpSpace.size.width; - - // התחל מהצד הימני, אחרי הכפתורים (suffixIcon) - double cursorX = renderBox.size.width - _kSuffixWidth - _kInnerPadding; - - for (int i = 0; i < words.length; i++) { - final word = words[i]; - tpWord - ..text = TextSpan(text: word, style: textStyle) - ..layout(); - - // מרכז המילה = תחילת המילה + חצי רוחב - // מוסיפים חצי רווח קודם (space/2) כדי לקבל “אמצע אופטי” מדויק ב‑RTL - final centerX = cursorX - tpWord.size.width / 2 + spaceWidth / 2; - - final globalCenter = Offset( - renderBox.localToGlobal(Offset(centerX, 0)).dx, - renderBox.localToGlobal(Offset.zero).dy + - renderBox.size.height + - _kPlusYOffset, + final centerLocalX = (pts[0].point.dx + pts[1].point.dx) / 2; + final local = Offset( + centerLocalX, + editable!.size.height + _kPlusYOffset, ); - _wordPositions.add(globalCenter - stackOffset); + final global = editable!.localToGlobal(local); + final inStack = global - stackOrigin; - // זזים אחורה: מילה + רווח, חוץ מהשמאלית‑ביותר (האחרונה בלולאה) - cursorX -= tpWord.size.width; - if (i < words.length - 1) { - cursorX -= spaceWidth; - } + _wordPositions.add(inStack); + idx = end + 1; } setState(() {}); } void _addAlternative(int termIndex) { - if (termIndex >= _searchQuery.terms.length) return; - setState(() { - // 1️⃣ קודם כול – נקה תיבות ריקות בכל המילים - _alternativeControllers.forEach((ti, list) { - for (int i = list.length - 1; i >= 0; i--) { - if (list[i].text.trim().isEmpty) { - list[i].dispose(); - list.removeAt(i); - _showAlternativeFields[ti]?.removeAt(i); - } - } - }); - - // 2️⃣ הוסף תיבה חדשה למילה הנוכחית _alternativeControllers.putIfAbsent(termIndex, () => []); - _showAlternativeFields.putIfAbsent(termIndex, () => []); - + final newIndex = _alternativeControllers[termIndex]!.length; _alternativeControllers[termIndex]!.add(TextEditingController()); - _showAlternativeFields[termIndex]!.add(true); + _showAlternativeOverlay(termIndex, newIndex); }); } void _removeAlternative(int termIndex, int altIndex) { setState(() { - if (_alternativeControllers[termIndex] != null && + if (_alternativeOverlays.containsKey(termIndex) && + altIndex < _alternativeOverlays[termIndex]!.length) { + _alternativeOverlays[termIndex]![altIndex].remove(); + _alternativeOverlays[termIndex]!.removeAt(altIndex); + } + if (_alternativeControllers.containsKey(termIndex) && altIndex < _alternativeControllers[termIndex]!.length) { - // עדכון המודל לפני הסרת הקונטרולר - final term = _searchQuery.terms[termIndex]; - final updatedTerm = term.removeAlternative(altIndex); - _searchQuery = _searchQuery.updateTerm(termIndex, updatedTerm); - - // הסרת הקונטרולר והשדה _alternativeControllers[termIndex]![altIndex].dispose(); _alternativeControllers[termIndex]!.removeAt(altIndex); - _showAlternativeFields[termIndex]!.removeAt(altIndex); } }); } + void _showAlternativeOverlay(int termIndex, int altIndex) { + final overlayState = Overlay.of(context); + + final RenderBox? textFieldBox = + _textFieldKey.currentContext?.findRenderObject() as RenderBox?; + if (textFieldBox == null) return; + + final textFieldGlobalPosition = textFieldBox.localToGlobal(Offset.zero); + final wordRelativePosition = _wordPositions[termIndex]; + final overlayPosition = textFieldGlobalPosition + wordRelativePosition; + + final controller = _alternativeControllers[termIndex]![altIndex]; + + final entry = OverlayEntry( + builder: (context) { + return Positioned( + left: overlayPosition.dx - 80, + top: overlayPosition.dy + 15 + (altIndex * 45.0), + child: _AlternativeField( + controller: controller, + onRemove: () => _removeAlternative(termIndex, altIndex), + ), + ); + }, + ); + + _alternativeOverlays.putIfAbsent(termIndex, () => []).add(entry); + overlayState.insert(entry); + } + Widget _buildPlusButton(int termIndex, Offset position) { - // הכפתור "פעיל" אם יש לו לפחות שדה חלופי אחד פתוח. final bool isActive = _alternativeControllers[termIndex]?.isNotEmpty ?? false; - return Positioned( left: position.dx - _kPlusRadius, top: position.dy - _kPlusRadius, - // שימוש בווידג'ט החדש child: _PlusButton( active: isActive, onTap: () => _addAlternative(termIndex), @@ -442,33 +379,127 @@ class _EnhancedSearchFieldState extends State { ); } - Widget _buildAlternativeField(int termIndex, int altIndex) { - final controller = _alternativeControllers[termIndex]?[altIndex]; - if (controller == null) return const SizedBox.shrink(); + void _showSearchOptionsOverlay() { + if (_searchOptionsOverlay != null) return; + + final overlayState = Overlay.of(context); + final RenderBox? textFieldBox = + _textFieldKey.currentContext?.findRenderObject() as RenderBox?; + if (textFieldBox == null) return; + + final textFieldGlobalPosition = textFieldBox.localToGlobal(Offset.zero); + + _searchOptionsOverlay = OverlayEntry( + builder: (context) { + return Positioned( + left: textFieldGlobalPosition.dx, + top: textFieldGlobalPosition.dy + textFieldBox.size.height, + width: textFieldBox.size.width, + child: Container( + height: 48.0, + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.25), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + border: Border( + left: BorderSide(color: Colors.grey.shade400, width: 1), + right: BorderSide(color: Colors.grey.shade400, width: 1), + bottom: BorderSide(color: Colors.grey.shade400, width: 1), + ), + ), + child: Material( + color: Theme.of(context).scaffoldBackgroundColor, + child: Padding( + padding: const EdgeInsets.only( + left: 48.0, right: 16.0, top: 8.0, bottom: 8.0), + child: _buildSearchOptionsContent(), + ), + ), + ), + ); + }, + ); - final wordPos = _wordPositions.length > termIndex - ? _wordPositions[termIndex] - : Offset.zero; + overlayState.insert(_searchOptionsOverlay!); + } - final topPosition = (wordPos.dy + 22) + (altIndex * 45.0); + Widget _buildSearchOptionsContent() { + const options = [ + 'קידומות', + 'סיומות', + 'קידומות דקדוקיות', + 'סיומות דקדוקיות', + 'כתיב מלא/חסר', + 'שורש', + ]; + + return Wrap( + spacing: 16.0, + runSpacing: 8.0, + children: options.map((option) => _buildCheckbox(option)).toList(), + ); + } - return Positioned( - left: wordPos.dx - 75, - top: topPosition, - child: _AlternativeField( - controller: controller, - onRemove: () => _removeAlternative(termIndex, altIndex), + Widget _buildCheckbox(String option) { + return InkWell( + onTap: () {}, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + border: Border.all( + color: Colors.grey.shade600, + width: 2, + ), + borderRadius: BorderRadius.circular(3), + color: Colors.transparent, + ), + ), + const SizedBox(width: 6), + Text( + option, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ), + ], + ), ), ); } + void _hideSearchOptionsOverlay() { + _searchOptionsOverlay?.remove(); + _searchOptionsOverlay = null; + } + + void _toggleSearchOptions(bool isExpanded) { + if (isExpanded) { + _showSearchOptionsOverlay(); + } else { + _hideSearchOptionsOverlay(); + } + } + @override Widget build(BuildContext context) { return Stack( + // --- שלב 2: נתינת המפתח ל-Stack --- key: _stackKey, clipBehavior: Clip.none, children: [ - // השדה הראשי - בגובה רגיל Padding( padding: const EdgeInsets.all(8.0), child: TextField( @@ -493,17 +524,14 @@ class _EnhancedSearchFieldState extends State { suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ - const SearchOptionsDropdown(), + SearchOptionsDropdown( + onToggle: _toggleSearchOptions, + ), IconButton( icon: const Icon(Icons.clear), onPressed: () { widget.widget.tab.queryController.clear(); context.read().add(UpdateSearchQuery('')); - setState(() { - _searchQuery = SearchQuery(); - _updateAlternativeControllers(); - _wordPositions.clear(); - }); }, ), ], @@ -511,28 +539,10 @@ class _EnhancedSearchFieldState extends State { ), ), ), - - // כפתורי הפלוס - צפים מעל השדה ..._wordPositions.asMap().entries.map((entry) { - final index = entry.key; - final position = entry.value; - if (index < _searchQuery.terms.length) { - return _buildPlusButton(index, position); - } - return const SizedBox.shrink(); - }).toList(), - - // שדות חילופיים - צפים מתחת לשדה - ..._alternativeControllers.entries.expand((entry) { - final termIndex = entry.key; - final controllers = entry.value; - - return controllers.asMap().entries.map((controllerEntry) { - final altIndex = controllerEntry.key; - return _buildAlternativeField(termIndex, altIndex); - }); + return _buildPlusButton(entry.key, entry.value); }).toList(), ], ); } -} +} \ No newline at end of file diff --git a/lib/search/view/search_options_dropdown.dart b/lib/search/view/search_options_dropdown.dart index 5e707a577..cc9021e1f 100644 --- a/lib/search/view/search_options_dropdown.dart +++ b/lib/search/view/search_options_dropdown.dart @@ -1,13 +1,44 @@ import 'package:flutter/material.dart'; class SearchOptionsDropdown extends StatefulWidget { - const SearchOptionsDropdown({super.key}); + final Function(bool)? onToggle; + + const SearchOptionsDropdown({super.key, this.onToggle}); @override State createState() => _SearchOptionsDropdownState(); } class _SearchOptionsDropdownState extends State { + bool _isExpanded = false; + + void _toggleExpanded() { + setState(() { + _isExpanded = !_isExpanded; + }); + widget.onToggle?.call(_isExpanded); + } + + @override + Widget build(BuildContext context) { + return IconButton( + icon: Icon(_isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down), + tooltip: 'אפשרויות חיפוש', + onPressed: _toggleExpanded, + ); + } +} + +class SearchOptionsRow extends StatefulWidget { + final bool isVisible; + + const SearchOptionsRow({super.key, required this.isVisible}); + + @override + State createState() => _SearchOptionsRowState(); +} + +class _SearchOptionsRowState extends State { final Map _options = { 'קידומות': false, 'סיומות': false, @@ -17,107 +48,102 @@ class _SearchOptionsDropdownState extends State { 'שורש': false, }; - @override - Widget build(BuildContext context) { - return PopupMenuButton( - icon: const Icon(Icons.keyboard_arrow_down), - tooltip: 'אפשרויות חיפוש', - offset: const Offset(0, 40), - constraints: const BoxConstraints(minWidth: 200, maxWidth: 250), - color: Theme.of(context).popupMenuTheme.color ?? - Theme.of(context).canvasColor, - itemBuilder: (BuildContext context) { - return [ - // כותרת התפריט - PopupMenuItem( - enabled: false, - height: 30, - child: Text( - 'אפשרויות חיפוש', + Widget _buildCheckbox(String option) { + return InkWell( + onTap: () { + setState(() { + _options[option] = !_options[option]!; + }); + }, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + border: Border.all( + color: _options[option]! + ? Theme.of(context).primaryColor + : Colors.grey.shade600, + width: 2, + ), + borderRadius: BorderRadius.circular(3), + color: _options[option]! + ? Theme.of(context).primaryColor.withValues(alpha: 0.1) + : Colors.transparent, + ), + child: _options[option]! + ? Icon( + Icons.check, + size: 14, + color: Theme.of(context).primaryColor, + ) + : null, + ), + const SizedBox(width: 6), + Text( + option, style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: Theme.of(context).textTheme.bodyLarge?.color, + fontSize: 13, + color: Theme.of(context).textTheme.bodyMedium?.color, ), ), - ), - const PopupMenuDivider(), - // האפשרויות - ..._options.keys.map((String option) { - return PopupMenuItem( - value: option, - enabled: false, // מונע סגירה של התפריט בלחיצה - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setMenuState) { - return InkWell( - onTap: () { - setMenuState(() { - _options[option] = !_options[option]!; - }); - setState(() {}); // עדכון המצב הכללי - }, + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + height: widget.isVisible ? 60.0 : 0.0, + width: double.infinity, + child: widget.isVisible + ? ColoredBox( + color: Colors.white, // רקע אטום מלא + child: Container( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.25), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + border: Border( + left: BorderSide(color: Colors.grey.shade400, width: 1), + right: BorderSide(color: Colors.grey.shade400, width: 1), + bottom: BorderSide(color: Colors.grey.shade400, width: 1), + ), + ), + child: ColoredBox( + color: Colors.white, // עוד שכבת רקע אטום + child: Material( + color: Colors.white, + child: ColoredBox( + color: Colors.white, // שכבה נוספת child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 12.0, horizontal: 8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - option, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Theme.of(context) - .textTheme - .bodyLarge - ?.color, - ), - ), - ), - const SizedBox(width: 16), - Container( - width: 20, - height: 20, - decoration: BoxDecoration( - border: Border.all( - color: _options[option]! - ? Theme.of(context).primaryColor - : Colors.grey.shade600, - width: 2, - ), - borderRadius: BorderRadius.circular(4), - color: _options[option]! - ? Theme.of(context) - .primaryColor - .withValues(alpha: 0.1) - : Colors.transparent, - ), - child: _options[option]! - ? Icon( - Icons.check, - size: 16, - color: Theme.of(context) - .textTheme - .bodyLarge - ?.color ?? - Theme.of(context).primaryColor, - ) - : null, - ), - ], + padding: const EdgeInsets.only(left: 48.0, right: 16.0, top: 12.0, bottom: 12.0), + child: Wrap( + spacing: 16.0, + runSpacing: 8.0, + children: _options.keys.map((option) => _buildCheckbox(option)).toList(), ), ), - ); - }, + ), + ), ), - ); - }).toList(), - ]; - }, - onSelected: (String value) { - // כרגע לא נעשה כלום - כפי שביקשת - }, + ), + ) + : const SizedBox.shrink(), ); } } From 51d06b78a6a124f2f8c9fcced6b865a861aeddc6 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 27 Jul 2025 21:49:45 +0300 Subject: [PATCH 033/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=9B?= =?UTF-8?q?=D7=A4=D7=AA=D7=95=D7=A8=D7=99=D7=9D=20=D7=9C=D7=9E=D7=A8=D7=95?= =?UTF-8?q?=D7=95=D7=97=20=D7=91=D7=99=D7=9F=20=D7=9E=D7=99=D7=9C=D7=99?= =?UTF-8?q?=D7=9D,=20=D7=95=D7=A9=D7=99=D7=A4=D7=95=D7=A8=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/enhanced_search_field.dart | 446 +++++++++++++++--- .../view/full_text_settings_widgets.dart | 181 +++++-- lib/search/view/tantivy_full_text_search.dart | 130 +++-- lib/search/view/tantivy_search_results.dart | 99 ++-- 4 files changed, 651 insertions(+), 205 deletions(-) diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index e2f0c4b87..912d982e1 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:otzaria/search/bloc/search_bloc.dart'; import 'package:otzaria/search/bloc/search_event.dart'; @@ -21,6 +22,18 @@ class _PlusButton extends StatefulWidget { State<_PlusButton> createState() => _PlusButtonState(); } +// כפתור המרווח שמופיע בריחוף - עגול כמו כפתור ה+ +class _SpacingButton extends StatefulWidget { + final VoidCallback onTap; + + const _SpacingButton({ + required this.onTap, + }); + + @override + State<_SpacingButton> createState() => _SpacingButtonState(); +} + class _PlusButtonState extends State<_PlusButton> { bool _isHovering = false; @@ -42,15 +55,11 @@ class _PlusButtonState extends State<_PlusButton> { width: 20, height: 20, decoration: BoxDecoration( - // --- תוקן כאן --- - color: isHighlighted - ? primaryColor // מצב מודגש: צבע מלא - : primaryColor.withOpacity(0.5), // מצב רגיל: חצי שקוף + color: isHighlighted ? primaryColor : primaryColor.withOpacity(0.5), shape: BoxShape.circle, boxShadow: [ - if (isHighlighted) // הוספת צל רק במצב מודגש + if (isHighlighted) BoxShadow( - // --- ותוקן גם כאן --- color: Colors.black.withOpacity(0.3), blurRadius: 4, offset: const Offset(0, 2), @@ -68,10 +77,164 @@ class _PlusButtonState extends State<_PlusButton> { } } +class _SpacingButtonState extends State<_SpacingButton> { + bool _isHovering = false; + + @override + Widget build(BuildContext context) { + final primaryColor = Theme.of(context).primaryColor; + + return MouseRegion( + onEnter: (_) => setState(() => _isHovering = true), + onExit: (_) => setState(() => _isHovering = false), + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 20, + height: 20, + decoration: BoxDecoration( + color: _isHovering ? primaryColor : primaryColor.withOpacity(0.7), + shape: BoxShape.circle, + boxShadow: [ + if (_isHovering) + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: const Icon( + Icons.more_horiz, + size: 12, + color: Colors.white, + ), + ), + ), + ); + } +} + +// תיבה צפה למרווח בין מילים +class _SpacingField extends StatefulWidget { + final TextEditingController controller; + final VoidCallback onRemove; + final VoidCallback? onFocusLost; + + const _SpacingField({ + required this.controller, + required this.onRemove, + this.onFocusLost, + }); + + @override + State<_SpacingField> createState() => _SpacingFieldState(); +} + +class _SpacingFieldState extends State<_SpacingField> { + final FocusNode _focus = FocusNode(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _focus.requestFocus(); + } + }); + _focus.addListener(_onFocusChanged); + } + + void _onFocusChanged() { + setState(() {}); + + if (!_focus.hasFocus && widget.controller.text.trim().isEmpty) { + widget.onFocusLost?.call(); + } + } + + @override + void dispose() { + _focus.removeListener(_onFocusChanged); + _focus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: 65, + height: 40, + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _focus.hasFocus + ? Theme.of(context).primaryColor + : Theme.of(context).dividerColor, + width: _focus.hasFocus ? 1.5 : 1.0, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(_focus.hasFocus ? 0.15 : 0.08), + blurRadius: _focus.hasFocus ? 6 : 3, + offset: const Offset(0, 2), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Material( + type: MaterialType.transparency, + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.close, size: 14), + onPressed: widget.onRemove, + splashRadius: 16, + padding: const EdgeInsets.only(left: 4, right: 2), + constraints: const BoxConstraints(), + ), + Expanded( + child: TextField( + controller: widget.controller, + focusNode: _focus, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(2), + ], + decoration: const InputDecoration( + hintText: 'מרווח', + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.only(right: 4, bottom: 4), + ), + style: const TextStyle(fontSize: 12, color: Colors.black87), + textAlign: TextAlign.right, + onSubmitted: (v) { + if (v.trim().isEmpty) widget.onRemove(); + }, + ), + ), + ], + ), + ), + ); + } +} + class _AlternativeField extends StatefulWidget { final TextEditingController controller; final VoidCallback onRemove; - const _AlternativeField({required this.controller, required this.onRemove}); + final VoidCallback? onFocusLost; + + const _AlternativeField({ + required this.controller, + required this.onRemove, + this.onFocusLost, + }); @override State<_AlternativeField> createState() => _AlternativeFieldState(); @@ -88,17 +251,20 @@ class _AlternativeFieldState extends State<_AlternativeField> { _focus.requestFocus(); } }); - // מאזין לשינויי פוקוס כדי לעדכן את המראה (צל) - _focus.addListener(() { - setState(() {}); - }); + _focus.addListener(_onFocusChanged); + } + + void _onFocusChanged() { + setState(() {}); + + if (!_focus.hasFocus && widget.controller.text.trim().isEmpty) { + widget.onFocusLost?.call(); + } } @override void dispose() { - _focus.removeListener(() { - setState(() {}); - }); + _focus.removeListener(_onFocusChanged); _focus.dispose(); super.dispose(); } @@ -119,8 +285,7 @@ class _AlternativeFieldState extends State<_AlternativeField> { ), boxShadow: [ BoxShadow( - color: - Colors.black.withOpacity(_focus.hasFocus ? 0.15 : 0.08), + color: Colors.black.withOpacity(_focus.hasFocus ? 0.15 : 0.08), blurRadius: _focus.hasFocus ? 6 : 3, offset: const Offset(0, 2), ), @@ -175,16 +340,26 @@ class EnhancedSearchField extends StatefulWidget { class _EnhancedSearchFieldState extends State { SearchQuery _searchQuery = SearchQuery(); final GlobalKey _textFieldKey = GlobalKey(); - // --- שלב 1: הוספת מפתח ל-Stack --- final GlobalKey _stackKey = GlobalKey(); final List _wordPositions = []; final Map> _alternativeControllers = {}; - final Map> _alternativeOverlays = {}; OverlayEntry? _searchOptionsOverlay; + int? _hoveredWordIndex; + final Map _spacingOverlays = {}; + final Map _spacingControllers = {}; + + final List _wordLeftEdges = []; + final List _wordRightEdges = []; + + static const double _kSearchFieldMinWidth = 300; + static const double _kControlHeight = 48; static const double _kPlusYOffset = 10; static const double _kPlusRadius = 10; + static const double _kSpacingYOffset = 45; + + String _spaceKey(int left, int right) => '$left-$right'; @override void initState() { @@ -210,7 +385,10 @@ class _EnhancedSearchFieldState extends State { } } _alternativeOverlays.clear(); - + for (final entry in _spacingOverlays.values) { + entry.remove(); + } + _spacingOverlays.clear(); _searchOptionsOverlay?.remove(); _searchOptionsOverlay = null; } @@ -222,11 +400,14 @@ class _EnhancedSearchFieldState extends State { } } _alternativeControllers.clear(); + for (final controller in _spacingControllers.values) { + controller.dispose(); + } + _spacingControllers.clear(); } void _onTextChanged() { _clearAllOverlays(); - final text = widget.widget.tab.queryController.text; setState(() { _searchQuery = SearchQuery.fromString(text); @@ -252,11 +433,9 @@ class _EnhancedSearchFieldState extends State { } } - // --- שלב 3: החלפת הלוגיקה של חישוב המיקום --- void _calculateWordPositions() { if (_textFieldKey.currentContext == null) return; - // 1. מוצאים את RenderEditable RenderEditable? editable; void findEditable(RenderObject child) { if (child is RenderEditable) { @@ -265,19 +444,20 @@ class _EnhancedSearchFieldState extends State { child.visitChildren(findEditable); } } + _textFieldKey.currentContext! .findRenderObject()! .visitChildren(findEditable); if (editable == null) return; - // 2. בסיס ה‑Stack בגלובלי - final stackBox = - _stackKey.currentContext?.findRenderObject() as RenderBox?; + final stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?; if (stackBox == null) return; final stackOrigin = stackBox.localToGlobal(Offset.zero); - // 3. מחשבים מרכז של כל מילה → גלובלי → יחסית ל‑Stack _wordPositions.clear(); + _wordLeftEdges.clear(); + _wordRightEdges.clear(); + final text = widget.widget.tab.queryController.text; if (text.isEmpty) { setState(() {}); @@ -287,6 +467,7 @@ class _EnhancedSearchFieldState extends State { final words = text.trim().split(RegExp(r'\s+')); int idx = 0; for (final w in words) { + if (w.isEmpty) continue; final start = text.indexOf(w, idx); if (start == -1) continue; final end = start + w.length; @@ -294,27 +475,36 @@ class _EnhancedSearchFieldState extends State { final pts = editable!.getEndpointsForSelection( TextSelection(baseOffset: start, extentOffset: end), ); - if (pts.length < 2) continue; + if (pts.isEmpty) continue; + + final leftLocalX = pts.first.point.dx; + final rightLocalX = pts.last.point.dx; + + final leftGlobal = editable!.localToGlobal(Offset(leftLocalX, 0)); + final rightGlobal = editable!.localToGlobal(Offset(rightLocalX, 0)); + + _wordLeftEdges.add(leftGlobal.dx - stackOrigin.dx); + _wordRightEdges.add(rightGlobal.dx - stackOrigin.dx); - final centerLocalX = (pts[0].point.dx + pts[1].point.dx) / 2; + final centerLocalX = (leftLocalX + rightLocalX) / 2; final local = Offset( centerLocalX, editable!.size.height + _kPlusYOffset, ); - final global = editable!.localToGlobal(local); - final inStack = global - stackOrigin; + _wordPositions.add(global - stackOrigin); - _wordPositions.add(inStack); - idx = end + 1; + idx = end; } - setState(() {}); } void _addAlternative(int termIndex) { setState(() { _alternativeControllers.putIfAbsent(termIndex, () => []); + if (_alternativeControllers[termIndex]!.length >= 2) { + return; + } final newIndex = _alternativeControllers[termIndex]!.length; _alternativeControllers[termIndex]!.add(TextEditingController()); _showAlternativeOverlay(termIndex, newIndex); @@ -333,22 +523,38 @@ class _EnhancedSearchFieldState extends State { _alternativeControllers[termIndex]![altIndex].dispose(); _alternativeControllers[termIndex]!.removeAt(altIndex); } + _refreshAlternativeOverlays(termIndex); }); } + void _checkAndRemoveEmptyField(int termIndex, int altIndex) { + if (_alternativeControllers.containsKey(termIndex) && + altIndex < _alternativeControllers[termIndex]!.length && + _alternativeControllers[termIndex]![altIndex].text.trim().isEmpty) { + _removeAlternative(termIndex, altIndex); + } + } + + void _refreshAlternativeOverlays(int termIndex) { + if (!_alternativeOverlays.containsKey(termIndex)) return; + for (final overlay in _alternativeOverlays[termIndex]!) { + overlay.remove(); + } + _alternativeOverlays[termIndex]!.clear(); + for (int i = 0; i < _alternativeControllers[termIndex]!.length; i++) { + _showAlternativeOverlay(termIndex, i); + } + } + void _showAlternativeOverlay(int termIndex, int altIndex) { final overlayState = Overlay.of(context); - final RenderBox? textFieldBox = _textFieldKey.currentContext?.findRenderObject() as RenderBox?; if (textFieldBox == null) return; - final textFieldGlobalPosition = textFieldBox.localToGlobal(Offset.zero); final wordRelativePosition = _wordPositions[termIndex]; final overlayPosition = textFieldGlobalPosition + wordRelativePosition; - final controller = _alternativeControllers[termIndex]![altIndex]; - final entry = OverlayEntry( builder: (context) { return Positioned( @@ -357,11 +563,11 @@ class _EnhancedSearchFieldState extends State { child: _AlternativeField( controller: controller, onRemove: () => _removeAlternative(termIndex, altIndex), + onFocusLost: () => _checkAndRemoveEmptyField(termIndex, altIndex), ), ); }, ); - _alternativeOverlays.putIfAbsent(termIndex, () => []).add(entry); overlayState.insert(entry); } @@ -379,16 +585,84 @@ class _EnhancedSearchFieldState extends State { ); } + void _showSpacingOverlay(int leftIndex, int rightIndex) { + final key = _spaceKey(leftIndex, rightIndex); + if (_spacingOverlays.containsKey(key)) return; + final overlayState = Overlay.of(context); + final RenderBox? textFieldBox = + _textFieldKey.currentContext?.findRenderObject() as RenderBox?; + if (textFieldBox == null) return; + final textFieldGlobal = textFieldBox.localToGlobal(Offset.zero); + final midpoint = Offset( + (_wordRightEdges[leftIndex] + _wordLeftEdges[rightIndex]) / 2, + _wordPositions[leftIndex].dy - _kSpacingYOffset, + ); + final overlayPos = textFieldGlobal + midpoint; + final controller = + _spacingControllers.putIfAbsent(key, () => TextEditingController()); + final entry = OverlayEntry( + builder: (_) => Positioned( + left: overlayPos.dx - 32.5, + top: overlayPos.dy - 50, + child: _SpacingField( + controller: controller, + onRemove: () => _removeSpacingOverlay(key), + onFocusLost: () => _removeSpacingOverlayIfEmpty(key), + ), + ), + ); + _spacingOverlays[key] = entry; + overlayState.insert(entry); + } + + void _removeSpacingOverlay(String key) { + _spacingOverlays[key]?.remove(); + _spacingOverlays.remove(key); + _spacingControllers[key]?.dispose(); + _spacingControllers.remove(key); + } + + void _removeSpacingOverlayIfEmpty(String key) { + if (_spacingControllers[key]?.text.trim().isEmpty ?? true) { + _removeSpacingOverlay(key); + } + } + + List _buildSpacingButtons() { + if (_wordPositions.length < 2) return []; + + List buttons = []; + for (int i = 0; i < _wordPositions.length - 1; i++) { + final spacingX = (_wordRightEdges[i] + _wordLeftEdges[i + 1]) / 2; + final spacingY = _wordPositions[i].dy - _kSpacingYOffset; + + final shouldShow = _hoveredWordIndex == i || _hoveredWordIndex == i + 1; + if (shouldShow) { + buttons.add( + Positioned( + left: spacingX - 10, + top: spacingY, + child: MouseRegion( + onEnter: (_) => setState(() => _hoveredWordIndex = i), + onExit: (_) => setState(() => _hoveredWordIndex = null), + child: _SpacingButton( + onTap: () => _showSpacingOverlay(i, i + 1), + ), + ), + ), + ); + } + } + return buttons; + } + void _showSearchOptionsOverlay() { if (_searchOptionsOverlay != null) return; - final overlayState = Overlay.of(context); final RenderBox? textFieldBox = _textFieldKey.currentContext?.findRenderObject() as RenderBox?; if (textFieldBox == null) return; - final textFieldGlobalPosition = textFieldBox.localToGlobal(Offset.zero); - _searchOptionsOverlay = OverlayEntry( builder: (context) { return Positioned( @@ -424,7 +698,6 @@ class _EnhancedSearchFieldState extends State { ); }, ); - overlayState.insert(_searchOptionsOverlay!); } @@ -437,7 +710,6 @@ class _EnhancedSearchFieldState extends State { 'כתיב מלא/חסר', 'שורש', ]; - return Wrap( spacing: 16.0, runSpacing: 8.0, @@ -496,53 +768,79 @@ class _EnhancedSearchFieldState extends State { @override Widget build(BuildContext context) { return Stack( - // --- שלב 2: נתינת המפתח ל-Stack --- key: _stackKey, clipBehavior: Clip.none, children: [ Padding( padding: const EdgeInsets.all(8.0), - child: TextField( - key: _textFieldKey, - focusNode: widget.widget.tab.searchFieldFocusNode, - controller: widget.widget.tab.queryController, - onSubmitted: (e) { - context.read().add(UpdateSearchQuery(e)); - widget.widget.tab.isLeftPaneOpen.value = false; - }, - decoration: InputDecoration( - border: const OutlineInputBorder(), - hintText: "חפש כאן..", - labelText: "לחיפוש הקש אנטר או לחץ על סמל החיפוש", - prefixIcon: IconButton( - onPressed: () { - context.read().add(UpdateSearchQuery( - widget.widget.tab.queryController.text)); - }, - icon: const Icon(Icons.search), + child: SizedBox( + width: double.infinity, + child: ConstrainedBox( + constraints: const BoxConstraints( + minWidth: _kSearchFieldMinWidth, + minHeight: _kControlHeight, ), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SearchOptionsDropdown( - onToggle: _toggleSearchOptions, - ), - IconButton( - icon: const Icon(Icons.clear), + child: TextField( + key: _textFieldKey, + focusNode: widget.widget.tab.searchFieldFocusNode, + controller: widget.widget.tab.queryController, + onSubmitted: (e) { + context.read().add(UpdateSearchQuery(e)); + widget.widget.tab.isLeftPaneOpen.value = false; + }, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: "חפש כאן..", + labelText: "לחיפוש הקש אנטר או לחץ על סמל החיפוש", + prefixIcon: IconButton( onPressed: () { - widget.widget.tab.queryController.clear(); - context.read().add(UpdateSearchQuery('')); + context.read().add(UpdateSearchQuery( + widget.widget.tab.queryController.text)); }, + icon: const Icon(Icons.search), ), - ], + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SearchOptionsDropdown( + onToggle: _toggleSearchOptions, + ), + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + widget.widget.tab.queryController.clear(); + context.read().add(UpdateSearchQuery('')); + }, + ), + ], + ), + ), ), ), ), ), + ..._wordPositions.asMap().entries.map((entry) { + final wordIndex = entry.key; + final position = entry.value; + return Positioned( + left: position.dx - 30, + top: position.dy - 35, + child: MouseRegion( + onEnter: (_) => setState(() => _hoveredWordIndex = wordIndex), + onExit: (_) => setState(() => _hoveredWordIndex = null), + child: Container( + width: 60, + height: 30, + color: Colors.transparent, + ), + ), + ); + }).toList(), ..._wordPositions.asMap().entries.map((entry) { return _buildPlusButton(entry.key, entry.value); }).toList(), + ..._buildSpacingButtons(), ], ); } -} \ No newline at end of file +} diff --git a/lib/search/view/full_text_settings_widgets.dart b/lib/search/view/full_text_settings_widgets.dart index b8d6c3928..661d84232 100644 --- a/lib/search/view/full_text_settings_widgets.dart +++ b/lib/search/view/full_text_settings_widgets.dart @@ -111,7 +111,7 @@ class NumOfResults extends StatelessWidget { } } -class SearchTermsDisplay extends StatelessWidget { +class SearchTermsDisplay extends StatefulWidget { const SearchTermsDisplay({ super.key, required this.tab, @@ -119,6 +119,32 @@ class SearchTermsDisplay extends StatelessWidget { final SearchingTab tab; + @override + State createState() => _SearchTermsDisplayState(); +} + +class _SearchTermsDisplayState extends State { + int? _hoveredWordIndex; + + @override + void initState() { + super.initState(); + // מאזין לשינויים בקונטרולר + widget.tab.queryController.addListener(_onTextChanged); + } + + @override + void dispose() { + widget.tab.queryController.removeListener(_onTextChanged); + super.dispose(); + } + + void _onTextChanged() { + setState(() { + // עדכון התצוגה כשהטקסט משתנה + }); + } + String _getDisplayText(String originalQuery) { // כרגע נציג את הטקסט המקורי // בעתיד נוסיף כאן לוגיקה להצגת החלופות @@ -126,44 +152,139 @@ class SearchTermsDisplay extends StatelessWidget { return originalQuery; } + List _getWords(String text) { + // פיצול הטקסט למילים + return text.trim().split(RegExp(r'\s+')); + } + + Widget _buildSpacingButton( + {required VoidCallback onPressed, required bool isLeft}) { + return SizedBox( + width: 20, + height: 20, + child: IconButton( + padding: EdgeInsets.zero, + iconSize: 12, + onPressed: onPressed, + icon: Icon( + isLeft ? Icons.keyboard_arrow_left : Icons.keyboard_arrow_right, + color: Colors.blue, + ), + style: IconButton.styleFrom( + backgroundColor: Colors.blue.withOpacity(0.1), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ); + } + + Widget _buildWordWithSpacing(String word, int index, int totalWords) { + final isFirst = index == 0; + final isLast = index == totalWords - 1; + final isHovered = _hoveredWordIndex == index; + + return MouseRegion( + onEnter: (_) => setState(() => _hoveredWordIndex = index), + onExit: (_) => setState(() => _hoveredWordIndex = null), + child: Container( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // כפתור שמאלי (רק למילים שאינן ראשונות או כשמרחפים) + if (!isFirst && isHovered) + _buildSpacingButton( + onPressed: () { + // כאן נוסיף לוגיקה להוספת מרווח + print('Add spacing before word: $word'); + }, + isLeft: true, + ), + + // המילה עצמה + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: isHovered + ? BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ) + : null, + child: Text( + word, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + + // כפתור ימני (רק למילים שאינן אחרונות או כשמרחפים) + if (!isLast && isHovered) + _buildSpacingButton( + onPressed: () { + // כאן נוסיף לוגיקה להוספת מרווח + print('Add spacing after word: $word'); + }, + isLeft: false, + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - final displayText = _getDisplayText(state.searchQuery); - - // חישוב רוחב מותאם לטקסט המלא (כולל חלופות) - final textLength = displayText.length; - const minWidth = 120.0; // רוחב מינימלי - const maxWidth = 400.0; // רוחב מקסימלי מוגדל לחלופות - final calculatedWidth = - (textLength * 8.0 + 60).clamp(minWidth, maxWidth); + // נציג את הטקסט הנוכחי מהקונטרולר במקום מה-state + final displayText = _getDisplayText(widget.tab.queryController.text); return Container( height: 52, // גובה קבוע כמו שאר הווידג'טים padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - child: Center( - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: minWidth, - maxWidth: calculatedWidth, - ), - child: TextField( - readOnly: true, - controller: TextEditingController(text: displayText), - textAlign: TextAlign.center, // ממרכז את הטקסט - decoration: const InputDecoration( - labelText: 'מילות החיפוש', - border: OutlineInputBorder(), - contentPadding: - EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), - ), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, + child: LayoutBuilder( + builder: (context, constraints) { + // חישוב רוחב דינמי בהתבסס על אורך הטקסט + final textLength = displayText.length; + const minWidth = 120.0; // רוחב מינימלי גדול יותר + final maxWidth = constraints.maxWidth; // כל הרוחב הזמין + + // חישוב רוחב בהתבסס על אורך הטקסט + final calculatedWidth = textLength == 0 + ? minWidth + : (textLength * 8.0 + 40).clamp(minWidth, maxWidth); + + return Align( + alignment: Alignment.center, // ממורכז תמיד + child: SizedBox( + width: calculatedWidth, + child: Scrollbar( + thumbVisibility: + displayText.isNotEmpty, // מציג פס גלילה רק כשיש טקסט + child: TextField( + readOnly: true, + controller: TextEditingController(text: displayText), + textAlign: TextAlign.center, // ממרכז את הטקסט + maxLines: 1, // שורה אחת בלבד + scrollPadding: EdgeInsets.zero, // מאפשר גלילה חלקה + decoration: const InputDecoration( + labelText: 'מילות החיפוש', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric( + horizontal: 8.0, vertical: 8.0), + ), + style: const TextStyle( + fontSize: 13, // גופן קצת יותר קטן + fontWeight: FontWeight.w500, + ), + ), + ), ), - ), - ), + ); + }, ), ); }, diff --git a/lib/search/view/tantivy_full_text_search.dart b/lib/search/view/tantivy_full_text_search.dart index a54ca5c78..44936231d 100644 --- a/lib/search/view/tantivy_full_text_search.dart +++ b/lib/search/view/tantivy_full_text_search.dart @@ -33,7 +33,7 @@ class _TantivyFullTextSearchState extends State // Check if indexing is in progress using the IndexingBloc final indexingState = context.read().state; _showIndexWarning = indexingState is IndexingInProgress; - + // Request focus on search field when the widget is first created _requestSearchFieldFocus(); } @@ -50,7 +50,7 @@ class _TantivyFullTextSearchState extends State if (mounted && widget.tab.searchFieldFocusNode.canRequestFocus) { // Check if this tab is the currently selected tab final tabsState = context.read().state; - if (tabsState.hasOpenTabs && + if (tabsState.hasOpenTabs && tabsState.currentTabIndex < tabsState.tabs.length && tabsState.tabs[tabsState.currentTabIndex] == widget.tab) { widget.tab.searchFieldFocusNode.requestFocus(); @@ -61,8 +61,8 @@ class _TantivyFullTextSearchState extends State void _onNavigationChanged(NavigationState state) { // Request focus when navigating to search screen - if (state.currentScreen == Screen.search - || state.currentScreen == Screen.reading) { + if (state.currentScreen == Screen.search || + state.currentScreen == Screen.reading) { _requestSearchFieldFocus(); } } @@ -92,6 +92,9 @@ class _TantivyFullTextSearchState extends State Expanded(child: TantivySearchField(widget: widget)), ], ), + // השורה התחתונה - מוצגת תמיד! + _buildBottomRow(state), + _buildDivider(), Expanded( child: Stack( children: [ @@ -157,34 +160,44 @@ class _TantivyFullTextSearchState extends State Expanded( child: BlocBuilder( builder: (context, state) { - return Row( + return Column( children: [ - SizedBox( - width: 350, - child: SearchFacetFiltering(tab: widget.tab), - ), - Container( - width: 1, - color: Colors.grey.shade300, - ), + // השורה התחתונה - מוצגת תמיד! + _buildBottomRow(state), + _buildDivider(), Expanded( - child: Builder(builder: (context) { - if (state.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - if (state.searchQuery.isEmpty) { - return const Center(child: Text("לא בוצע חיפוש")); - } - if (state.results.isEmpty) { - return const Center( - child: Padding( - padding: EdgeInsets.all(8.0), - child: Text('אין תוצאות'), - )); - } - return TantivySearchResults(tab: widget.tab); - }), - ) + child: Row( + children: [ + SizedBox( + width: 350, + child: SearchFacetFiltering(tab: widget.tab), + ), + Container( + width: 1, + color: Colors.grey.shade300, + ), + Expanded( + child: Builder(builder: (context) { + if (state.isLoading) { + return const Center( + child: CircularProgressIndicator()); + } + if (state.searchQuery.isEmpty) { + return const Center(child: Text("לא בוצע חיפוש")); + } + if (state.results.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(8.0), + child: Text('אין תוצאות'), + )); + } + return TantivySearchResults(tab: widget.tab); + }), + ) + ], + ), + ), ], ); }, @@ -206,6 +219,63 @@ class _TantivyFullTextSearchState extends State ); } + // השורה התחתונה שמוצגת תמיד + Widget _buildBottomRow(SearchState state) { + return LayoutBuilder( + builder: (context, constraints) { + return Container( + height: 60, // גובה קבוע + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: Row( + children: [ + // מילות החיפוש - תופס את כל המקום הזמין + Expanded( + child: SearchTermsDisplay(tab: widget.tab), + ), + // ספירת התוצאות עם תווית + SizedBox( + width: 180, // רוחב קבוע כמו שאר הבקרות + height: 52, // אותו גובה כמו הבקרות האחרות + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, vertical: 4.0), + child: InputDecorator( + decoration: const InputDecoration( + labelText: 'תוצאות חיפוש', + border: OutlineInputBorder(), + contentPadding: + EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + ), + child: Center( + child: Text( + state.results.isEmpty && state.searchQuery.isEmpty + ? 'עדיין לא בוצע חיפוש' + : '${state.results.length} מתוך ${state.totalResults}', + style: const TextStyle(fontSize: 14), + ), + ), + ), + ), + ), + if (constraints.maxWidth > 450) + OrderOfResults(widget: TantivySearchResults(tab: widget.tab)), + if (constraints.maxWidth > 450) NumOfResults(tab: widget.tab), + ], + ), + ); + }, + ); + } + + // פס מפריד מתחת לשורה התחתונה + Widget _buildDivider() { + return Container( + height: 1, + color: Colors.grey.shade300, + margin: const EdgeInsets.symmetric(horizontal: 8.0), + ); + } + Container _buildIndexWarning() { return Container( padding: const EdgeInsets.all(8.0), diff --git a/lib/search/view/tantivy_search_results.dart b/lib/search/view/tantivy_search_results.dart index 9b1813dcc..16e2eaf70 100644 --- a/lib/search/view/tantivy_search_results.dart +++ b/lib/search/view/tantivy_search_results.dart @@ -7,7 +7,7 @@ import 'package:html/parser.dart' as html_parser; import 'package:otzaria/models/books.dart'; import 'package:otzaria/search/bloc/search_bloc.dart'; import 'package:otzaria/search/bloc/search_state.dart'; -import 'package:otzaria/search/view/full_text_settings_widgets.dart'; + import 'package:otzaria/settings/settings_bloc.dart'; import 'package:otzaria/settings/settings_state.dart'; import 'package:otzaria/tabs/bloc/tabs_bloc.dart'; @@ -246,69 +246,32 @@ class _TantivySearchResultsState extends State { return LayoutBuilder(builder: (context, constrains) { return BlocBuilder( builder: (context, state) { - if (state.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - if (state.searchQuery.isEmpty) { - return const Center(child: Text("לא בוצע חיפוש")); - } - if (state.results.isEmpty) { - return const Center( - child: Padding( - padding: EdgeInsets.all(8.0), - child: Text('אין תוצאות'), - )); - } - - return Column( - children: [ - // פס עליון עם הבקרות - גובה קבוע - Container( - height: 60, // גובה קבוע - padding: - const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - child: Row( - children: [ - // מילות החיפוש - מקבל את כל המקום הנותר - Expanded(child: SearchTermsDisplay(tab: widget.tab)), - // ספירת התוצאות במלבן - Container( - height: 52, // אותו גובה כמו הבקרות האחרות - padding: const EdgeInsets.symmetric( - horizontal: 8.0, vertical: 4.0), - child: Container( - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade400), - borderRadius: BorderRadius.circular(4.0), - ), - padding: const EdgeInsets.symmetric( - horizontal: 12.0, vertical: 8.0), - child: Center( - child: Text( - '${state.results.length} תוצאות מתוך ${state.totalResults}', - style: const TextStyle(fontSize: 14), - ), - ), - ), - ), - if (constrains.maxWidth > 450) - OrderOfResults(widget: widget), - if (constrains.maxWidth > 450) - NumOfResults(tab: widget.tab), - ], - ), - ), - // פס מפריד מתחת לשורת הבקרות - Container( - height: 1, - color: Colors.grey.shade300, - margin: const EdgeInsets.symmetric(horizontal: 8.0), - ), - Expanded( - child: ListView.builder( - shrinkWrap: true, - itemCount: state.results.length, - itemBuilder: (context, index) { + // עכשיו רק מציגים את התוצאות - השורה התחתונה מוצגת במקום אחר + return _buildResultsContent(state, constrains); + }, + ); + }); + } + + Widget _buildResultsContent(SearchState state, BoxConstraints constrains) { + if (state.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (state.searchQuery.isEmpty) { + return const Center(child: Text("לא בוצע חיפוש")); + } + if (state.results.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(8.0), + child: Text('אין תוצאות'), + )); + } + + return ListView.builder( + shrinkWrap: true, + itemCount: state.results.length, + itemBuilder: (context, index) { final result = state.results[index]; return BlocBuilder( builder: (context, settingsState) { @@ -395,12 +358,6 @@ class _TantivySearchResultsState extends State { }, ); }, - ), - ), - ], - ); - }, - ); - }); + ); } } From 101ffad91a29064fae42599ef47b1d58cfce381b Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 27 Jul 2025 22:09:59 +0300 Subject: [PATCH 034/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=9B?= =?UTF-8?q?=D7=A4=D7=AA=D7=95=D7=A8=20=D7=97=D7=99=D7=A4=D7=95=D7=A9=20?= =?UTF-8?q?=D7=9E=D7=AA=D7=A7=D7=93=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/enhanced_search_field.dart | 22 ++++--- .../view/full_text_settings_widgets.dart | 58 +++++++++++++++++++ lib/search/view/tantivy_full_text_search.dart | 27 ++++++++- lib/tabs/models/searching_tab.dart | 8 ++- 4 files changed, 105 insertions(+), 10 deletions(-) diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 912d982e1..143891ef7 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -346,6 +346,9 @@ class _EnhancedSearchFieldState extends State { final Map> _alternativeOverlays = {}; OverlayEntry? _searchOptionsOverlay; int? _hoveredWordIndex; + + // מצב חיפוש מתקדם + bool _isAdvancedSearchEnabled = true; final Map _spacingOverlays = {}; final Map _spacingControllers = {}; @@ -802,9 +805,10 @@ class _EnhancedSearchFieldState extends State { suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ - SearchOptionsDropdown( - onToggle: _toggleSearchOptions, - ), + if (widget.widget.tab.isAdvancedSearchEnabled) + SearchOptionsDropdown( + onToggle: _toggleSearchOptions, + ), IconButton( icon: const Icon(Icons.clear), onPressed: () { @@ -836,10 +840,14 @@ class _EnhancedSearchFieldState extends State { ), ); }).toList(), - ..._wordPositions.asMap().entries.map((entry) { - return _buildPlusButton(entry.key, entry.value); - }).toList(), - ..._buildSpacingButtons(), + // כפתורי ה+ (רק בחיפוש מתקדם) + if (widget.widget.tab.isAdvancedSearchEnabled) + ..._wordPositions.asMap().entries.map((entry) { + return _buildPlusButton(entry.key, entry.value); + }).toList(), + // כפתורי המרווח (רק בחיפוש מתקדם) + if (widget.widget.tab.isAdvancedSearchEnabled) + ..._buildSpacingButtons(), ], ); } diff --git a/lib/search/view/full_text_settings_widgets.dart b/lib/search/view/full_text_settings_widgets.dart index 661d84232..db1d8ba30 100644 --- a/lib/search/view/full_text_settings_widgets.dart +++ b/lib/search/view/full_text_settings_widgets.dart @@ -292,6 +292,64 @@ class _SearchTermsDisplayState extends State { } } +class AdvancedSearchToggle extends StatelessWidget { + const AdvancedSearchToggle({ + super.key, + required this.tab, + required this.onChanged, + }); + + final SearchingTab tab; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 100, // רוחב צר יותר + height: 52, // גובה קבוע כמו שאר הבקרות + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: Container( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceVariant, // צבע רקע לכל המלבן + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(4), + ), + child: Column( + children: [ + // תווית עליונה + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + child: Text( + 'חיפוש מתקדם', + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ), + // הצ'קבוקס + Expanded( + child: Center( + child: Checkbox( + value: tab.isAdvancedSearchEnabled, + onChanged: (value) => onChanged(value ?? true), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + class OrderOfResults extends StatelessWidget { const OrderOfResults({ super.key, diff --git a/lib/search/view/tantivy_full_text_search.dart b/lib/search/view/tantivy_full_text_search.dart index 44936231d..1d852bb2b 100644 --- a/lib/search/view/tantivy_full_text_search.dart +++ b/lib/search/view/tantivy_full_text_search.dart @@ -88,6 +88,18 @@ class _TantivyFullTextSearchState extends State if (_showIndexWarning) _buildIndexWarning(), Row( children: [ + // כפתור חיפוש מתקדם למסכים קטנים - מימין + SizedBox( + width: 70, // רוחב קטן יותר למסכים קטנים + child: AdvancedSearchToggle( + tab: widget.tab, + onChanged: (value) { + setState(() { + widget.tab.isAdvancedSearchEnabled = value; + }); + }, + ), + ), _buildMenuButton(), Expanded(child: TantivySearchField(widget: widget)), ], @@ -150,6 +162,15 @@ class _TantivyFullTextSearchState extends State Row( mainAxisSize: MainAxisSize.min, children: [ + // כפתור חיפוש מתקדם - מימין + AdvancedSearchToggle( + tab: widget.tab, + onChanged: (value) { + setState(() { + widget.tab.isAdvancedSearchEnabled = value; + }); + }, + ), Expanded( child: TantivySearchField(widget: widget), ), @@ -228,9 +249,11 @@ class _TantivyFullTextSearchState extends State padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), child: Row( children: [ - // מילות החיפוש - תופס את כל המקום הזמין + // מילות החיפוש - תמיד תופס מקום, אבל מוסתר כשלא בחיפוש מתקדם Expanded( - child: SearchTermsDisplay(tab: widget.tab), + child: widget.tab.isAdvancedSearchEnabled + ? SearchTermsDisplay(tab: widget.tab) + : const SizedBox.shrink(), // מקום ריק שמחזיק את הפרופורציות ), // ספירת התוצאות עם תווית SizedBox( diff --git a/lib/tabs/models/searching_tab.dart b/lib/tabs/models/searching_tab.dart index 53826dc4f..5d4b0a0aa 100644 --- a/lib/tabs/models/searching_tab.dart +++ b/lib/tabs/models/searching_tab.dart @@ -12,6 +12,9 @@ class SearchingTab extends OpenedTab { final ValueNotifier isLeftPaneOpen = ValueNotifier(true); final ItemScrollController scrollController = ItemScrollController(); List allBooks = []; + + // מצב חיפוש מתקדם + bool isAdvancedSearchEnabled = true; SearchingTab( super.title, @@ -35,7 +38,9 @@ class SearchingTab extends OpenedTab { @override factory SearchingTab.fromJson(Map json) { - return SearchingTab(json['title'], json['searchText']); + final tab = SearchingTab(json['title'], json['searchText']); + tab.isAdvancedSearchEnabled = json['isAdvancedSearchEnabled'] ?? true; + return tab; } @override @@ -43,6 +48,7 @@ class SearchingTab extends OpenedTab { return { 'title': title, 'searchText': queryController.text, + 'isAdvancedSearchEnabled': isAdvancedSearchEnabled, 'type': 'SearchingTabWindow' }; } From 0ac50742d98d4c5a17ab4553c9606e10478ac747 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 28 Jul 2025 21:22:21 +0300 Subject: [PATCH 035/197] =?UTF-8?q?=D7=94=D7=9E=D7=92=D7=99=D7=A8=D7=94=20?= =?UTF-8?q?=D7=A4=D7=95=D7=A2=D7=9C=D7=AA=20=D7=9C=D7=A4=D7=99=20=D7=9E?= =?UTF-8?q?=D7=99=D7=9C=D7=94,=20=D7=95=D7=A9=D7=99=D7=A4=D7=95=D7=A8?= =?UTF-8?q?=D7=99=20=D7=9E=D7=9E=D7=A9=D7=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/enhanced_search_field.dart | 580 +++++++++++++----- .../view/full_text_settings_widgets.dart | 119 ++-- lib/search/view/search_options_dropdown.dart | 171 ++++-- 3 files changed, 625 insertions(+), 245 deletions(-) diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 143891ef7..f59c724e9 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -55,12 +55,14 @@ class _PlusButtonState extends State<_PlusButton> { width: 20, height: 20, decoration: BoxDecoration( - color: isHighlighted ? primaryColor : primaryColor.withOpacity(0.5), + color: isHighlighted + ? primaryColor + : primaryColor.withValues(alpha: 0.5), shape: BoxShape.circle, boxShadow: [ if (isHighlighted) BoxShadow( - color: Colors.black.withOpacity(0.3), + color: Colors.black.withValues(alpha: 0.3), blurRadius: 4, offset: const Offset(0, 2), ), @@ -95,12 +97,14 @@ class _SpacingButtonState extends State<_SpacingButton> { width: 20, height: 20, decoration: BoxDecoration( - color: _isHovering ? primaryColor : primaryColor.withOpacity(0.7), + color: _isHovering + ? primaryColor + : primaryColor.withValues(alpha: 0.7), shape: BoxShape.circle, boxShadow: [ if (_isHovering) BoxShadow( - color: Colors.black.withOpacity(0.3), + color: Colors.black.withValues(alpha: 0.3), blurRadius: 4, offset: const Offset(0, 2), ), @@ -178,7 +182,8 @@ class _SpacingFieldState extends State<_SpacingField> { ), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(_focus.hasFocus ? 0.15 : 0.08), + color: + Colors.black.withValues(alpha: _focus.hasFocus ? 0.15 : 0.08), blurRadius: _focus.hasFocus ? 6 : 3, offset: const Offset(0, 2), ), @@ -285,7 +290,8 @@ class _AlternativeFieldState extends State<_AlternativeField> { ), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(_focus.hasFocus ? 0.15 : 0.08), + color: + Colors.black.withValues(alpha: _focus.hasFocus ? 0.15 : 0.08), blurRadius: _focus.hasFocus ? 6 : 3, offset: const Offset(0, 2), ), @@ -346,9 +352,10 @@ class _EnhancedSearchFieldState extends State { final Map> _alternativeOverlays = {}; OverlayEntry? _searchOptionsOverlay; int? _hoveredWordIndex; - - // מצב חיפוש מתקדם - bool _isAdvancedSearchEnabled = true; + + // מפה שמחזיקה אפשרויות לכל מילה (לא static כדי שתתנקה כשהווידג'ט נהרס) + final Map> _wordOptions = {}; + final Map _spacingOverlays = {}; final Map _spacingControllers = {}; @@ -360,7 +367,7 @@ class _EnhancedSearchFieldState extends State { static const double _kPlusYOffset = 10; static const double _kPlusRadius = 10; - static const double _kSpacingYOffset = 45; + static const double _kSpacingYOffset = 53; String _spaceKey(int left, int right) => '$left-$right'; @@ -368,6 +375,9 @@ class _EnhancedSearchFieldState extends State { void initState() { super.initState(); widget.widget.tab.queryController.addListener(_onTextChanged); + // מאזין לשינויי מיקום הסמן + widget.widget.tab.searchFieldFocusNode + .addListener(_onCursorPositionChanged); WidgetsBinding.instance.addPostFrameCallback((_) { _calculateWordPositions(); }); @@ -377,24 +387,34 @@ class _EnhancedSearchFieldState extends State { void dispose() { _clearAllOverlays(); widget.widget.tab.queryController.removeListener(_onTextChanged); + widget.widget.tab.searchFieldFocusNode + .removeListener(_onCursorPositionChanged); _disposeControllers(); + // ניקוי אפשרויות החיפוש כשסוגרים את המסך + _wordOptions.clear(); super.dispose(); } - void _clearAllOverlays() { - for (final entries in _alternativeOverlays.values) { - for (final entry in entries) { - entry.remove(); - } - } - _alternativeOverlays.clear(); - for (final entry in _spacingOverlays.values) { +void _clearAllOverlays({bool keepSearchDrawer = false}) { + // ניקוי אלטרנטיבות ומרווחים + for (final entries in _alternativeOverlays.values) { + for (final entry in entries) { entry.remove(); } - _spacingOverlays.clear(); + } + _alternativeOverlays.clear(); + + for (final entry in _spacingOverlays.values) { + entry.remove(); + } + _spacingOverlays.clear(); + + // סגירת מגירת האפשרויות רק אם לא ביקשנו לשמור אותה + if (!keepSearchDrawer) { _searchOptionsOverlay?.remove(); _searchOptionsOverlay = null; } +} void _disposeControllers() { for (final controllers in _alternativeControllers.values) { @@ -409,21 +429,59 @@ class _EnhancedSearchFieldState extends State { _spacingControllers.clear(); } - void _onTextChanged() { - _clearAllOverlays(); - final text = widget.widget.tab.queryController.text; - setState(() { - _searchQuery = SearchQuery.fromString(text); - _updateAlternativeControllers(); - }); - WidgetsBinding.instance.addPostFrameCallback((_) { - _calculateWordPositions(); - for (int i = 0; i < _searchQuery.terms.length; i++) { - for (int j = 0; j < _searchQuery.terms[i].alternatives.length; j++) { - _showAlternativeOverlay(i, j); - } +void _onTextChanged() { + // בודקים אם המגירה הייתה פתוחה לפני השינוי + final bool drawerWasOpen = _searchOptionsOverlay != null; + + // מנקים את כל הבועות, אבל משאירים את המגירה פתוחה אם היא הייתה פתוחה + _clearAllOverlays(keepSearchDrawer: drawerWasOpen); + + final text = widget.widget.tab.queryController.text; + + // אם שדה החיפוש התרוקן, נסגור את המגירה בכל זאת + if (text.trim().isEmpty && drawerWasOpen) { + _hideSearchOptionsOverlay(); + _notifyDropdownClosed(); + // יוצאים מהפונקציה כדי לא להמשיך + return; + } + + setState(() { + _searchQuery = SearchQuery.fromString(text); + _updateAlternativeControllers(); + }); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _calculateWordPositions(); + + for (int i = 0; i < _searchQuery.terms.length; i++) { + for (int j = 0; j < _searchQuery.terms[i].alternatives.length; j++) { + _showAlternativeOverlay(i, j); } - }); + } + + // אם המגירה הייתה פתוחה, מרעננים את התוכן שלה + if (drawerWasOpen) { + _updateSearchOptionsOverlay(); + } + }); +} + + void _onCursorPositionChanged() { + // עדכון המגירה כשהסמן זז (אם היא פתוחה) + if (_searchOptionsOverlay != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _updateSearchOptionsOverlay(); + }); + } + } + + void _updateSearchOptionsOverlay() { + // עדכון המגירה אם היא פתוחה + if (_searchOptionsOverlay != null) { + _hideSearchOptionsOverlay(); + _showSearchOptionsOverlay(); + } } void _updateAlternativeControllers() { @@ -668,35 +726,70 @@ class _EnhancedSearchFieldState extends State { final textFieldGlobalPosition = textFieldBox.localToGlobal(Offset.zero); _searchOptionsOverlay = OverlayEntry( builder: (context) { - return Positioned( - left: textFieldGlobalPosition.dx, - top: textFieldGlobalPosition.dy + textFieldBox.size.height, - width: textFieldBox.size.width, - child: Container( - height: 48.0, - decoration: BoxDecoration( - color: Theme.of(context).scaffoldBackgroundColor, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.25), - blurRadius: 8, - offset: const Offset(0, 4), + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (PointerDownEvent event) { + // בדיקה אם הלחיצה היא מחוץ לאזור שדה החיפוש והמגירה + final clickPosition = event.position; + + // אזור שדה החיפוש (מורחב כדי לכלול את כל האזור כולל הכפתורים) + final textFieldRect = Rect.fromLTWH( + textFieldGlobalPosition.dx - 20, // מרווח נוסף משמאל + textFieldGlobalPosition.dy - 20, // מרווח נוסף מלמעלה + textFieldBox.size.width + 40, // רוחב מורחב יותר + textFieldBox.size.height + 40, // גובה מורחב יותר + ); + + // אזור המגירה (מורחב מעט) + final drawerRect = Rect.fromLTWH( + textFieldGlobalPosition.dx - 10, + textFieldGlobalPosition.dy + textFieldBox.size.height - 5, + textFieldBox.size.width + 20, + 50.0, // גובה מורחב + ); + + // אם הלחיצה מחוץ לשני האזורים, סגור את המגירה + if (!textFieldRect.contains(clickPosition) && + !drawerRect.contains(clickPosition)) { + _hideSearchOptionsOverlay(); + _notifyDropdownClosed(); + } + }, + child: Stack( + children: [ + // המגירה עצמה + Positioned( + left: textFieldGlobalPosition.dx, + top: textFieldGlobalPosition.dy + textFieldBox.size.height, + width: textFieldBox.size.width, + child: Container( + height: 40.0, + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.25), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + border: Border( + left: BorderSide(color: Colors.grey.shade400, width: 1), + right: BorderSide(color: Colors.grey.shade400, width: 1), + bottom: BorderSide(color: Colors.grey.shade400, width: 1), + ), + ), + child: Material( + color: Theme.of(context).scaffoldBackgroundColor, + child: Padding( + padding: const EdgeInsets.only( + left: 48.0, right: 16.0, top: 8.0, bottom: 8.0), + child: _buildSearchOptionsContent(), + ), + ), ), - ], - border: Border( - left: BorderSide(color: Colors.grey.shade400, width: 1), - right: BorderSide(color: Colors.grey.shade400, width: 1), - bottom: BorderSide(color: Colors.grey.shade400, width: 1), - ), - ), - child: Material( - color: Theme.of(context).scaffoldBackgroundColor, - child: Padding( - padding: const EdgeInsets.only( - left: 48.0, right: 16.0, top: 8.0, bottom: 8.0), - child: _buildSearchOptionsContent(), ), - ), + ], ), ); }, @@ -704,54 +797,67 @@ class _EnhancedSearchFieldState extends State { overlayState.insert(_searchOptionsOverlay!); } - Widget _buildSearchOptionsContent() { - const options = [ - 'קידומות', - 'סיומות', - 'קידומות דקדוקיות', - 'סיומות דקדוקיות', - 'כתיב מלא/חסר', - 'שורש', - ]; - return Wrap( - spacing: 16.0, - runSpacing: 8.0, - children: options.map((option) => _buildCheckbox(option)).toList(), - ); + // המילה הנוכחית (לפי מיקום הסמן) + Map? _getCurrentWordInfo() { + final text = widget.widget.tab.queryController.text; + final cursorPosition = + widget.widget.tab.queryController.selection.baseOffset; + + if (text.isEmpty || cursorPosition < 0) return null; + + final words = text.trim().split(RegExp(r'\s+')); + int currentPos = 0; + + for (int i = 0; i < words.length; i++) { + final word = words[i]; + if (word.isEmpty) continue; + + final wordStart = text.indexOf(word, currentPos); + if (wordStart == -1) continue; + final wordEnd = wordStart + word.length; + + if (cursorPosition >= wordStart && cursorPosition <= wordEnd) { + return { + 'word': word, + 'index': i, + 'start': wordStart, + 'end': wordEnd, + }; + } + + currentPos = wordEnd; + } + + return null; } - Widget _buildCheckbox(String option) { - return InkWell( - onTap: () {}, - borderRadius: BorderRadius.circular(4), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 18, - height: 18, - decoration: BoxDecoration( - border: Border.all( - color: Colors.grey.shade600, - width: 2, - ), - borderRadius: BorderRadius.circular(3), - color: Colors.transparent, - ), - ), - const SizedBox(width: 6), - Text( - option, - style: TextStyle( - fontSize: 13, - color: Theme.of(context).textTheme.bodyMedium?.color, - ), - ), - ], + Widget _buildSearchOptionsContent() { + final wordInfo = _getCurrentWordInfo(); + + // אם אין מילה נוכחית, נציג הודעה + if (wordInfo == null || + wordInfo['word'] == null || + wordInfo['word'].isEmpty) { + final text = widget.widget.tab.queryController.text; + final message = text.trim().isEmpty + ? 'הקלד טקסט ומקם את הסמן על מילה לבחירת אפשרויות' + : 'מקם את הסמן על מילה לבחירת אפשרויות'; + + return Center( + child: Text( + message, + style: const TextStyle(fontSize: 12, color: Colors.grey), + textAlign: TextAlign.center, ), - ), + ); + } + + return _SearchOptionsContent( + currentWord: wordInfo['word'], + wordIndex: wordInfo['index'], + wordOptions: _wordOptions, + key: ValueKey( + '${wordInfo['word']}_${wordInfo['index']}'), // מפתח ייחודי לעדכון ); } @@ -760,14 +866,45 @@ class _EnhancedSearchFieldState extends State { _searchOptionsOverlay = null; } + void _notifyDropdownClosed() { + // עדכון מצב הכפתור כשהמגירה נסגרת מבחוץ + setState(() { + // זה יגרום לעדכון של הכפתור ב-build + // המצב יתעדכן דרך _isSearchOptionsVisible + }); + } + void _toggleSearchOptions(bool isExpanded) { if (isExpanded) { - _showSearchOptionsOverlay(); + // בדיקה שיש טקסט בשדה החיפוש ושהסמן על מילה + final text = widget.widget.tab.queryController.text.trim(); + final wordInfo = _getCurrentWordInfo(); + + if (text.isNotEmpty && + wordInfo != null && + wordInfo['word'] != null && + wordInfo['word'].isNotEmpty) { + _showSearchOptionsOverlay(); + } else { + // אם אין טקסט או הסמן לא על מילה, עדכן את המצב של הכפתור + setState(() { + // זה יגרום לכפתור לחזור למצב לא לחוץ + }); + + // הצגת הודעה קצרה למשתמש (אופציונלי) + if (text.isEmpty) { + // יכול להוסיף כאן הודעה שצריך להקליד טקסט + } else { + // יכול להוסיף כאן הודעה שצריך למקם את הסמן על מילה + } + } } else { _hideSearchOptionsOverlay(); } } + bool get _isSearchOptionsVisible => _searchOptionsOverlay != null; + @override Widget build(BuildContext context) { return Stack( @@ -783,59 +920,107 @@ class _EnhancedSearchFieldState extends State { minWidth: _kSearchFieldMinWidth, minHeight: _kControlHeight, ), - child: TextField( - key: _textFieldKey, - focusNode: widget.widget.tab.searchFieldFocusNode, - controller: widget.widget.tab.queryController, - onSubmitted: (e) { - context.read().add(UpdateSearchQuery(e)); - widget.widget.tab.isLeftPaneOpen.value = false; + child: KeyboardListener( + focusNode: FocusNode(), + onKeyEvent: (KeyEvent event) { + // עדכון המגירה כשמשתמשים בחצים במקלדת + if (event is KeyDownEvent) { + final isArrowKey = + event.logicalKey.keyLabel == 'Arrow Left' || + event.logicalKey.keyLabel == 'Arrow Right' || + event.logicalKey.keyLabel == 'Arrow Up' || + event.logicalKey.keyLabel == 'Arrow Down'; + + if (isArrowKey) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_searchOptionsOverlay != null) { + _updateSearchOptionsOverlay(); + } + }); + } + } }, - decoration: InputDecoration( - border: const OutlineInputBorder(), - hintText: "חפש כאן..", - labelText: "לחיפוש הקש אנטר או לחץ על סמל החיפוש", - prefixIcon: IconButton( - onPressed: () { - context.read().add(UpdateSearchQuery( - widget.widget.tab.queryController.text)); - }, - icon: const Icon(Icons.search), - ), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.widget.tab.isAdvancedSearchEnabled) - SearchOptionsDropdown( - onToggle: _toggleSearchOptions, + child: TextField( + key: _textFieldKey, + focusNode: widget.widget.tab.searchFieldFocusNode, + controller: widget.widget.tab.queryController, + onTap: () { + // עדכון המגירה כשלוחצים בשדה הטקסט + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_searchOptionsOverlay != null) { + _updateSearchOptionsOverlay(); + } + }); + }, + onChanged: (text) { + // עדכון המגירה כשהטקסט משתנה + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_searchOptionsOverlay != null) { + _updateSearchOptionsOverlay(); + } + }); + }, + onSubmitted: (e) { + context.read().add(UpdateSearchQuery(e)); + widget.widget.tab.isLeftPaneOpen.value = false; + }, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: "חפש כאן..", + labelText: "לחיפוש הקש אנטר או לחץ על סמל החיפוש", + prefixIcon: IconButton( + onPressed: () { + context.read().add(UpdateSearchQuery( + widget.widget.tab.queryController.text)); + }, + icon: const Icon(Icons.search), + ), + // החלף את כל ה-Row הקיים בזה: + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.widget.tab.isAdvancedSearchEnabled) + IconButton( + onPressed: () => _toggleSearchOptions(!_isSearchOptionsVisible), + icon: const Icon(Icons.keyboard_arrow_down), + focusNode: FocusNode( // <-- התוספת המרכזית + canRequestFocus: false, // מונע מהכפתור לבקש פוקוס + skipTraversal: true, // מדלג עליו בניווט מקלדת + ), + ), + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + widget.widget.tab.queryController.clear(); + context + .read() + .add(UpdateSearchQuery('')); + }, ), - IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - widget.widget.tab.queryController.clear(); - context.read().add(UpdateSearchQuery('')); - }, - ), - ], + ], + ), ), ), ), ), ), ), + // אזורי ריחוף על המילים - רק בחלק העליון ..._wordPositions.asMap().entries.map((entry) { final wordIndex = entry.key; final position = entry.value; return Positioned( left: position.dx - 30, - top: position.dy - 35, + top: position.dy - 47, // יותר למעלה כדי לא לחסום את שדה החיפוש child: MouseRegion( onEnter: (_) => setState(() => _hoveredWordIndex = wordIndex), onExit: (_) => setState(() => _hoveredWordIndex = null), - child: Container( - width: 60, - height: 30, - color: Colors.transparent, + child: IgnorePointer( + child: Container( + width: 60, + height: 20, // גובה קטן יותר + color: Colors.transparent, + ), ), ), ); @@ -852,3 +1037,112 @@ class _EnhancedSearchFieldState extends State { ); } } + +class _SearchOptionsContent extends StatefulWidget { + final String currentWord; + final int wordIndex; + final Map> wordOptions; + + const _SearchOptionsContent({ + super.key, + required this.currentWord, + required this.wordIndex, + required this.wordOptions, + }); + + @override + State<_SearchOptionsContent> createState() => _SearchOptionsContentState(); +} + +class _SearchOptionsContentState extends State<_SearchOptionsContent> { + // רשימת האפשרויות הזמינות + static const List _availableOptions = [ + 'קידומות', + 'סיומות', + 'קידומות דקדוקיות', + 'סיומות דקדוקיות', + 'כתיב מלא/חסר', + 'שורש', + ]; + + String get _wordKey => '${widget.currentWord}_${widget.wordIndex}'; + + Map _getCurrentWordOptions() { + // אם אין אפשרויות למילה הזו, ניצור אותן + if (!widget.wordOptions.containsKey(_wordKey)) { + widget.wordOptions[_wordKey] = + Map.fromIterable(_availableOptions, value: (_) => false); + } + + return widget.wordOptions[_wordKey]!; + } + + Widget _buildCheckbox(String option) { + final currentOptions = _getCurrentWordOptions(); + + return InkWell( + onTap: () { + setState(() { + currentOptions[option] = !currentOptions[option]!; + }); + }, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + border: Border.all( + color: currentOptions[option]! + ? Theme.of(context).primaryColor + : Colors.grey.shade600, + width: 2, + ), + borderRadius: BorderRadius.circular(3), + color: currentOptions[option]! + ? Theme.of(context).primaryColor.withValues(alpha: 0.1) + : Colors.transparent, + ), + child: currentOptions[option]! + ? Icon( + Icons.check, + size: 14, + color: Theme.of(context).primaryColor, + ) + : null, + ), + const SizedBox(width: 6), + Align( + alignment: Alignment.center, + child: Text( + option, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).textTheme.bodyMedium?.color, + height: 1.0, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 16.0, + runSpacing: 8.0, + children: + _availableOptions.map((option) => _buildCheckbox(option)).toList(), + ); + } +} diff --git a/lib/search/view/full_text_settings_widgets.dart b/lib/search/view/full_text_settings_widgets.dart index db1d8ba30..5253c2ee6 100644 --- a/lib/search/view/full_text_settings_widgets.dart +++ b/lib/search/view/full_text_settings_widgets.dart @@ -147,11 +147,54 @@ class _SearchTermsDisplayState extends State { String _getDisplayText(String originalQuery) { // כרגע נציג את הטקסט המקורי - // בעתיד נוסיף כאן לוגיקה להצגת החלופות + // בעתיד נוסיף לוגיקה להצגת החלופות // למשל: "מאימתי או מתי ו קורין או קוראין" return originalQuery; } + Widget _buildFormattedText(String text) { + print('_buildFormattedText called with: "$text"'); + if (text.trim().isEmpty) return const SizedBox.shrink(); + + final words = text.trim().split(RegExp(r'\s+')); + final List spans = []; + print('Words found: $words'); + + for (int i = 0; i < words.length; i++) { + // הוספת המילה המודגשת + spans.add( + TextSpan( + text: words[i], + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + ); + + // הוספת + בין המילים (לא אחרי המילה האחרונה) + if (i < words.length - 1) { + spans.add( + const TextSpan( + text: ' + ', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Colors.black, // שחור מודגש כמו שביקשת + ), + ), + ); + } + } + + print('Created ${spans.length} spans'); + return RichText( + text: TextSpan(children: spans), + textAlign: TextAlign.center, + ); + } + List _getWords(String text) { // פיצול הטקסט למילים return text.trim().split(RegExp(r'\s+')); @@ -241,6 +284,8 @@ class _SearchTermsDisplayState extends State { builder: (context, state) { // נציג את הטקסט הנוכחי מהקונטרולר במקום מה-state final displayText = _getDisplayText(widget.tab.queryController.text); + print( + 'SearchTermsDisplay build called with displayText: "$displayText"'); return Container( height: 52, // גובה קבוע כמו שאר הווידג'טים @@ -261,25 +306,25 @@ class _SearchTermsDisplayState extends State { alignment: Alignment.center, // ממורכז תמיד child: SizedBox( width: calculatedWidth, - child: Scrollbar( - thumbVisibility: - displayText.isNotEmpty, // מציג פס גלילה רק כשיש טקסט - child: TextField( - readOnly: true, - controller: TextEditingController(text: displayText), - textAlign: TextAlign.center, // ממרכז את הטקסט - maxLines: 1, // שורה אחת בלבד - scrollPadding: EdgeInsets.zero, // מאפשר גלילה חלקה + height: 52, // גובה קבוע כמו שאר הבקרות + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, vertical: 4.0), + child: InputDecorator( decoration: const InputDecoration( labelText: 'מילות החיפוש', border: OutlineInputBorder(), contentPadding: EdgeInsets.symmetric( horizontal: 8.0, vertical: 8.0), ), - style: const TextStyle( - fontSize: 13, // גופן קצת יותר קטן - fontWeight: FontWeight.w500, - ), + child: displayText.isEmpty + ? const SizedBox.shrink() + : SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Center( + child: _buildFormattedText(displayText), + ), + ), ), ), ), @@ -309,40 +354,20 @@ class AdvancedSearchToggle extends StatelessWidget { height: 52, // גובה קבוע כמו שאר הבקרות child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - child: Container( - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceVariant, // צבע רקע לכל המלבן - border: Border.all(color: Theme.of(context).dividerColor), - borderRadius: BorderRadius.circular(4), + child: InputDecorator( + decoration: const InputDecoration( + labelText: 'חיפוש מתקדם', + labelStyle: TextStyle(fontSize: 13), // גופן קטן יותר + border: OutlineInputBorder(), + contentPadding: + EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), ), - child: Column( - children: [ - // תווית עליונה - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - child: Text( - 'חיפוש מתקדם', - style: TextStyle( - fontSize: 10, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - ), - // הצ'קבוקס - Expanded( - child: Center( - child: Checkbox( - value: tab.isAdvancedSearchEnabled, - onChanged: (value) => onChanged(value ?? true), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - ), - ], + child: Center( + child: Checkbox( + value: tab.isAdvancedSearchEnabled, + onChanged: (value) => onChanged(value ?? true), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), ), ), ), diff --git a/lib/search/view/search_options_dropdown.dart b/lib/search/view/search_options_dropdown.dart index cc9021e1f..0b6a40408 100644 --- a/lib/search/view/search_options_dropdown.dart +++ b/lib/search/view/search_options_dropdown.dart @@ -2,15 +2,36 @@ import 'package:flutter/material.dart'; class SearchOptionsDropdown extends StatefulWidget { final Function(bool)? onToggle; - - const SearchOptionsDropdown({super.key, this.onToggle}); + final bool isExpanded; + + const SearchOptionsDropdown({ + super.key, + this.onToggle, + this.isExpanded = false, + }); @override State createState() => _SearchOptionsDropdownState(); } class _SearchOptionsDropdownState extends State { - bool _isExpanded = false; + late bool _isExpanded; + + @override + void initState() { + super.initState(); + _isExpanded = widget.isExpanded; + } + + @override + void didUpdateWidget(SearchOptionsDropdown oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isExpanded != oldWidget.isExpanded) { + setState(() { + _isExpanded = widget.isExpanded; + }); + } + } void _toggleExpanded() { setState(() { @@ -22,7 +43,8 @@ class _SearchOptionsDropdownState extends State { @override Widget build(BuildContext context) { return IconButton( - icon: Icon(_isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down), + icon: Icon( + _isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down), tooltip: 'אפשרויות חיפוש', onPressed: _toggleExpanded, ); @@ -31,28 +53,57 @@ class _SearchOptionsDropdownState extends State { class SearchOptionsRow extends StatefulWidget { final bool isVisible; + final String? currentWord; // המילה הנוכחית - const SearchOptionsRow({super.key, required this.isVisible}); + const SearchOptionsRow({ + super.key, + required this.isVisible, + this.currentWord, + }); @override State createState() => _SearchOptionsRowState(); } class _SearchOptionsRowState extends State { - final Map _options = { - 'קידומות': false, - 'סיומות': false, - 'קידומות דקדוקיות': false, - 'סיומות דקדוקיות': false, - 'כתיב מלא/חסר': false, - 'שורש': false, - }; + // מפה שמחזיקה אפשרויות לכל מילה + static final Map> _wordOptions = {}; + + // רשימת האפשרויות הזמינות + static const List _availableOptions = [ + 'קידומות', + 'סיומות', + 'קידומות דקדוקיות', + 'סיומות דקדוקיות', + 'כתיב מלא/חסר', + 'שורש', + ]; + + Map _getCurrentWordOptions() { + final currentWord = widget.currentWord; + if (currentWord == null || currentWord.isEmpty) { + return Map.fromIterable(_availableOptions, value: (_) => false); + } + + // אם אין אפשרויות למילה הזו, ניצור אותן + if (!_wordOptions.containsKey(currentWord)) { + _wordOptions[currentWord] = Map.fromIterable(_availableOptions, value: (_) => false); + } + + return _wordOptions[currentWord]!; + } Widget _buildCheckbox(String option) { + final currentOptions = _getCurrentWordOptions(); + return InkWell( onTap: () { setState(() { - _options[option] = !_options[option]!; + final currentWord = widget.currentWord; + if (currentWord != null && currentWord.isNotEmpty) { + currentOptions[option] = !currentOptions[option]!; + + } }); }, borderRadius: BorderRadius.circular(4), @@ -60,23 +111,25 @@ class _SearchOptionsRowState extends State { padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), child: Row( mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( width: 18, height: 18, decoration: BoxDecoration( border: Border.all( - color: _options[option]! + color: currentOptions[option]! ? Theme.of(context).primaryColor : Colors.grey.shade600, width: 2, ), borderRadius: BorderRadius.circular(3), - color: _options[option]! + color: currentOptions[option]! ? Theme.of(context).primaryColor.withValues(alpha: 0.1) : Colors.transparent, ), - child: _options[option]! + child: currentOptions[option]! ? Icon( Icons.check, size: 14, @@ -85,11 +138,16 @@ class _SearchOptionsRowState extends State { : null, ), const SizedBox(width: 6), - Text( - option, - style: TextStyle( - fontSize: 13, - color: Theme.of(context).textTheme.bodyMedium?.color, + Align( + alignment: Alignment.center, + child: Text( + option, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).textTheme.bodyMedium?.color, + height: 1.0, // מבטיח שהטקסט לא יהיה גבוה מדי + ), + textAlign: TextAlign.center, ), ), ], @@ -103,47 +161,50 @@ class _SearchOptionsRowState extends State { return AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, - height: widget.isVisible ? 60.0 : 0.0, + height: widget.isVisible ? 40.0 : 0.0, width: double.infinity, - child: widget.isVisible - ? ColoredBox( - color: Colors.white, // רקע אטום מלא - child: Container( - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.25), - blurRadius: 8, - offset: const Offset(0, 4), + child: widget.isVisible + ? ColoredBox( + color: Colors.white, // רקע אטום מלא + child: Container( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.25), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + border: Border( + left: BorderSide(color: Colors.grey.shade400, width: 1), + right: BorderSide(color: Colors.grey.shade400, width: 1), + bottom: BorderSide(color: Colors.grey.shade400, width: 1), ), - ], - border: Border( - left: BorderSide(color: Colors.grey.shade400, width: 1), - right: BorderSide(color: Colors.grey.shade400, width: 1), - bottom: BorderSide(color: Colors.grey.shade400, width: 1), ), - ), - child: ColoredBox( - color: Colors.white, // עוד שכבת רקע אטום - child: Material( - color: Colors.white, - child: ColoredBox( - color: Colors.white, // שכבה נוספת - child: Padding( - padding: const EdgeInsets.only(left: 48.0, right: 16.0, top: 12.0, bottom: 12.0), - child: Wrap( - spacing: 16.0, - runSpacing: 8.0, - children: _options.keys.map((option) => _buildCheckbox(option)).toList(), + child: ColoredBox( + color: Colors.white, // עוד שכבת רקע אטום + child: Material( + color: Colors.white, + child: ColoredBox( + color: Colors.white, // שכבה נוספת + child: Padding( + padding: const EdgeInsets.only( + left: 48.0, right: 16.0, top: 8.0, bottom: 8.0), + child: Wrap( + spacing: 16.0, + runSpacing: 8.0, + children: _availableOptions + .map((option) => _buildCheckbox(option)) + .toList(), + ), ), ), ), ), ), - ), - ) - : const SizedBox.shrink(), + ) + : const SizedBox.shrink(), ); } } From a78ad9116392a5126865b5f779901b9494eb0367 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 28 Jul 2025 22:05:22 +0300 Subject: [PATCH 036/197] =?UTF-8?q?=D7=94=D7=A6=D7=92=D7=AA=20=D7=90=D7=A4?= =?UTF-8?q?=D7=A9=D7=A8=D7=95=D7=99=D7=95=D7=AA=20=D7=94=D7=97=D7=99=D7=A4?= =?UTF-8?q?=D7=95=D7=A9=20=D7=91=D7=AA=D7=95=D7=9A=20=D7=A9=D7=93=D7=94=20?= =?UTF-8?q?=D7=9E=D7=99=D7=9C=D7=95=D7=AA=20=D7=94=D7=97=D7=99=D7=A4=D7=95?= =?UTF-8?q?=D7=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/enhanced_search_field.dart | 121 ++++++------ .../view/full_text_settings_widgets.dart | 184 ++++++++++++++---- lib/tabs/models/searching_tab.dart | 7 + 3 files changed, 223 insertions(+), 89 deletions(-) diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index f59c724e9..7f4f6212d 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -6,7 +6,6 @@ import 'package:otzaria/search/bloc/search_bloc.dart'; import 'package:otzaria/search/bloc/search_event.dart'; import 'package:otzaria/search/models/search_terms_model.dart'; import 'package:otzaria/search/view/tantivy_full_text_search.dart'; -import 'package:otzaria/search/view/search_options_dropdown.dart'; // הווידג'ט החדש לניהול מצבי הכפתור class _PlusButton extends StatefulWidget { @@ -353,9 +352,6 @@ class _EnhancedSearchFieldState extends State { OverlayEntry? _searchOptionsOverlay; int? _hoveredWordIndex; - // מפה שמחזיקה אפשרויות לכל מילה (לא static כדי שתתנקה כשהווידג'ט נהרס) - final Map> _wordOptions = {}; - final Map _spacingOverlays = {}; final Map _spacingControllers = {}; @@ -391,30 +387,30 @@ class _EnhancedSearchFieldState extends State { .removeListener(_onCursorPositionChanged); _disposeControllers(); // ניקוי אפשרויות החיפוש כשסוגרים את המסך - _wordOptions.clear(); + widget.widget.tab.searchOptions.clear(); super.dispose(); } -void _clearAllOverlays({bool keepSearchDrawer = false}) { - // ניקוי אלטרנטיבות ומרווחים - for (final entries in _alternativeOverlays.values) { - for (final entry in entries) { + void _clearAllOverlays({bool keepSearchDrawer = false}) { + // ניקוי אלטרנטיבות ומרווחים + for (final entries in _alternativeOverlays.values) { + for (final entry in entries) { + entry.remove(); + } + } + _alternativeOverlays.clear(); + + for (final entry in _spacingOverlays.values) { entry.remove(); } - } - _alternativeOverlays.clear(); - - for (final entry in _spacingOverlays.values) { - entry.remove(); - } - _spacingOverlays.clear(); + _spacingOverlays.clear(); - // סגירת מגירת האפשרויות רק אם לא ביקשנו לשמור אותה - if (!keepSearchDrawer) { - _searchOptionsOverlay?.remove(); - _searchOptionsOverlay = null; + // סגירת מגירת האפשרויות רק אם לא ביקשנו לשמור אותה + if (!keepSearchDrawer) { + _searchOptionsOverlay?.remove(); + _searchOptionsOverlay = null; + } } -} void _disposeControllers() { for (final controllers in _alternativeControllers.values) { @@ -429,43 +425,43 @@ void _clearAllOverlays({bool keepSearchDrawer = false}) { _spacingControllers.clear(); } -void _onTextChanged() { - // בודקים אם המגירה הייתה פתוחה לפני השינוי - final bool drawerWasOpen = _searchOptionsOverlay != null; + void _onTextChanged() { + // בודקים אם המגירה הייתה פתוחה לפני השינוי + final bool drawerWasOpen = _searchOptionsOverlay != null; - // מנקים את כל הבועות, אבל משאירים את המגירה פתוחה אם היא הייתה פתוחה - _clearAllOverlays(keepSearchDrawer: drawerWasOpen); + // מנקים את כל הבועות, אבל משאירים את המגירה פתוחה אם היא הייתה פתוחה + _clearAllOverlays(keepSearchDrawer: drawerWasOpen); - final text = widget.widget.tab.queryController.text; + final text = widget.widget.tab.queryController.text; - // אם שדה החיפוש התרוקן, נסגור את המגירה בכל זאת - if (text.trim().isEmpty && drawerWasOpen) { - _hideSearchOptionsOverlay(); - _notifyDropdownClosed(); - // יוצאים מהפונקציה כדי לא להמשיך - return; - } + // אם שדה החיפוש התרוקן, נסגור את המגירה בכל זאת + if (text.trim().isEmpty && drawerWasOpen) { + _hideSearchOptionsOverlay(); + _notifyDropdownClosed(); + // יוצאים מהפונקציה כדי לא להמשיך + return; + } - setState(() { - _searchQuery = SearchQuery.fromString(text); - _updateAlternativeControllers(); - }); + setState(() { + _searchQuery = SearchQuery.fromString(text); + _updateAlternativeControllers(); + }); - WidgetsBinding.instance.addPostFrameCallback((_) { - _calculateWordPositions(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _calculateWordPositions(); - for (int i = 0; i < _searchQuery.terms.length; i++) { - for (int j = 0; j < _searchQuery.terms[i].alternatives.length; j++) { - _showAlternativeOverlay(i, j); + for (int i = 0; i < _searchQuery.terms.length; i++) { + for (int j = 0; j < _searchQuery.terms[i].alternatives.length; j++) { + _showAlternativeOverlay(i, j); + } } - } - // אם המגירה הייתה פתוחה, מרעננים את התוכן שלה - if (drawerWasOpen) { - _updateSearchOptionsOverlay(); - } - }); -} + // אם המגירה הייתה פתוחה, מרעננים את התוכן שלה + if (drawerWasOpen) { + _updateSearchOptionsOverlay(); + } + }); + } void _onCursorPositionChanged() { // עדכון המגירה כשהסמן זז (אם היא פתוחה) @@ -855,7 +851,8 @@ void _onTextChanged() { return _SearchOptionsContent( currentWord: wordInfo['word'], wordIndex: wordInfo['index'], - wordOptions: _wordOptions, + wordOptions: widget.widget.tab.searchOptions, + onOptionsChanged: _onSearchOptionsChanged, key: ValueKey( '${wordInfo['word']}_${wordInfo['index']}'), // מפתח ייחודי לעדכון ); @@ -905,6 +902,16 @@ void _onTextChanged() { bool get _isSearchOptionsVisible => _searchOptionsOverlay != null; + void _onSearchOptionsChanged() { + // עדכון התצוגה כשמשתמש משנה אפשרויות + setState(() { + // זה יגרום לעדכון של התצוגה + }); + + // עדכון ה-notifier כדי שהתצוגה של מילות החיפוש תתעדכן + widget.widget.tab.searchOptionsChanged.value++; + } + @override Widget build(BuildContext context) { return Stack( @@ -981,11 +988,13 @@ void _onTextChanged() { children: [ if (widget.widget.tab.isAdvancedSearchEnabled) IconButton( - onPressed: () => _toggleSearchOptions(!_isSearchOptionsVisible), + onPressed: () => + _toggleSearchOptions(!_isSearchOptionsVisible), icon: const Icon(Icons.keyboard_arrow_down), - focusNode: FocusNode( // <-- התוספת המרכזית - canRequestFocus: false, // מונע מהכפתור לבקש פוקוס - skipTraversal: true, // מדלג עליו בניווט מקלדת + focusNode: FocusNode( + // <-- התוספת המרכזית + canRequestFocus: false, // מונע מהכפתור לבקש פוקוס + skipTraversal: true, // מדלג עליו בניווט מקלדת ), ), IconButton( @@ -1042,12 +1051,14 @@ class _SearchOptionsContent extends StatefulWidget { final String currentWord; final int wordIndex; final Map> wordOptions; + final VoidCallback? onOptionsChanged; const _SearchOptionsContent({ super.key, required this.currentWord, required this.wordIndex, required this.wordOptions, + this.onOptionsChanged, }); @override diff --git a/lib/search/view/full_text_settings_widgets.dart b/lib/search/view/full_text_settings_widgets.dart index 5253c2ee6..188567d44 100644 --- a/lib/search/view/full_text_settings_widgets.dart +++ b/lib/search/view/full_text_settings_widgets.dart @@ -131,47 +131,137 @@ class _SearchTermsDisplayState extends State { super.initState(); // מאזין לשינויים בקונטרולר widget.tab.queryController.addListener(_onTextChanged); + // מאזין לשינויים באפשרויות החיפוש + _listenToSearchOptions(); } - @override - void dispose() { - widget.tab.queryController.removeListener(_onTextChanged); - super.dispose(); + void _listenToSearchOptions() { + // מאזין לשינויים באפשרויות החיפוש + widget.tab.searchOptionsChanged.addListener(_onSearchOptionsChanged); } - void _onTextChanged() { + void _onSearchOptionsChanged() { + // עדכון התצוגה כשמשתמש משנה אפשרויות setState(() { - // עדכון התצוגה כשהטקסט משתנה + // זה יגרום לעדכון של התצוגה }); } - String _getDisplayText(String originalQuery) { - // כרגע נציג את הטקסט המקורי - // בעתיד נוסיף לוגיקה להצגת החלופות - // למשל: "מאימתי או מתי ו קורין או קוראין" - return originalQuery; + double _calculateFormattedTextWidth(String text) { + if (text.trim().isEmpty) return 0; + + // יצירת TextSpan עם הטקסט המעוצב + final spans = _buildFormattedTextSpans(text); + + // שימוש ב-TextPainter למדידת הרוחב האמיתי + final textPainter = TextPainter( + text: TextSpan(children: spans), + textDirection: TextDirection.rtl, + ); + + textPainter.layout(); + return textPainter.width; } - Widget _buildFormattedText(String text) { - print('_buildFormattedText called with: "$text"'); - if (text.trim().isEmpty) return const SizedBox.shrink(); + List _buildFormattedTextSpans(String text) { + if (text.trim().isEmpty) return [const TextSpan(text: '')]; final words = text.trim().split(RegExp(r'\s+')); final List spans = []; - print('Words found: $words'); + + // מיפוי אפשרויות לקיצורים + const Map optionAbbreviations = { + 'קידומות': 'ק', + 'סיומות': 'ס', + 'קידומות דקדוקיות': 'קד', + 'סיומות דקדוקיות': 'סד', + 'כתיב מלא/חסר': 'מח', + 'שורש': 'ש', + }; + + // אפשרויות שמופיעות אחרי המילה (סיומות) + const Set suffixOptions = { + 'סיומות', + 'סיומות דקדוקיות', + }; for (int i = 0; i < words.length; i++) { - // הוספת המילה המודגשת - spans.add( - TextSpan( - text: words[i], - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: Colors.black, + final word = words[i]; + final wordKey = '${word}_$i'; + + // בדיקה אם יש אפשרויות למילה הזו + final wordOptions = widget.tab.searchOptions[wordKey]; + final selectedOptions = wordOptions?.entries + .where((entry) => entry.value) + .map((entry) => entry.key) + .toList() ?? []; + + if (selectedOptions.isNotEmpty) { + // הפרדה בין קידומות לסיומות + final prefixes = selectedOptions + .where((opt) => !suffixOptions.contains(opt)) + .map((opt) => optionAbbreviations[opt] ?? opt) + .toList(); + + final suffixes = selectedOptions + .where((opt) => suffixOptions.contains(opt)) + .map((opt) => optionAbbreviations[opt] ?? opt) + .toList(); + + // הוספת קידומות לפני המילה + if (prefixes.isNotEmpty) { + spans.add( + TextSpan( + text: '(${prefixes.join(',')})', + style: const TextStyle( + fontSize: 10, // גופן קטן יותר לקיצורים + fontWeight: FontWeight.normal, + color: Colors.blue, + ), + ), + ); + spans.add(const TextSpan(text: ' ')); + } + + // הוספת המילה המודגשת + spans.add( + TextSpan( + text: word, + style: const TextStyle( + fontSize: 16, // גופן גדול יותר למילים + fontWeight: FontWeight.bold, + color: Colors.black, + ), ), - ), - ); + ); + + // הוספת סיומות אחרי המילה + if (suffixes.isNotEmpty) { + spans.add(const TextSpan(text: ' ')); + spans.add( + TextSpan( + text: '(${suffixes.join(',')})', + style: const TextStyle( + fontSize: 10, // גופן קטן יותר לקיצורים + fontWeight: FontWeight.normal, + color: Colors.blue, + ), + ), + ); + } + } else { + // אין אפשרויות - רק המילה המודגשת + spans.add( + TextSpan( + text: word, + style: const TextStyle( + fontSize: 16, // גופן גדול יותר למילים + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + ); + } // הוספת + בין המילים (לא אחרי המילה האחרונה) if (i < words.length - 1) { @@ -179,16 +269,42 @@ class _SearchTermsDisplayState extends State { const TextSpan( text: ' + ', style: TextStyle( - fontSize: 13, + fontSize: 16, // גופן גדול יותר ל-+ fontWeight: FontWeight.bold, - color: Colors.black, // שחור מודגש כמו שביקשת + color: Colors.black, ), ), ); } } - print('Created ${spans.length} spans'); + return spans; + } + + @override + void dispose() { + widget.tab.queryController.removeListener(_onTextChanged); + widget.tab.searchOptionsChanged.removeListener(_onSearchOptionsChanged); + super.dispose(); + } + + void _onTextChanged() { + setState(() { + // עדכון התצוגה כשהטקסט משתנה + }); + } + + String _getDisplayText(String originalQuery) { + // כרגע נציג את הטקסט המקורי + // בעתיד נוסיף לוגיקה להצגת החלופות + // למשל: "מאימתי או מתי ו קורין או קוראין" + return originalQuery; + } + + Widget _buildFormattedText(String text) { + if (text.trim().isEmpty) return const SizedBox.shrink(); + + final spans = _buildFormattedTextSpans(text); return RichText( text: TextSpan(children: spans), textAlign: TextAlign.center, @@ -292,15 +408,15 @@ class _SearchTermsDisplayState extends State { padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), child: LayoutBuilder( builder: (context, constraints) { - // חישוב רוחב דינמי בהתבסס על אורך הטקסט - final textLength = displayText.length; + // חישוב רוחב דינמי בהתבסס על אורך הטקסט המעוצב const minWidth = 120.0; // רוחב מינימלי גדול יותר final maxWidth = constraints.maxWidth; // כל הרוחב הזמין - // חישוב רוחב בהתבסס על אורך הטקסט - final calculatedWidth = textLength == 0 + // חישוב רוחב בהתבסס על הרוחב האמיתי של הטקסט המעוצב + final formattedTextWidth = _calculateFormattedTextWidth(displayText); + final calculatedWidth = formattedTextWidth == 0 ? minWidth - : (textLength * 8.0 + 40).clamp(minWidth, maxWidth); + : (formattedTextWidth + 40).clamp(minWidth, maxWidth); // מרווח מותאם לגופן הגדול return Align( alignment: Alignment.center, // ממורכז תמיד @@ -315,7 +431,7 @@ class _SearchTermsDisplayState extends State { labelText: 'מילות החיפוש', border: OutlineInputBorder(), contentPadding: EdgeInsets.symmetric( - horizontal: 8.0, vertical: 8.0), + horizontal: 8.0, vertical: 4.0), // פחות padding אנכי ), child: displayText.isEmpty ? const SizedBox.shrink() diff --git a/lib/tabs/models/searching_tab.dart b/lib/tabs/models/searching_tab.dart index 5d4b0a0aa..bca64ba41 100644 --- a/lib/tabs/models/searching_tab.dart +++ b/lib/tabs/models/searching_tab.dart @@ -15,6 +15,12 @@ class SearchingTab extends OpenedTab { // מצב חיפוש מתקדם bool isAdvancedSearchEnabled = true; + + // אפשרויות חיפוש לכל מילה (מילה_אינדקס -> אפשרויות) + final Map> searchOptions = {}; + + // notifier לעדכון התצוגה כשמשתמש משנה אפשרויות + final ValueNotifier searchOptionsChanged = ValueNotifier(0); SearchingTab( super.title, @@ -33,6 +39,7 @@ class SearchingTab extends OpenedTab { @override void dispose() { searchFieldFocusNode.dispose(); + searchOptionsChanged.dispose(); super.dispose(); } From 8e8e0cff56599915bae754a6e90b5230cc0a492f Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 28 Jul 2025 23:34:35 +0300 Subject: [PATCH 037/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=A9?= =?UTF-8?q?=D7=93=D7=94=20"=D7=90=D7=95"=20=D7=9C=D7=AA=D7=95=D7=9A=20?= =?UTF-8?q?=D7=9E=D7=99=D7=9C=D7=95=D7=AA=20=D7=94=D7=97=D7=99=D7=A4=D7=95?= =?UTF-8?q?=D7=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/enhanced_search_field.dart | 149 +++++--- .../view/full_text_settings_widgets.dart | 317 +++++++----------- lib/tabs/models/searching_tab.dart | 13 +- 3 files changed, 239 insertions(+), 240 deletions(-) diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 7f4f6212d..290b44d45 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -256,6 +256,12 @@ class _AlternativeFieldState extends State<_AlternativeField> { } }); _focus.addListener(_onFocusChanged); + // הוספת listener לשינויי טקסט כדי לעדכן את ה-opacity + widget.controller.addListener(_onTextChanged); + } + + void _onTextChanged() { + setState(() {}); // עדכון המצב לשינוי opacity } void _onFocusChanged() { @@ -269,61 +275,75 @@ class _AlternativeFieldState extends State<_AlternativeField> { @override void dispose() { _focus.removeListener(_onFocusChanged); + widget.controller.removeListener(_onTextChanged); _focus.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - return Container( - width: 160, - height: 40, - decoration: BoxDecoration( - color: Theme.of(context).scaffoldBackgroundColor, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: _focus.hasFocus - ? Theme.of(context).primaryColor - : Theme.of(context).dividerColor, - width: _focus.hasFocus ? 1.5 : 1.0, - ), - boxShadow: [ - BoxShadow( - color: - Colors.black.withValues(alpha: _focus.hasFocus ? 0.15 : 0.08), - blurRadius: _focus.hasFocus ? 6 : 3, - offset: const Offset(0, 2), + final bool hasText = widget.controller.text.trim().isNotEmpty; + final bool isInactive = !_focus.hasFocus && hasText; + + return AnimatedOpacity( + opacity: isInactive ? 0.5 : 1.0, // חצי שקופה כשלא בפוקוס ויש טקסט + duration: const Duration(milliseconds: 200), + child: Container( + width: 70, // הצרה עוד יותר - מ-120 ל-100 + height: 40, + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _focus.hasFocus + ? Theme.of(context).primaryColor + : Theme.of(context).dividerColor, + width: _focus.hasFocus ? 1.5 : 1.0, ), - ], - ), - clipBehavior: Clip.antiAlias, - child: Material( - type: MaterialType.transparency, - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.close, size: 16), - onPressed: widget.onRemove, - splashRadius: 18, + boxShadow: [ + BoxShadow( + color: + Colors.black.withValues(alpha: _focus.hasFocus ? 0.15 : 0.08), + blurRadius: _focus.hasFocus ? 6 : 3, + offset: const Offset(0, 2), ), - Expanded( - child: TextField( - controller: widget.controller, - focusNode: _focus, - decoration: const InputDecoration( - hintText: 'מילה חילופית', - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.only(right: 8, bottom: 4), + ], + ), + clipBehavior: Clip.antiAlias, + child: Material( + type: MaterialType.transparency, + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.close, size: 14), + onPressed: widget.onRemove, + splashRadius: 16, + padding: const EdgeInsets.only(left: 4, right: 2), + constraints: const BoxConstraints(), + ), + Expanded( + child: TextField( + controller: widget.controller, + focusNode: _focus, + inputFormatters: [ + // הגבלה למילה אחת - מניעת רווחים + FilteringTextInputFormatter.deny(RegExp(r'\s')), + ], + decoration: const InputDecoration( + hintText: 'מילה חילופית', + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.only(right: 4, bottom: 4), + ), + style: const TextStyle(fontSize: 12, color: Colors.black87), + textAlign: TextAlign.right, + onSubmitted: (v) { + if (v.trim().isEmpty) widget.onRemove(); + }, ), - style: const TextStyle(fontSize: 12, color: Colors.black87), - textAlign: TextAlign.right, - onSubmitted: (v) { - if (v.trim().isEmpty) widget.onRemove(); - }, ), - ), - ], + ], + ), ), ), ); @@ -484,9 +504,12 @@ class _EnhancedSearchFieldState extends State { _disposeControllers(); for (int i = 0; i < _searchQuery.terms.length; i++) { final term = _searchQuery.terms[i]; - _alternativeControllers[i] = term.alternatives - .map((alt) => TextEditingController(text: alt)) - .toList(); + _alternativeControllers[i] = term.alternatives.map((alt) { + final controller = TextEditingController(text: alt); + // הוספת listener לעדכון המידע ב-tab כשהטקסט משתנה + controller.addListener(() => _updateAlternativeWordsInTab()); + return controller; + }).toList(); } } @@ -563,9 +586,13 @@ class _EnhancedSearchFieldState extends State { return; } final newIndex = _alternativeControllers[termIndex]!.length; - _alternativeControllers[termIndex]!.add(TextEditingController()); + final controller = TextEditingController(); + // הוספת listener לעדכון המידע ב-tab כשהטקסט משתנה + controller.addListener(() => _updateAlternativeWordsInTab()); + _alternativeControllers[termIndex]!.add(controller); _showAlternativeOverlay(termIndex, newIndex); }); + _updateAlternativeWordsInTab(); } void _removeAlternative(int termIndex, int altIndex) { @@ -582,6 +609,8 @@ class _EnhancedSearchFieldState extends State { } _refreshAlternativeOverlays(termIndex); }); + // עדכון המידע ב-tab אחרי הסרת החלופה + _updateAlternativeWordsInTab(); } void _checkAndRemoveEmptyField(int termIndex, int altIndex) { @@ -615,7 +644,8 @@ class _EnhancedSearchFieldState extends State { final entry = OverlayEntry( builder: (context) { return Positioned( - left: overlayPosition.dx - 80, + left: overlayPosition.dx - + 35, // מרכוז התיבה (70/2 = 35) מתחת לכפתור ה-+ top: overlayPosition.dy + 15 + (altIndex * 45.0), child: _AlternativeField( controller: controller, @@ -912,6 +942,23 @@ class _EnhancedSearchFieldState extends State { widget.widget.tab.searchOptionsChanged.value++; } + void _updateAlternativeWordsInTab() { + // עדכון המילים החילופיות ב-tab + widget.widget.tab.alternativeWords.clear(); + for (int termIndex in _alternativeControllers.keys) { + final alternatives = _alternativeControllers[termIndex]! + .map((controller) => controller.text.trim()) + .where((text) => text.isNotEmpty) + .toList(); + if (alternatives.isNotEmpty) { + widget.widget.tab.alternativeWords[termIndex] = alternatives; + } + } + // עדכון התצוגה + widget.widget.tab.alternativeWordsChanged.value++; + widget.widget.tab.searchOptionsChanged.value++; + } + @override Widget build(BuildContext context) { return Stack( @@ -1096,6 +1143,8 @@ class _SearchOptionsContentState extends State<_SearchOptionsContent> { setState(() { currentOptions[option] = !currentOptions[option]!; }); + // עדכון מיידי של התצוגה + widget.onOptionsChanged?.call(); }, borderRadius: BorderRadius.circular(4), child: Padding( diff --git a/lib/search/view/full_text_settings_widgets.dart b/lib/search/view/full_text_settings_widgets.dart index 188567d44..27549d97b 100644 --- a/lib/search/view/full_text_settings_widgets.dart +++ b/lib/search/view/full_text_settings_widgets.dart @@ -124,8 +124,6 @@ class SearchTermsDisplay extends StatefulWidget { } class _SearchTermsDisplayState extends State { - int? _hoveredWordIndex; - @override void initState() { super.initState(); @@ -138,6 +136,8 @@ class _SearchTermsDisplayState extends State { void _listenToSearchOptions() { // מאזין לשינויים באפשרויות החיפוש widget.tab.searchOptionsChanged.addListener(_onSearchOptionsChanged); + // מאזין לשינויים במילים החילופיות + widget.tab.alternativeWordsChanged.addListener(_onAlternativeWordsChanged); } void _onSearchOptionsChanged() { @@ -147,23 +147,30 @@ class _SearchTermsDisplayState extends State { }); } - double _calculateFormattedTextWidth(String text) { + void _onAlternativeWordsChanged() { + // עדכון התצוגה כשמשתמש משנה מילים חילופיות + setState(() { + // זה יגרום לעדכון של התצוגה + }); + } + + double _calculateFormattedTextWidth(String text, BuildContext context) { if (text.trim().isEmpty) return 0; // יצירת TextSpan עם הטקסט המעוצב - final spans = _buildFormattedTextSpans(text); - + final spans = _buildFormattedTextSpans(text, context); + // שימוש ב-TextPainter למדידת הרוחב האמיתי final textPainter = TextPainter( text: TextSpan(children: spans), textDirection: TextDirection.rtl, ); - + textPainter.layout(); return textPainter.width; } - List _buildFormattedTextSpans(String text) { + List _buildFormattedTextSpans(String text, BuildContext context) { if (text.trim().isEmpty) return [const TextSpan(text: '')]; final words = text.trim().split(RegExp(r'\s+')); @@ -188,76 +195,97 @@ class _SearchTermsDisplayState extends State { for (int i = 0; i < words.length; i++) { final word = words[i]; final wordKey = '${word}_$i'; - + // בדיקה אם יש אפשרויות למילה הזו final wordOptions = widget.tab.searchOptions[wordKey]; final selectedOptions = wordOptions?.entries - .where((entry) => entry.value) - .map((entry) => entry.key) - .toList() ?? []; - - if (selectedOptions.isNotEmpty) { - // הפרדה בין קידומות לסיומות - final prefixes = selectedOptions - .where((opt) => !suffixOptions.contains(opt)) - .map((opt) => optionAbbreviations[opt] ?? opt) - .toList(); - - final suffixes = selectedOptions - .where((opt) => suffixOptions.contains(opt)) - .map((opt) => optionAbbreviations[opt] ?? opt) - .toList(); - - // הוספת קידומות לפני המילה - if (prefixes.isNotEmpty) { + .where((entry) => entry.value) + .map((entry) => entry.key) + .toList() ?? + []; + + // בדיקה אם יש מילים חילופיות למילה הזו + final alternativeWords = widget.tab.alternativeWords[i] ?? []; + + // הפרדה בין קידומות לסיומות + final prefixes = selectedOptions + .where((opt) => !suffixOptions.contains(opt)) + .map((opt) => optionAbbreviations[opt] ?? opt) + .toList(); + + final suffixes = selectedOptions + .where((opt) => suffixOptions.contains(opt)) + .map((opt) => optionAbbreviations[opt] ?? opt) + .toList(); + + // הוספת קידומות לפני המילה + if (prefixes.isNotEmpty) { + spans.add( + TextSpan( + text: '(${prefixes.join(',')})', + style: TextStyle( + fontSize: 10, // גופן קטן יותר לקיצורים + fontWeight: FontWeight.normal, + color: Theme.of(context).primaryColor, + ), + ), + ); + spans.add(const TextSpan(text: ' ')); + } + + // הוספת המילה המודגשת + spans.add( + TextSpan( + text: word, + style: const TextStyle( + fontSize: 16, // גופן גדול יותר למילים + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + ); + + // הוספת מילים חילופיות אם יש + if (alternativeWords.isNotEmpty) { + for (final altWord in alternativeWords) { + // הוספת "או" בצבע הסיומות + spans.add(const TextSpan(text: ' ')); spans.add( TextSpan( - text: '(${prefixes.join(',')})', - style: const TextStyle( - fontSize: 10, // גופן קטן יותר לקיצורים + text: 'או', + style: TextStyle( + fontSize: 12, fontWeight: FontWeight.normal, - color: Colors.blue, + color: Theme.of(context).primaryColor, ), ), ); spans.add(const TextSpan(text: ' ')); - } - - // הוספת המילה המודגשת - spans.add( - TextSpan( - text: word, - style: const TextStyle( - fontSize: 16, // גופן גדול יותר למילים - fontWeight: FontWeight.bold, - color: Colors.black, - ), - ), - ); - - // הוספת סיומות אחרי המילה - if (suffixes.isNotEmpty) { - spans.add(const TextSpan(text: ' ')); + + // הוספת המילה החילופית המודגשת spans.add( TextSpan( - text: '(${suffixes.join(',')})', + text: altWord, style: const TextStyle( - fontSize: 10, // גופן קטן יותר לקיצורים - fontWeight: FontWeight.normal, - color: Colors.blue, + fontSize: 16, // גופן גדול יותר למילים + fontWeight: FontWeight.bold, + color: Colors.black, ), ), ); } - } else { - // אין אפשרויות - רק המילה המודגשת + } + + // הוספת סיומות אחרי המילה (והמילים החילופיות) + if (suffixes.isNotEmpty) { + spans.add(const TextSpan(text: ' ')); spans.add( TextSpan( - text: word, - style: const TextStyle( - fontSize: 16, // גופן גדול יותר למילים - fontWeight: FontWeight.bold, - color: Colors.black, + text: '(${suffixes.join(',')})', + style: TextStyle( + fontSize: 10, // גופן קטן יותר לקיצורים + fontWeight: FontWeight.normal, + color: Theme.of(context).primaryColor, ), ), ); @@ -285,6 +313,8 @@ class _SearchTermsDisplayState extends State { void dispose() { widget.tab.queryController.removeListener(_onTextChanged); widget.tab.searchOptionsChanged.removeListener(_onSearchOptionsChanged); + widget.tab.alternativeWordsChanged + .removeListener(_onAlternativeWordsChanged); super.dispose(); } @@ -301,152 +331,65 @@ class _SearchTermsDisplayState extends State { return originalQuery; } - Widget _buildFormattedText(String text) { + Widget _buildFormattedText(String text, BuildContext context) { if (text.trim().isEmpty) return const SizedBox.shrink(); - final spans = _buildFormattedTextSpans(text); + final spans = _buildFormattedTextSpans(text, context); return RichText( text: TextSpan(children: spans), textAlign: TextAlign.center, ); } - List _getWords(String text) { - // פיצול הטקסט למילים - return text.trim().split(RegExp(r'\s+')); - } - - Widget _buildSpacingButton( - {required VoidCallback onPressed, required bool isLeft}) { - return SizedBox( - width: 20, - height: 20, - child: IconButton( - padding: EdgeInsets.zero, - iconSize: 12, - onPressed: onPressed, - icon: Icon( - isLeft ? Icons.keyboard_arrow_left : Icons.keyboard_arrow_right, - color: Colors.blue, - ), - style: IconButton.styleFrom( - backgroundColor: Colors.blue.withOpacity(0.1), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), - ), - ), - ); - } - - Widget _buildWordWithSpacing(String word, int index, int totalWords) { - final isFirst = index == 0; - final isLast = index == totalWords - 1; - final isHovered = _hoveredWordIndex == index; - - return MouseRegion( - onEnter: (_) => setState(() => _hoveredWordIndex = index), - onExit: (_) => setState(() => _hoveredWordIndex = null), - child: Container( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // כפתור שמאלי (רק למילים שאינן ראשונות או כשמרחפים) - if (!isFirst && isHovered) - _buildSpacingButton( - onPressed: () { - // כאן נוסיף לוגיקה להוספת מרווח - print('Add spacing before word: $word'); - }, - isLeft: true, - ), - - // המילה עצמה - Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - decoration: isHovered - ? BoxDecoration( - color: Colors.blue.withOpacity(0.1), - borderRadius: BorderRadius.circular(4), - ) - : null, - child: Text( - word, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ), - - // כפתור ימני (רק למילים שאינן אחרונות או כשמרחפים) - if (!isLast && isHovered) - _buildSpacingButton( - onPressed: () { - // כאן נוסיף לוגיקה להוספת מרווח - print('Add spacing after word: $word'); - }, - isLeft: false, - ), - ], - ), - ), - ); - } - @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { // נציג את הטקסט הנוכחי מהקונטרולר במקום מה-state final displayText = _getDisplayText(widget.tab.queryController.text); - print( - 'SearchTermsDisplay build called with displayText: "$displayText"'); - - return Container( - height: 52, // גובה קבוע כמו שאר הווידג'טים - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - child: LayoutBuilder( - builder: (context, constraints) { - // חישוב רוחב דינמי בהתבסס על אורך הטקסט המעוצב - const minWidth = 120.0; // רוחב מינימלי גדול יותר - final maxWidth = constraints.maxWidth; // כל הרוחב הזמין - - // חישוב רוחב בהתבסס על הרוחב האמיתי של הטקסט המעוצב - final formattedTextWidth = _calculateFormattedTextWidth(displayText); - final calculatedWidth = formattedTextWidth == 0 - ? minWidth - : (formattedTextWidth + 40).clamp(minWidth, maxWidth); // מרווח מותאם לגופן הגדול - - return Align( - alignment: Alignment.center, // ממורכז תמיד - child: SizedBox( - width: calculatedWidth, - height: 52, // גובה קבוע כמו שאר הבקרות - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, vertical: 4.0), - child: InputDecorator( - decoration: const InputDecoration( - labelText: 'מילות החיפוש', - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric( - horizontal: 8.0, vertical: 4.0), // פחות padding אנכי - ), - child: displayText.isEmpty - ? const SizedBox.shrink() - : SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Center( - child: _buildFormattedText(displayText), - ), - ), + + return LayoutBuilder( + builder: (context, constraints) { + // חישוב רוחב דינמי בהתבסס על אורך הטקסט המעוצב + const minWidth = 120.0; // רוחב מינימלי גדול יותר + final maxWidth = constraints.maxWidth; // כל הרוחב הזמין + + // חישוב רוחב בהתבסס על הרוחב האמיתי של הטקסט המעוצב + final formattedTextWidth = + _calculateFormattedTextWidth(displayText, context); + final calculatedWidth = formattedTextWidth == 0 + ? minWidth + : (formattedTextWidth + 40) + .clamp(minWidth, maxWidth); // מרווח מותאם לגופן הגדול + + return Align( + alignment: Alignment.center, // ממורכז תמיד + child: SizedBox( + width: calculatedWidth, + height: 52, // גובה קבוע כמו שאר הבקרות + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, vertical: 4.0), + child: InputDecorator( + decoration: const InputDecoration( + labelText: 'מילות החיפוש', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric( + horizontal: 8.0, vertical: 8.0), // padding מותאם ), + child: displayText.isEmpty + ? const SizedBox.shrink() + : SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Center( + child: _buildFormattedText(displayText, context), + ), + ), ), ), - ); - }, - ), + ), + ); + }, ); }, ); diff --git a/lib/tabs/models/searching_tab.dart b/lib/tabs/models/searching_tab.dart index bca64ba41..693ca1ee7 100644 --- a/lib/tabs/models/searching_tab.dart +++ b/lib/tabs/models/searching_tab.dart @@ -12,16 +12,22 @@ class SearchingTab extends OpenedTab { final ValueNotifier isLeftPaneOpen = ValueNotifier(true); final ItemScrollController scrollController = ItemScrollController(); List allBooks = []; - + // מצב חיפוש מתקדם bool isAdvancedSearchEnabled = true; - + // אפשרויות חיפוש לכל מילה (מילה_אינדקס -> אפשרויות) final Map> searchOptions = {}; - + + // מילים חילופיות לכל מילה (אינדקס_מילה -> רשימת מילים חילופיות) + final Map> alternativeWords = {}; + // notifier לעדכון התצוגה כשמשתמש משנה אפשרויות final ValueNotifier searchOptionsChanged = ValueNotifier(0); + // notifier לעדכון התצוגה כשמשתמש משנה מילים חילופיות + final ValueNotifier alternativeWordsChanged = ValueNotifier(0); + SearchingTab( super.title, String? searchText, @@ -40,6 +46,7 @@ class SearchingTab extends OpenedTab { void dispose() { searchFieldFocusNode.dispose(); searchOptionsChanged.dispose(); + alternativeWordsChanged.dispose(); super.dispose(); } From d711676fe06e5a0d7c724a1dc68f108abebffffd Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 29 Jul 2025 00:57:17 +0300 Subject: [PATCH 038/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20"?= =?UTF-8?q?=D7=9E=D7=A8=D7=95=D7=95=D7=97=20=D7=91=D7=99=D7=9F=20=D7=9E?= =?UTF-8?q?=D7=99=D7=9C=D7=99=D7=9D"=20=D7=9C=D7=9E=D7=99=D7=9C=D7=95?= =?UTF-8?q?=D7=AA=20=D7=94=D7=97=D7=99=D7=A4=D7=95=D7=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/enhanced_search_field.dart | 143 +++++++++++------- .../view/full_text_settings_widgets.dart | 72 +++++++-- lib/tabs/models/searching_tab.dart | 3 + 3 files changed, 154 insertions(+), 64 deletions(-) diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 290b44d45..77ef4f1da 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -148,6 +148,12 @@ class _SpacingFieldState extends State<_SpacingField> { } }); _focus.addListener(_onFocusChanged); + // הוספת listener לשינויי טקסט כדי לעדכן את ה-opacity + widget.controller.addListener(_onTextChanged); + } + + void _onTextChanged() { + setState(() {}); // עדכון המצב לשינוי opacity } void _onFocusChanged() { @@ -161,68 +167,76 @@ class _SpacingFieldState extends State<_SpacingField> { @override void dispose() { _focus.removeListener(_onFocusChanged); + widget.controller.removeListener(_onTextChanged); _focus.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - return Container( - width: 65, - height: 40, - decoration: BoxDecoration( - color: Theme.of(context).scaffoldBackgroundColor, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: _focus.hasFocus - ? Theme.of(context).primaryColor - : Theme.of(context).dividerColor, - width: _focus.hasFocus ? 1.5 : 1.0, - ), - boxShadow: [ - BoxShadow( - color: - Colors.black.withValues(alpha: _focus.hasFocus ? 0.15 : 0.08), - blurRadius: _focus.hasFocus ? 6 : 3, - offset: const Offset(0, 2), + final bool hasText = widget.controller.text.trim().isNotEmpty; + final bool isInactive = !_focus.hasFocus && hasText; + + return AnimatedOpacity( + opacity: isInactive ? 0.5 : 1.0, // חצי שקופה כשלא בפוקוס ויש טקסט + duration: const Duration(milliseconds: 200), + child: Container( + width: 45, // הצרה משמעותית מ-65 ל-45 (מתאים ל-2 ספרות) + height: 40, + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _focus.hasFocus + ? Theme.of(context).primaryColor + : Theme.of(context).dividerColor, + width: _focus.hasFocus ? 1.5 : 1.0, ), - ], - ), - clipBehavior: Clip.antiAlias, - child: Material( - type: MaterialType.transparency, - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.close, size: 14), - onPressed: widget.onRemove, - splashRadius: 16, - padding: const EdgeInsets.only(left: 4, right: 2), - constraints: const BoxConstraints(), + boxShadow: [ + BoxShadow( + color: + Colors.black.withValues(alpha: _focus.hasFocus ? 0.15 : 0.08), + blurRadius: _focus.hasFocus ? 6 : 3, + offset: const Offset(0, 2), ), - Expanded( - child: TextField( - controller: widget.controller, - focusNode: _focus, - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(2), - ], - decoration: const InputDecoration( - hintText: 'מרווח', - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.only(right: 4, bottom: 4), + ], + ), + clipBehavior: Clip.antiAlias, + child: Material( + type: MaterialType.transparency, + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.close, size: 14), + onPressed: widget.onRemove, + splashRadius: 16, + padding: const EdgeInsets.only(left: 4, right: 2), + constraints: const BoxConstraints(), + ), + Expanded( + child: TextField( + controller: widget.controller, + focusNode: _focus, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(2), + ], + decoration: const InputDecoration( + hintText: 'מרווח', + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.only(right: 4, bottom: 4), + ), + style: const TextStyle(fontSize: 12, color: Colors.black87), + textAlign: TextAlign.right, + onSubmitted: (v) { + if (v.trim().isEmpty) widget.onRemove(); + }, ), - style: const TextStyle(fontSize: 12, color: Colors.black87), - textAlign: TextAlign.right, - onSubmitted: (v) { - if (v.trim().isEmpty) widget.onRemove(); - }, ), - ), - ], + ], + ), ), ), ); @@ -685,11 +699,15 @@ class _EnhancedSearchFieldState extends State { _wordPositions[leftIndex].dy - _kSpacingYOffset, ); final overlayPos = textFieldGlobal + midpoint; - final controller = - _spacingControllers.putIfAbsent(key, () => TextEditingController()); + final controller = _spacingControllers.putIfAbsent(key, () { + final newController = TextEditingController(); + // הוספת listener לעדכון המידע ב-tab כשהטקסט משתנה + newController.addListener(() => _updateSpacingInTab()); + return newController; + }); final entry = OverlayEntry( builder: (_) => Positioned( - left: overlayPos.dx - 32.5, + left: overlayPos.dx - 22.5, // מרכוז התיבה החדשה (45/2 = 22.5) top: overlayPos.dy - 50, child: _SpacingField( controller: controller, @@ -707,6 +725,8 @@ class _EnhancedSearchFieldState extends State { _spacingOverlays.remove(key); _spacingControllers[key]?.dispose(); _spacingControllers.remove(key); + // עדכון המידע ב-tab אחרי הסרת המרווח + _updateSpacingInTab(); } void _removeSpacingOverlayIfEmpty(String key) { @@ -959,6 +979,19 @@ class _EnhancedSearchFieldState extends State { widget.widget.tab.searchOptionsChanged.value++; } + void _updateSpacingInTab() { + // עדכון המרווחים ב-tab + widget.widget.tab.spacingValues.clear(); + for (String key in _spacingControllers.keys) { + final spacingText = _spacingControllers[key]!.text.trim(); + if (spacingText.isNotEmpty) { + widget.widget.tab.spacingValues[key] = spacingText; + } + } + // עדכון התצוגה + widget.widget.tab.searchOptionsChanged.value++; + } + @override Widget build(BuildContext context) { return Stack( diff --git a/lib/search/view/full_text_settings_widgets.dart b/lib/search/view/full_text_settings_widgets.dart index 27549d97b..8e9aaa902 100644 --- a/lib/search/view/full_text_settings_widgets.dart +++ b/lib/search/view/full_text_settings_widgets.dart @@ -170,6 +170,24 @@ class _SearchTermsDisplayState extends State { return textPainter.width; } + // פונקציה להמרת מספרים לתת-כתב Unicode + String _convertToSubscript(String number) { + const Map subscriptMap = { + '0': '₀', + '1': '₁', + '2': '₂', + '3': '₃', + '4': '₄', + '5': '₅', + '6': '₆', + '7': '₇', + '8': '₈', + '9': '₉', + }; + + return number.split('').map((char) => subscriptMap[char] ?? char).join(); + } + List _buildFormattedTextSpans(String text, BuildContext context) { if (text.trim().isEmpty) return [const TextSpan(text: '')]; @@ -293,16 +311,52 @@ class _SearchTermsDisplayState extends State { // הוספת + בין המילים (לא אחרי המילה האחרונה) if (i < words.length - 1) { - spans.add( - const TextSpan( - text: ' + ', - style: TextStyle( - fontSize: 16, // גופן גדול יותר ל-+ - fontWeight: FontWeight.bold, - color: Colors.black, + // בדיקה אם יש מרווח מוגדר בין המילים + final spacingKey = '$i-${i + 1}'; + final spacingValue = widget.tab.spacingValues[spacingKey]; + + if (spacingValue != null && spacingValue.isNotEmpty) { + // הצגת + עם המרווח מתחת + spans.add(const TextSpan(text: ' ')); + + // הוספת + עם המספר כתת-כתב + spans.add( + TextSpan( + text: '+', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black, + ), ), - ), - ); + ); + // הוספת המספר כתת-כתב עם Unicode subscript characters + final subscriptValue = _convertToSubscript(spacingValue); + spans.add( + TextSpan( + text: subscriptValue, + style: TextStyle( + fontSize: 14, // גופן מעט יותר גדול למספר המרווח + fontWeight: FontWeight.normal, + color: Theme.of(context).primaryColor, + ), + ), + ); + + spans.add(const TextSpan(text: ' ')); + } else { + // + רגיל ללא מרווח + spans.add( + const TextSpan( + text: ' + ', + style: TextStyle( + fontSize: 16, // גופן גדול יותר ל-+ + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + ); + } } } diff --git a/lib/tabs/models/searching_tab.dart b/lib/tabs/models/searching_tab.dart index 693ca1ee7..fc3c33446 100644 --- a/lib/tabs/models/searching_tab.dart +++ b/lib/tabs/models/searching_tab.dart @@ -22,6 +22,9 @@ class SearchingTab extends OpenedTab { // מילים חילופיות לכל מילה (אינדקס_מילה -> רשימת מילים חילופיות) final Map> alternativeWords = {}; + // מרווחים בין מילים (מפתח_מרווח -> ערך_מרווח) + final Map spacingValues = {}; + // notifier לעדכון התצוגה כשמשתמש משנה אפשרויות final ValueNotifier searchOptionsChanged = ValueNotifier(0); From c378c2ea632c3a5b4bbbde0109a275b37a2c588a Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 29 Jul 2025 01:19:55 +0300 Subject: [PATCH 039/197] =?UTF-8?q?=D7=A8=D7=99=D7=9B=D7=95=D7=96=20=D7=94?= =?UTF-8?q?=D7=92=D7=93=D7=A8=D7=95=D7=AA=20=D7=94=D7=97=D7=99=D7=A4=D7=95?= =?UTF-8?q?=D7=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/bloc/search_bloc.dart | 78 +++++++- lib/search/bloc/search_event.dart | 11 ++ lib/search/bloc/search_state.dart | 56 +++--- .../search_configuration_example.dart | 179 ++++++++++++++++++ lib/search/models/search_configuration.dart | 155 +++++++++++++++ 5 files changed, 445 insertions(+), 34 deletions(-) create mode 100644 lib/search/examples/search_configuration_example.dart create mode 100644 lib/search/models/search_configuration.dart diff --git a/lib/search/bloc/search_bloc.dart b/lib/search/bloc/search_bloc.dart index 9438c1254..cbabe7ea4 100644 --- a/lib/search/bloc/search_bloc.dart +++ b/lib/search/bloc/search_bloc.dart @@ -21,6 +21,13 @@ class SearchBloc extends Bloc { on(_onResetSearch); on(_onUpdateFilterQuery); on(_onClearFilter); + + // Handlers חדשים לרגקס + on(_onToggleRegex); + on(_onToggleCaseSensitive); + on(_onToggleMultiline); + on(_onToggleDotAll); + on(_onToggleUnicode); } Future _onUpdateSearchQuery( UpdateSearchQuery event, @@ -122,7 +129,8 @@ class SearchBloc extends Bloc { UpdateDistance event, Emitter emit, ) { - emit(state.copyWith(distance: event.distance)); + final newConfig = state.configuration.copyWith(distance: event.distance); + emit(state.copyWith(configuration: newConfig)); add(UpdateSearchQuery(state.searchQuery)); } @@ -130,7 +138,8 @@ class SearchBloc extends Bloc { ToggleFuzzy event, Emitter emit, ) { - emit(state.copyWith(fuzzy: !state.fuzzy)); + final newConfig = state.configuration.copyWith(fuzzy: !state.fuzzy); + emit(state.copyWith(configuration: newConfig)); add(UpdateSearchQuery(state.searchQuery)); } @@ -149,7 +158,8 @@ class SearchBloc extends Bloc { final newFacets = List.from(state.currentFacets); if (!newFacets.contains(event.facet)) { newFacets.add(event.facet); - emit(state.copyWith(currentFacets: newFacets)); + final newConfig = state.configuration.copyWith(currentFacets: newFacets); + emit(state.copyWith(configuration: newConfig)); add(UpdateSearchQuery(state.searchQuery)); } } @@ -161,7 +171,8 @@ class SearchBloc extends Bloc { final newFacets = List.from(state.currentFacets); if (newFacets.contains(event.facet)) { newFacets.remove(event.facet); - emit(state.copyWith(currentFacets: newFacets)); + final newConfig = state.configuration.copyWith(currentFacets: newFacets); + emit(state.copyWith(configuration: newConfig)); add(UpdateSearchQuery(state.searchQuery)); } } @@ -170,7 +181,9 @@ class SearchBloc extends Bloc { SetFacet event, Emitter emit, ) { - emit(state.copyWith(currentFacets: [event.facet])); + final newConfig = + state.configuration.copyWith(currentFacets: [event.facet]); + emit(state.copyWith(configuration: newConfig)); add(UpdateSearchQuery(state.searchQuery)); } @@ -178,7 +191,8 @@ class SearchBloc extends Bloc { UpdateSortOrder event, Emitter emit, ) { - emit(state.copyWith(sortBy: event.order)); + final newConfig = state.configuration.copyWith(sortBy: event.order); + emit(state.copyWith(configuration: newConfig)); add(UpdateSearchQuery(state.searchQuery)); } @@ -186,7 +200,9 @@ class SearchBloc extends Bloc { UpdateNumResults event, Emitter emit, ) { - emit(state.copyWith(numResults: event.numResults)); + final newConfig = + state.configuration.copyWith(numResults: event.numResults); + emit(state.copyWith(configuration: newConfig)); add(UpdateSearchQuery(state.searchQuery)); } @@ -209,4 +225,52 @@ class SearchBloc extends Bloc { distance: state.distance, ); } + + // Handlers חדשים לרגקס + void _onToggleRegex( + ToggleRegex event, + Emitter emit, + ) { + final newConfig = + state.configuration.copyWith(regexEnabled: !state.regexEnabled); + emit(state.copyWith(configuration: newConfig)); + add(UpdateSearchQuery(state.searchQuery)); + } + + void _onToggleCaseSensitive( + ToggleCaseSensitive event, + Emitter emit, + ) { + final newConfig = + state.configuration.copyWith(caseSensitive: !state.caseSensitive); + emit(state.copyWith(configuration: newConfig)); + add(UpdateSearchQuery(state.searchQuery)); + } + + void _onToggleMultiline( + ToggleMultiline event, + Emitter emit, + ) { + final newConfig = state.configuration.copyWith(multiline: !state.multiline); + emit(state.copyWith(configuration: newConfig)); + add(UpdateSearchQuery(state.searchQuery)); + } + + void _onToggleDotAll( + ToggleDotAll event, + Emitter emit, + ) { + final newConfig = state.configuration.copyWith(dotAll: !state.dotAll); + emit(state.copyWith(configuration: newConfig)); + add(UpdateSearchQuery(state.searchQuery)); + } + + void _onToggleUnicode( + ToggleUnicode event, + Emitter emit, + ) { + final newConfig = state.configuration.copyWith(unicode: !state.unicode); + emit(state.copyWith(configuration: newConfig)); + add(UpdateSearchQuery(state.searchQuery)); + } } diff --git a/lib/search/bloc/search_event.dart b/lib/search/bloc/search_event.dart index c4202a9a5..a248b2b6b 100644 --- a/lib/search/bloc/search_event.dart +++ b/lib/search/bloc/search_event.dart @@ -57,3 +57,14 @@ class UpdateNumResults extends SearchEvent { } class ResetSearch extends SearchEvent {} + +// Events חדשים להגדרות רגקס +class ToggleRegex extends SearchEvent {} + +class ToggleCaseSensitive extends SearchEvent {} + +class ToggleMultiline extends SearchEvent {} + +class ToggleDotAll extends SearchEvent {} + +class ToggleUnicode extends SearchEvent {} diff --git a/lib/search/bloc/search_state.dart b/lib/search/bloc/search_state.dart index 18a7f4a12..b6c8928d4 100644 --- a/lib/search/bloc/search_state.dart +++ b/lib/search/bloc/search_state.dart @@ -1,61 +1,63 @@ import 'package:otzaria/models/books.dart'; +import 'package:otzaria/search/models/search_configuration.dart'; import 'package:search_engine/search_engine.dart'; class SearchState { final String? filterQuery; final List? filteredBooks; - final int distance; - final bool fuzzy; final List results; final Set booksToSearch; - final List currentFacets; - final ResultsOrder sortBy; - final int numResults; final bool isLoading; final String searchQuery; final int totalResults; + // הגדרות החיפוש מרוכזות במחלקה נפרדת + final SearchConfiguration configuration; + const SearchState({ - this.distance = 2, - this.fuzzy = false, this.results = const [], this.booksToSearch = const {}, - this.currentFacets = const ["/"], - this.sortBy = ResultsOrder.catalogue, - this.numResults = 100, this.isLoading = false, this.searchQuery = '', this.totalResults = 0, this.filterQuery, this.filteredBooks, + this.configuration = const SearchConfiguration(), }); SearchState copyWith({ - int? distance, - bool? fuzzy, List? results, Set? booksToSearch, - List? currentFacets, - ResultsOrder? sortBy, - int? numResults, bool? isLoading, String? searchQuery, int? totalResults, String? filterQuery, List? filteredBooks, + SearchConfiguration? configuration, }) { return SearchState( - distance: distance ?? this.distance, - fuzzy: fuzzy ?? this.fuzzy, - results: results ?? this.results, - booksToSearch: booksToSearch ?? this.booksToSearch, - currentFacets: currentFacets ?? this.currentFacets, - sortBy: sortBy ?? this.sortBy, - numResults: numResults ?? this.numResults, - isLoading: isLoading ?? this.isLoading, - searchQuery: searchQuery ?? this.searchQuery, - totalResults: totalResults ?? this.totalResults, - filterQuery: filterQuery, - filteredBooks: filteredBooks); + results: results ?? this.results, + booksToSearch: booksToSearch ?? this.booksToSearch, + isLoading: isLoading ?? this.isLoading, + searchQuery: searchQuery ?? this.searchQuery, + totalResults: totalResults ?? this.totalResults, + filterQuery: filterQuery, + filteredBooks: filteredBooks, + configuration: configuration ?? this.configuration, + ); } + + // Getters לנוחות גישה להגדרות (backward compatibility) + int get distance => configuration.distance; + bool get fuzzy => configuration.fuzzy; + List get currentFacets => configuration.currentFacets; + ResultsOrder get sortBy => configuration.sortBy; + int get numResults => configuration.numResults; + + // Getters חדשים לרגקס + bool get regexEnabled => configuration.regexEnabled; + bool get caseSensitive => configuration.caseSensitive; + bool get multiline => configuration.multiline; + bool get dotAll => configuration.dotAll; + bool get unicode => configuration.unicode; } diff --git a/lib/search/examples/search_configuration_example.dart b/lib/search/examples/search_configuration_example.dart new file mode 100644 index 000000000..2830b98ba --- /dev/null +++ b/lib/search/examples/search_configuration_example.dart @@ -0,0 +1,179 @@ +// דוגמה לשימוש ב-SearchConfiguration החדש +// קובץ זה מראה איך להשתמש בהגדרות החיפוש המרוכזות + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:otzaria/search/bloc/search_bloc.dart'; +import 'package:otzaria/search/bloc/search_event.dart'; +import 'package:otzaria/search/bloc/search_state.dart'; +import 'package:otzaria/search/models/search_configuration.dart'; + +/// דוגמה לווידג'ט שמציג את הגדרות החיפוש הנוכחיות +class SearchConfigurationDisplay extends StatelessWidget { + const SearchConfigurationDisplay({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final config = state.configuration; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('הגדרות חיפוש נוכחיות:', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + + // הגדרות קיימות + Text('מרחק: ${config.distance}'), + Text('חיפוש מטושטש: ${config.fuzzy ? "מופעל" : "כבוי"}'), + Text('מספר תוצאות: ${config.numResults}'), + Text('סדר מיון: ${config.sortBy}'), + + const Divider(), + + // הגדרות רגקס חדשות + Text('הגדרות רגקס:', + style: Theme.of(context).textTheme.titleSmall), + Text('רגקס מופעל: ${config.regexEnabled ? "כן" : "לא"}'), + Text('רגיש לאותיות: ${config.caseSensitive ? "כן" : "לא"}'), + Text('מרובה שורות: ${config.multiline ? "כן" : "לא"}'), + Text('נקודה כוללת הכל: ${config.dotAll ? "כן" : "לא"}'), + Text('יוניקוד: ${config.unicode ? "כן" : "לא"}'), + + if (config.regexEnabled) ...[ + const SizedBox(height: 8), + Text('דגלי רגקס: ${config.regexFlags}'), + ], + ], + ), + ), + ); + }, + ); + } +} + +/// דוגמה לווידג'ט שמאפשר לשנות הגדרות רגקס +class RegexSettingsPanel extends StatelessWidget { + const RegexSettingsPanel({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final config = state.configuration; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('הגדרות רגקס:', + style: Theme.of(context).textTheme.titleMedium), + SwitchListTile( + title: const Text('הפעל חיפוש רגקס'), + value: config.regexEnabled, + onChanged: (_) => + context.read().add(ToggleRegex()), + ), + if (config.regexEnabled) ...[ + SwitchListTile( + title: const Text('רגיש לאותיות גדולות/קטנות'), + subtitle: const Text('אם כבוי, A ו-a נחשבים זהים'), + value: config.caseSensitive, + onChanged: (_) => + context.read().add(ToggleCaseSensitive()), + ), + SwitchListTile( + title: const Text('מצב מרובה שורות'), + subtitle: const Text('^ ו-\$ מתייחסים לתחילת/סוף שורה'), + value: config.multiline, + onChanged: (_) => + context.read().add(ToggleMultiline()), + ), + SwitchListTile( + title: const Text('נקודה כוללת הכל'), + subtitle: const Text('. כולל גם תווי שורה חדשה'), + value: config.dotAll, + onChanged: (_) => + context.read().add(ToggleDotAll()), + ), + SwitchListTile( + title: const Text('תמיכה ביוניקוד'), + subtitle: const Text('תמיכה מלאה בתווי יוניקוד'), + value: config.unicode, + onChanged: (_) => + context.read().add(ToggleUnicode()), + ), + ], + ], + ), + ), + ); + }, + ); + } +} + +/// דוגמה ליצירת הגדרות מותאמות אישית +class CustomSearchConfiguration { + /// יוצר הגדרות לחיפוש רגקס בסיסי + static SearchConfiguration basicRegex() { + return const SearchConfiguration( + regexEnabled: true, + caseSensitive: false, + multiline: false, + dotAll: false, + unicode: true, + ); + } + + /// יוצר הגדרות לחיפוש רגקס מתקדם + static SearchConfiguration advancedRegex() { + return const SearchConfiguration( + regexEnabled: true, + caseSensitive: true, + multiline: true, + dotAll: true, + unicode: true, + distance: 1, + fuzzy: false, + numResults: 50, + ); + } + + /// יוצר הגדרות לחיפוש מטושטש + static SearchConfiguration fuzzySearch() { + return const SearchConfiguration( + regexEnabled: false, + fuzzy: true, + distance: 3, + numResults: 200, + ); + } +} + +/// דוגמה לשמירה וטעינה של הגדרות +class SearchConfigurationManager { + static const String _configKey = 'search_configuration'; + + /// שמירת הגדרות (דוגמה - צריך להתאים לשיטת השמירה בפרויקט) + static Future saveConfiguration(SearchConfiguration config) async { + // כאן תהיה השמירה ב-SharedPreferences או במקום אחר + final configMap = config.toMap(); + print('שמירת הגדרות: $configMap'); + } + + /// טעינת הגדרות (דוגמה - צריך להתאים לשיטת הטעינה בפרויקט) + static Future loadConfiguration() async { + // כאן תהיה הטעינה מ-SharedPreferences או ממקום אחר + // לעת עתה מחזיר הגדרות ברירת מחדל + return const SearchConfiguration(); + } +} diff --git a/lib/search/models/search_configuration.dart b/lib/search/models/search_configuration.dart new file mode 100644 index 000000000..a6f8a75d9 --- /dev/null +++ b/lib/search/models/search_configuration.dart @@ -0,0 +1,155 @@ +import 'package:search_engine/search_engine.dart'; + +/// מחלקה שמרכזת את כל הגדרות החיפוש במקום אחד +/// כוללת הגדרות קיימות והגדרות עתידיות לרגקס +class SearchConfiguration { + // הגדרות חיפוש קיימות + final int distance; + final bool fuzzy; + final ResultsOrder sortBy; + final int numResults; + final List currentFacets; + + // הגדרות רגקס עתידיות (מוכנות להרחבה) + final bool regexEnabled; + final bool caseSensitive; + final bool multiline; + final bool dotAll; + final bool unicode; + + const SearchConfiguration({ + // ערכי ברירת מחדל קיימים + this.distance = 2, + this.fuzzy = false, + this.sortBy = ResultsOrder.catalogue, + this.numResults = 100, + this.currentFacets = const ["/"], + + // ערכי ברירת מחדל לרגקס + this.regexEnabled = false, + this.caseSensitive = false, + this.multiline = false, + this.dotAll = false, + this.unicode = true, + }); + + /// יוצר עותק עם שינויים + SearchConfiguration copyWith({ + int? distance, + bool? fuzzy, + ResultsOrder? sortBy, + int? numResults, + List? currentFacets, + bool? regexEnabled, + bool? caseSensitive, + bool? multiline, + bool? dotAll, + bool? unicode, + }) { + return SearchConfiguration( + distance: distance ?? this.distance, + fuzzy: fuzzy ?? this.fuzzy, + sortBy: sortBy ?? this.sortBy, + numResults: numResults ?? this.numResults, + currentFacets: currentFacets ?? this.currentFacets, + regexEnabled: regexEnabled ?? this.regexEnabled, + caseSensitive: caseSensitive ?? this.caseSensitive, + multiline: multiline ?? this.multiline, + dotAll: dotAll ?? this.dotAll, + unicode: unicode ?? this.unicode, + ); + } + + /// המרה למפה לשמירה או העברה + Map toMap() { + return { + 'distance': distance, + 'fuzzy': fuzzy, + 'sortBy': sortBy.index, + 'numResults': numResults, + 'currentFacets': currentFacets, + 'regexEnabled': regexEnabled, + 'caseSensitive': caseSensitive, + 'multiline': multiline, + 'dotAll': dotAll, + 'unicode': unicode, + }; + } + + /// יצירה ממפה + factory SearchConfiguration.fromMap(Map map) { + return SearchConfiguration( + distance: map['distance'] ?? 2, + fuzzy: map['fuzzy'] ?? false, + sortBy: ResultsOrder.values[map['sortBy'] ?? 0], + numResults: map['numResults'] ?? 100, + currentFacets: List.from(map['currentFacets'] ?? ["/"]), + regexEnabled: map['regexEnabled'] ?? false, + caseSensitive: map['caseSensitive'] ?? false, + multiline: map['multiline'] ?? false, + dotAll: map['dotAll'] ?? false, + unicode: map['unicode'] ?? true, + ); + } + + /// בדיקה אם החיפוש במצב רגקס + bool get isRegexMode => regexEnabled; + + /// קבלת דגלי רגקס כמחרוזת (לשימוש עתידי) + String get regexFlags { + String flags = ''; + if (!caseSensitive) flags += 'i'; + if (multiline) flags += 'm'; + if (dotAll) flags += 's'; + if (unicode) flags += 'u'; + return flags; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SearchConfiguration && + other.distance == distance && + other.fuzzy == fuzzy && + other.sortBy == sortBy && + other.numResults == numResults && + other.currentFacets.toString() == currentFacets.toString() && + other.regexEnabled == regexEnabled && + other.caseSensitive == caseSensitive && + other.multiline == multiline && + other.dotAll == dotAll && + other.unicode == unicode; + } + + @override + int get hashCode { + return Object.hash( + distance, + fuzzy, + sortBy, + numResults, + currentFacets, + regexEnabled, + caseSensitive, + multiline, + dotAll, + unicode, + ); + } + + @override + String toString() { + return 'SearchConfiguration(' + 'distance: $distance, ' + 'fuzzy: $fuzzy, ' + 'sortBy: $sortBy, ' + 'numResults: $numResults, ' + 'facets: $currentFacets, ' + 'regex: $regexEnabled, ' + 'caseSensitive: $caseSensitive, ' + 'multiline: $multiline, ' + 'dotAll: $dotAll, ' + 'unicode: $unicode' + ')'; + } +} From 5703217b456a6672060088bc1067cf434ab974fd Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 29 Jul 2025 13:27:47 +0300 Subject: [PATCH 040/197] =?UTF-8?q?=D7=A9=D7=99=D7=A0=D7=95=D7=99=20=D7=9C?= =?UTF-8?q?=D7=9E=D7=A0=D7=95=D7=A2=20=D7=94=D7=97=D7=99=D7=A4=D7=95=D7=A9?= =?UTF-8?q?=20=D7=94=D7=97=D7=93=D7=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data_providers/tantivy_data_provider.dart | 62 +- lib/search/search_repository.dart | 35 +- pubspec.lock | 8 +- pubspec.yaml | 2 +- repomix-new-search.xml | 2934 +++++++++++++++++ 5 files changed, 3021 insertions(+), 20 deletions(-) create mode 100644 repomix-new-search.xml diff --git a/lib/data/data_providers/tantivy_data_provider.dart b/lib/data/data_providers/tantivy_data_provider.dart index f4cf56faf..a34880c7c 100644 --- a/lib/data/data_providers/tantivy_data_provider.dart +++ b/lib/data/data_providers/tantivy_data_provider.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:search_engine/search_engine.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:hive/hive.dart'; +import 'package:otzaria/search/search_repository.dart'; /// A singleton class that manages search functionality using Tantivy search engine. /// @@ -35,7 +36,7 @@ class TantivyDataProvider { Platform.pathSeparator + 'ref_index'; - engine = SearchEngine.newInstance(path: indexPath); + engine = Future.value(SearchEngine(path: indexPath)); try { refEngine = ReferenceSearchEngine(path: refIndexPath); @@ -51,13 +52,22 @@ class TantivyDataProvider { //test the engine engine.then((value) { try { - value.search( - query: 'a', - limit: 10, - fuzzy: false, - facets: ["/"], - order: ResultsOrder.catalogue); + print('🧪 בודק מנוע חיפוש...'); + value + .search( + regexTerms: ['a'], + limit: 10, + slop: 0, + maxExpansions: 0, + facets: ["/"], + order: ResultsOrder.catalogue) + .then((results) { + print('🧪 בדיקת מנוע הצליחה - נמצאו ${results.length} תוצאות'); + }).catchError((e) { + print('❌ שגיאה בבדיקת מנוע: $e'); + }); } catch (e) { + print('❌ שגיאה בבדיקת מנוע (sync): $e'); if (e.toString() == "PanicException(Failed to create index: SchemaError(\"An index exists but the schema does not match.\"))") { resetIndex(indexPath); @@ -95,10 +105,36 @@ class TantivyDataProvider { Future countTexts(String query, List books, List facets, {bool fuzzy = false, int distance = 2}) async { final index = await engine; + + print('🔢 CountTexts: מתחיל ספירה'); + print('🔢 Query: "$query"'); + print('🔢 Facets: $facets'); + + // המרת החיפוש הפשוט לפורמט החדש - ללא רגקס אמיתי! + List regexTerms; if (!fuzzy) { - query = distance > 0 ? '"$query"~$distance' : '"$query"'; + // חיפוש מדוייק - ננסה בלי מירכאות תחילה + regexTerms = [query]; + } else { + // חיפוש מקורב - נשתמש במילים בודדות + regexTerms = query.trim().split(RegExp(r'\s+')); + } + + print('🔢 RegexTerms: $regexTerms'); + + try { + final count = await index.count( + regexTerms: regexTerms, + facets: facets, + slop: distance, + maxExpansions: fuzzy ? 50 : 0); + + print('🔢 ספירה: נמצאו $count תוצאות'); + return count; + } catch (e) { + print('❌ שגיאה בספירה: $e'); + rethrow; } - return index.count(query: query, facets: facets, fuzzy: fuzzy); } Future resetIndex(String indexPath) async { @@ -118,9 +154,11 @@ class TantivyDataProvider { /// Returns a Stream of search results that can be listened to for real-time updates Stream> searchTextsStream( String query, List facets, int limit, bool fuzzy) async* { - final index = await engine; - yield* index.searchStream( - query: query, facets: facets, limit: limit, fuzzy: fuzzy); + // הפונקציה הזו לא נתמכת במנוע החדש - נחזיר תוצאה חד-פעמית + final searchRepository = SearchRepository(); + final results = + await searchRepository.searchTexts(query, facets, limit, fuzzy: fuzzy); + yield results; } Future> searchRefs( diff --git a/lib/search/search_repository.dart b/lib/search/search_repository.dart index 6d1bd5231..693c5fb86 100644 --- a/lib/search/search_repository.dart +++ b/lib/search/search_repository.dart @@ -19,10 +19,39 @@ class SearchRepository { SearchEngine index; index = await TantivyDataProvider.instance.engine; + + print('🔍 SearchRepository: מתחיל חיפוש'); + print('🔍 Query: "$query"'); + print('🔍 Facets: $facets'); + print('🔍 Fuzzy: $fuzzy, Distance: $distance'); + + // המרת החיפוש הפשוט לפורמט החדש - ללא רגקס אמיתי! + List regexTerms; if (!fuzzy) { - query = distance > 0 ? '*"$query"~$distance' : '"$query"'; + // חיפוש מדוייק - ננסה בלי מירכאות תחילה + regexTerms = [query]; + } else { + // חיפוש מקורב - נשתמש במילים בודדות + regexTerms = query.trim().split(RegExp(r'\s+')); + } + + print('🔍 RegexTerms: $regexTerms'); + print('🔍 Slop: $distance, MaxExpansions: ${fuzzy ? 50 : 0}'); + + try { + final results = await index.search( + regexTerms: regexTerms, + facets: facets, + limit: limit, + slop: distance, + maxExpansions: fuzzy ? 50 : 0, + order: order); + + print('🔍 תוצאות: נמצאו ${results.length} תוצאות'); + return results; + } catch (e) { + print('❌ שגיאה בחיפוש: $e'); + rethrow; } - return await index.search( - query: query, facets: facets, limit: limit, fuzzy: fuzzy, order: order); } } diff --git a/pubspec.lock b/pubspec.lock index 686379fa4..f5dcdcc84 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -454,10 +454,10 @@ packages: dependency: transitive description: name: flutter_rust_bridge - sha256: "5a5c7a5deeef2cc2ffe6076a33b0429f4a20ceac22a397297aed2b1eb067e611" + sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e" url: "https://pub.dev" source: hosted - version: "2.9.0" + version: "2.11.1" flutter_settings_screens: dependency: "direct main" description: @@ -1048,8 +1048,8 @@ packages: dependency: "direct main" description: path: "." - ref: "01b49f69b8475f673cd1db13128a482963d4bf7d" - resolved-ref: "01b49f69b8475f673cd1db13128a482963d4bf7d" + ref: use-regex + resolved-ref: e0f7a2a982bfdc5a0414fe26033b708a345b8547 url: "https://github.com/Sivan22/otzaria_search_engine" source: git version: "0.0.1" diff --git a/pubspec.yaml b/pubspec.yaml index 212897214..653e9901c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -97,7 +97,7 @@ dependencies: #path: ../search_engine git: url: https://github.com/Sivan22/otzaria_search_engine - ref: 01b49f69b8475f673cd1db13128a482963d4bf7d + ref: use-regex flutter_archive: ^6.0.3 flutter_spinbox: ^0.13.1 toggle_switch: ^2.3.0 diff --git a/repomix-new-search.xml b/repomix-new-search.xml new file mode 100644 index 000000000..82c88a470 --- /dev/null +++ b/repomix-new-search.xml @@ -0,0 +1,2934 @@ +This file is a merged representation of the entire codebase, combined into a single document by Repomix. + + +This section contains a summary of this file. + + +This file contains a packed representation of the entire repository's contents. +It is designed to be easily consumable by AI systems for analysis, code review, +or other automated processes. + + + +The content is organized as follows: +1. This summary section +2. Repository information +3. Directory structure +4. Repository files (if enabled) +5. Multiple file entries, each consisting of: + - File path as an attribute + - Full contents of the file + + + +- This file should be treated as read-only. Any changes should be made to the + original repository files, not this packed version. +- When processing this file, use the file path to distinguish + between different files in the repository. +- Be aware that this file may contain sensitive information. Handle it with + the same level of security as you would the original repository. + + + +- Some files may have been excluded based on .gitignore rules and Repomix's configuration +- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files +- Files matching patterns in .gitignore are excluded +- Files matching default ignore patterns are excluded +- Files are sorted by Git change count (files with more changes are at the bottom) + + + + + +search_engine.dart +src/rust/api/reference_search_engine.dart +src/rust/api/search_engine.dart +src/rust/frb_generated.dart +src/rust/frb_generated.io.dart +src/rust/frb_generated.web.dart + + + +This section contains the contents of the repository's files. + + +library search_engine; + +export 'src/rust/api/search_engine.dart'; +export 'src/rust/api/reference_search_engine.dart'; +export 'src/rust/frb_generated.dart' show RustLib; + + + +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import '../frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; +import 'search_engine.dart'; + +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone` + +// Rust type: RustOpaqueMoi>> +abstract class BoxQuery implements RustOpaqueInterface {} + +// Rust type: RustOpaqueMoi> +abstract class Index implements RustOpaqueInterface {} + +// Rust type: RustOpaqueMoi> +abstract class ReferenceSearchEngine implements RustOpaqueInterface { + Future addDocument( + {required BigInt id, + required String title, + required String reference, + required String shortRef, + required BigInt segment, + required bool isPdf, + required String filePath}); + + Future clear(); + + Future commit(); + + Future count({required String query, required bool fuzzy}); + + static Future createSearchQuery( + {required Index index, + required String searchTerm, + required bool fuzzy}) => + RustLib.instance.api + .crateApiReferenceSearchEngineReferenceSearchEngineCreateSearchQuery( + index: index, searchTerm: searchTerm, fuzzy: fuzzy); + + factory ReferenceSearchEngine({required String path}) => RustLib.instance.api + .crateApiReferenceSearchEngineReferenceSearchEngineNew(path: path); + + Future> search( + {required String query, + required int limit, + required bool fuzzy, + required ResultsOrder order}); +} + +class ReferenceSearchResult { + final String title; + final String reference; + final String shortRef; + final BigInt id; + final BigInt segment; + final bool isPdf; + final String filePath; + + const ReferenceSearchResult({ + required this.title, + required this.reference, + required this.shortRef, + required this.id, + required this.segment, + required this.isPdf, + required this.filePath, + }); + + @override + int get hashCode => + title.hashCode ^ + reference.hashCode ^ + shortRef.hashCode ^ + id.hashCode ^ + segment.hashCode ^ + isPdf.hashCode ^ + filePath.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ReferenceSearchResult && + runtimeType == other.runtimeType && + title == other.title && + reference == other.reference && + shortRef == other.shortRef && + id == other.id && + segment == other.segment && + isPdf == other.isPdf && + filePath == other.filePath; +} + + + +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import '../frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; +import 'reference_search_engine.dart'; + +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone` + +// Rust type: RustOpaqueMoi> +abstract class SearchEngine implements RustOpaqueInterface { + Future addDocument( + {required BigInt id, + required String title, + required String reference, + required String topics, + required String text, + required BigInt segment, + required bool isPdf, + required String filePath}); + + Future clear(); + + Future commit(); + + Future count( + {required List regexTerms, + required List facets, + required int slop, + required int maxExpansions}); + + static Future createQuery( + {required Index index, + required List regexTerms, + required List facets, + required int slop, + required int maxExpansions}) => + RustLib.instance.api.crateApiSearchEngineSearchEngineCreateQuery( + index: index, + regexTerms: regexTerms, + facets: facets, + slop: slop, + maxExpansions: maxExpansions); + + factory SearchEngine({required String path}) => + RustLib.instance.api.crateApiSearchEngineSearchEngineNew(path: path); + + Future removeDocumentsByTitle({required String title}); + + Future> search( + {required List regexTerms, + required List facets, + required int limit, + required int slop, + required int maxExpansions, + required ResultsOrder order}); +} + +enum ResultsOrder { + catalogue, + relevance, + ; +} + +class SearchResult { + final String title; + final String reference; + final String text; + final BigInt id; + final BigInt segment; + final bool isPdf; + final String filePath; + + const SearchResult({ + required this.title, + required this.reference, + required this.text, + required this.id, + required this.segment, + required this.isPdf, + required this.filePath, + }); + + @override + int get hashCode => + title.hashCode ^ + reference.hashCode ^ + text.hashCode ^ + id.hashCode ^ + segment.hashCode ^ + isPdf.hashCode ^ + filePath.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SearchResult && + runtimeType == other.runtimeType && + title == other.title && + reference == other.reference && + text == other.text && + id == other.id && + segment == other.segment && + isPdf == other.isPdf && + filePath == other.filePath; +} + + + +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'api/reference_search_engine.dart'; +import 'api/search_engine.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'frb_generated.dart'; +import 'frb_generated.io.dart' + if (dart.library.js_interop) 'frb_generated.web.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +/// Main entrypoint of the Rust API +class RustLib extends BaseEntrypoint { + @internal + static final instance = RustLib._(); + + RustLib._(); + + /// Initialize flutter_rust_bridge + static Future init({ + RustLibApi? api, + BaseHandler? handler, + ExternalLibrary? externalLibrary, + bool forceSameCodegenVersion = true, + }) async { + await instance.initImpl( + api: api, + handler: handler, + externalLibrary: externalLibrary, + forceSameCodegenVersion: forceSameCodegenVersion, + ); + } + + /// Initialize flutter_rust_bridge in mock mode. + /// No libraries for FFI are loaded. + static void initMock({ + required RustLibApi api, + }) { + instance.initMockImpl( + api: api, + ); + } + + /// Dispose flutter_rust_bridge + /// + /// The call to this function is optional, since flutter_rust_bridge (and everything else) + /// is automatically disposed when the app stops. + static void dispose() => instance.disposeImpl(); + + @override + ApiImplConstructor get apiImplConstructor => + RustLibApiImpl.new; + + @override + WireConstructor get wireConstructor => + RustLibWire.fromExternalLibrary; + + @override + Future executeRustInitializers() async {} + + @override + ExternalLibraryLoaderConfig get defaultExternalLibraryLoaderConfig => + kDefaultExternalLibraryLoaderConfig; + + @override + String get codegenVersion => '2.11.1'; + + @override + int get rustContentHash => 271381323; + + static const kDefaultExternalLibraryLoaderConfig = + ExternalLibraryLoaderConfig( + stem: 'search_engine', + ioDirectory: 'rust/target/release/', + webPrefix: 'pkg/', + ); +} + +abstract class RustLibApi extends BaseApi { + Future crateApiReferenceSearchEngineReferenceSearchEngineAddDocument( + {required ReferenceSearchEngine that, + required BigInt id, + required String title, + required String reference, + required String shortRef, + required BigInt segment, + required bool isPdf, + required String filePath}); + + Future crateApiReferenceSearchEngineReferenceSearchEngineClear( + {required ReferenceSearchEngine that}); + + Future crateApiReferenceSearchEngineReferenceSearchEngineCommit( + {required ReferenceSearchEngine that}); + + Future crateApiReferenceSearchEngineReferenceSearchEngineCount( + {required ReferenceSearchEngine that, + required String query, + required bool fuzzy}); + + Future + crateApiReferenceSearchEngineReferenceSearchEngineCreateSearchQuery( + {required Index index, + required String searchTerm, + required bool fuzzy}); + + ReferenceSearchEngine crateApiReferenceSearchEngineReferenceSearchEngineNew( + {required String path}); + + Future> + crateApiReferenceSearchEngineReferenceSearchEngineSearch( + {required ReferenceSearchEngine that, + required String query, + required int limit, + required bool fuzzy, + required ResultsOrder order}); + + Future crateApiSearchEngineSearchEngineAddDocument( + {required SearchEngine that, + required BigInt id, + required String title, + required String reference, + required String topics, + required String text, + required BigInt segment, + required bool isPdf, + required String filePath}); + + Future crateApiSearchEngineSearchEngineClear( + {required SearchEngine that}); + + Future crateApiSearchEngineSearchEngineCommit( + {required SearchEngine that}); + + Future crateApiSearchEngineSearchEngineCount( + {required SearchEngine that, + required List regexTerms, + required List facets, + required int slop, + required int maxExpansions}); + + Future crateApiSearchEngineSearchEngineCreateQuery( + {required Index index, + required List regexTerms, + required List facets, + required int slop, + required int maxExpansions}); + + SearchEngine crateApiSearchEngineSearchEngineNew({required String path}); + + Future crateApiSearchEngineSearchEngineRemoveDocumentsByTitle( + {required SearchEngine that, required String title}); + + Future> crateApiSearchEngineSearchEngineSearch( + {required SearchEngine that, + required List regexTerms, + required List facets, + required int limit, + required int slop, + required int maxExpansions, + required ResultsOrder order}); + + RustArcIncrementStrongCountFnType + get rust_arc_increment_strong_count_BoxQuery; + + RustArcDecrementStrongCountFnType + get rust_arc_decrement_strong_count_BoxQuery; + + CrossPlatformFinalizerArg get rust_arc_decrement_strong_count_BoxQueryPtr; + + RustArcIncrementStrongCountFnType get rust_arc_increment_strong_count_Index; + + RustArcDecrementStrongCountFnType get rust_arc_decrement_strong_count_Index; + + CrossPlatformFinalizerArg get rust_arc_decrement_strong_count_IndexPtr; + + RustArcIncrementStrongCountFnType + get rust_arc_increment_strong_count_ReferenceSearchEngine; + + RustArcDecrementStrongCountFnType + get rust_arc_decrement_strong_count_ReferenceSearchEngine; + + CrossPlatformFinalizerArg + get rust_arc_decrement_strong_count_ReferenceSearchEnginePtr; + + RustArcIncrementStrongCountFnType + get rust_arc_increment_strong_count_SearchEngine; + + RustArcDecrementStrongCountFnType + get rust_arc_decrement_strong_count_SearchEngine; + + CrossPlatformFinalizerArg get rust_arc_decrement_strong_count_SearchEnginePtr; +} + +class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { + RustLibApiImpl({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @override + Future crateApiReferenceSearchEngineReferenceSearchEngineAddDocument( + {required ReferenceSearchEngine that, + required BigInt id, + required String title, + required String reference, + required String shortRef, + required BigInt segment, + required bool isPdf, + required String filePath}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + that, serializer); + sse_encode_u_64(id, serializer); + sse_encode_String(title, serializer); + sse_encode_String(reference, serializer); + sse_encode_String(shortRef, serializer); + sse_encode_u_64(segment, serializer); + sse_encode_bool(isPdf, serializer); + sse_encode_String(filePath, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 1, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: + kCrateApiReferenceSearchEngineReferenceSearchEngineAddDocumentConstMeta, + argValues: [ + that, + id, + title, + reference, + shortRef, + segment, + isPdf, + filePath + ], + apiImpl: this, + )); + } + + TaskConstMeta + get kCrateApiReferenceSearchEngineReferenceSearchEngineAddDocumentConstMeta => + const TaskConstMeta( + debugName: "ReferenceSearchEngine_add_document", + argNames: [ + "that", + "id", + "title", + "reference", + "shortRef", + "segment", + "isPdf", + "filePath" + ], + ); + + @override + Future crateApiReferenceSearchEngineReferenceSearchEngineClear( + {required ReferenceSearchEngine that}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + that, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 2, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: + kCrateApiReferenceSearchEngineReferenceSearchEngineClearConstMeta, + argValues: [that], + apiImpl: this, + )); + } + + TaskConstMeta + get kCrateApiReferenceSearchEngineReferenceSearchEngineClearConstMeta => + const TaskConstMeta( + debugName: "ReferenceSearchEngine_clear", + argNames: ["that"], + ); + + @override + Future crateApiReferenceSearchEngineReferenceSearchEngineCommit( + {required ReferenceSearchEngine that}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + that, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 3, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: + kCrateApiReferenceSearchEngineReferenceSearchEngineCommitConstMeta, + argValues: [that], + apiImpl: this, + )); + } + + TaskConstMeta + get kCrateApiReferenceSearchEngineReferenceSearchEngineCommitConstMeta => + const TaskConstMeta( + debugName: "ReferenceSearchEngine_commit", + argNames: ["that"], + ); + + @override + Future crateApiReferenceSearchEngineReferenceSearchEngineCount( + {required ReferenceSearchEngine that, + required String query, + required bool fuzzy}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + that, serializer); + sse_encode_String(query, serializer); + sse_encode_bool(fuzzy, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 4, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_u_32, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: + kCrateApiReferenceSearchEngineReferenceSearchEngineCountConstMeta, + argValues: [that, query, fuzzy], + apiImpl: this, + )); + } + + TaskConstMeta + get kCrateApiReferenceSearchEngineReferenceSearchEngineCountConstMeta => + const TaskConstMeta( + debugName: "ReferenceSearchEngine_count", + argNames: ["that", "query", "fuzzy"], + ); + + @override + Future + crateApiReferenceSearchEngineReferenceSearchEngineCreateSearchQuery( + {required Index index, + required String searchTerm, + required bool fuzzy}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + index, serializer); + sse_encode_String(searchTerm, serializer); + sse_encode_bool(fuzzy, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 5, port: port_); + }, + codec: SseCodec( + decodeSuccessData: + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: + kCrateApiReferenceSearchEngineReferenceSearchEngineCreateSearchQueryConstMeta, + argValues: [index, searchTerm, fuzzy], + apiImpl: this, + )); + } + + TaskConstMeta + get kCrateApiReferenceSearchEngineReferenceSearchEngineCreateSearchQueryConstMeta => + const TaskConstMeta( + debugName: "ReferenceSearchEngine_create_search_query", + argNames: ["index", "searchTerm", "fuzzy"], + ); + + @override + ReferenceSearchEngine crateApiReferenceSearchEngineReferenceSearchEngineNew( + {required String path}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(path, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 6)!; + }, + codec: SseCodec( + decodeSuccessData: + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine, + decodeErrorData: null, + ), + constMeta: + kCrateApiReferenceSearchEngineReferenceSearchEngineNewConstMeta, + argValues: [path], + apiImpl: this, + )); + } + + TaskConstMeta + get kCrateApiReferenceSearchEngineReferenceSearchEngineNewConstMeta => + const TaskConstMeta( + debugName: "ReferenceSearchEngine_new", + argNames: ["path"], + ); + + @override + Future> + crateApiReferenceSearchEngineReferenceSearchEngineSearch( + {required ReferenceSearchEngine that, + required String query, + required int limit, + required bool fuzzy, + required ResultsOrder order}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + that, serializer); + sse_encode_String(query, serializer); + sse_encode_u_32(limit, serializer); + sse_encode_bool(fuzzy, serializer); + sse_encode_results_order(order, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 7, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_reference_search_result, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: + kCrateApiReferenceSearchEngineReferenceSearchEngineSearchConstMeta, + argValues: [that, query, limit, fuzzy, order], + apiImpl: this, + )); + } + + TaskConstMeta + get kCrateApiReferenceSearchEngineReferenceSearchEngineSearchConstMeta => + const TaskConstMeta( + debugName: "ReferenceSearchEngine_search", + argNames: ["that", "query", "limit", "fuzzy", "order"], + ); + + @override + Future crateApiSearchEngineSearchEngineAddDocument( + {required SearchEngine that, + required BigInt id, + required String title, + required String reference, + required String topics, + required String text, + required BigInt segment, + required bool isPdf, + required String filePath}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + that, serializer); + sse_encode_u_64(id, serializer); + sse_encode_String(title, serializer); + sse_encode_String(reference, serializer); + sse_encode_String(topics, serializer); + sse_encode_String(text, serializer); + sse_encode_u_64(segment, serializer); + sse_encode_bool(isPdf, serializer); + sse_encode_String(filePath, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 8, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiSearchEngineSearchEngineAddDocumentConstMeta, + argValues: [ + that, + id, + title, + reference, + topics, + text, + segment, + isPdf, + filePath + ], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSearchEngineSearchEngineAddDocumentConstMeta => + const TaskConstMeta( + debugName: "SearchEngine_add_document", + argNames: [ + "that", + "id", + "title", + "reference", + "topics", + "text", + "segment", + "isPdf", + "filePath" + ], + ); + + @override + Future crateApiSearchEngineSearchEngineClear( + {required SearchEngine that}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + that, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 9, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiSearchEngineSearchEngineClearConstMeta, + argValues: [that], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSearchEngineSearchEngineClearConstMeta => + const TaskConstMeta( + debugName: "SearchEngine_clear", + argNames: ["that"], + ); + + @override + Future crateApiSearchEngineSearchEngineCommit( + {required SearchEngine that}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + that, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 10, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiSearchEngineSearchEngineCommitConstMeta, + argValues: [that], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSearchEngineSearchEngineCommitConstMeta => + const TaskConstMeta( + debugName: "SearchEngine_commit", + argNames: ["that"], + ); + + @override + Future crateApiSearchEngineSearchEngineCount( + {required SearchEngine that, + required List regexTerms, + required List facets, + required int slop, + required int maxExpansions}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + that, serializer); + sse_encode_list_String(regexTerms, serializer); + sse_encode_list_String(facets, serializer); + sse_encode_u_32(slop, serializer); + sse_encode_u_32(maxExpansions, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 11, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_u_32, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiSearchEngineSearchEngineCountConstMeta, + argValues: [that, regexTerms, facets, slop, maxExpansions], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSearchEngineSearchEngineCountConstMeta => + const TaskConstMeta( + debugName: "SearchEngine_count", + argNames: ["that", "regexTerms", "facets", "slop", "maxExpansions"], + ); + + @override + Future crateApiSearchEngineSearchEngineCreateQuery( + {required Index index, + required List regexTerms, + required List facets, + required int slop, + required int maxExpansions}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + index, serializer); + sse_encode_list_String(regexTerms, serializer); + sse_encode_list_String(facets, serializer); + sse_encode_u_32(slop, serializer); + sse_encode_u_32(maxExpansions, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 12, port: port_); + }, + codec: SseCodec( + decodeSuccessData: + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiSearchEngineSearchEngineCreateQueryConstMeta, + argValues: [index, regexTerms, facets, slop, maxExpansions], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSearchEngineSearchEngineCreateQueryConstMeta => + const TaskConstMeta( + debugName: "SearchEngine_create_query", + argNames: ["index", "regexTerms", "facets", "slop", "maxExpansions"], + ); + + @override + SearchEngine crateApiSearchEngineSearchEngineNew({required String path}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(path, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 13)!; + }, + codec: SseCodec( + decodeSuccessData: + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine, + decodeErrorData: null, + ), + constMeta: kCrateApiSearchEngineSearchEngineNewConstMeta, + argValues: [path], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSearchEngineSearchEngineNewConstMeta => + const TaskConstMeta( + debugName: "SearchEngine_new", + argNames: ["path"], + ); + + @override + Future crateApiSearchEngineSearchEngineRemoveDocumentsByTitle( + {required SearchEngine that, required String title}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + that, serializer); + sse_encode_String(title, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 14, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: + kCrateApiSearchEngineSearchEngineRemoveDocumentsByTitleConstMeta, + argValues: [that, title], + apiImpl: this, + )); + } + + TaskConstMeta + get kCrateApiSearchEngineSearchEngineRemoveDocumentsByTitleConstMeta => + const TaskConstMeta( + debugName: "SearchEngine_remove_documents_by_title", + argNames: ["that", "title"], + ); + + @override + Future> crateApiSearchEngineSearchEngineSearch( + {required SearchEngine that, + required List regexTerms, + required List facets, + required int limit, + required int slop, + required int maxExpansions, + required ResultsOrder order}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + that, serializer); + sse_encode_list_String(regexTerms, serializer); + sse_encode_list_String(facets, serializer); + sse_encode_u_32(limit, serializer); + sse_encode_u_32(slop, serializer); + sse_encode_u_32(maxExpansions, serializer); + sse_encode_results_order(order, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 15, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_search_result, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiSearchEngineSearchEngineSearchConstMeta, + argValues: [that, regexTerms, facets, limit, slop, maxExpansions, order], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiSearchEngineSearchEngineSearchConstMeta => + const TaskConstMeta( + debugName: "SearchEngine_search", + argNames: [ + "that", + "regexTerms", + "facets", + "limit", + "slop", + "maxExpansions", + "order" + ], + ); + + RustArcIncrementStrongCountFnType + get rust_arc_increment_strong_count_BoxQuery => wire + .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery; + + RustArcDecrementStrongCountFnType + get rust_arc_decrement_strong_count_BoxQuery => wire + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery; + + RustArcIncrementStrongCountFnType get rust_arc_increment_strong_count_Index => + wire.rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex; + + RustArcDecrementStrongCountFnType get rust_arc_decrement_strong_count_Index => + wire.rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex; + + RustArcIncrementStrongCountFnType + get rust_arc_increment_strong_count_ReferenceSearchEngine => wire + .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine; + + RustArcDecrementStrongCountFnType + get rust_arc_decrement_strong_count_ReferenceSearchEngine => wire + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine; + + RustArcIncrementStrongCountFnType + get rust_arc_increment_strong_count_SearchEngine => wire + .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine; + + RustArcDecrementStrongCountFnType + get rust_arc_decrement_strong_count_SearchEngine => wire + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine; + + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return AnyhowException(raw as String); + } + + @protected + BoxQuery + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return BoxQueryImpl.frbInternalDcoDecode(raw as List); + } + + @protected + ReferenceSearchEngine + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return ReferenceSearchEngineImpl.frbInternalDcoDecode(raw as List); + } + + @protected + SearchEngine + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return SearchEngineImpl.frbInternalDcoDecode(raw as List); + } + + @protected + ReferenceSearchEngine + dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return ReferenceSearchEngineImpl.frbInternalDcoDecode(raw as List); + } + + @protected + SearchEngine + dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return SearchEngineImpl.frbInternalDcoDecode(raw as List); + } + + @protected + Index + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return IndexImpl.frbInternalDcoDecode(raw as List); + } + + @protected + ReferenceSearchEngine + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return ReferenceSearchEngineImpl.frbInternalDcoDecode(raw as List); + } + + @protected + SearchEngine + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return SearchEngineImpl.frbInternalDcoDecode(raw as List); + } + + @protected + BoxQuery + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return BoxQueryImpl.frbInternalDcoDecode(raw as List); + } + + @protected + Index + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return IndexImpl.frbInternalDcoDecode(raw as List); + } + + @protected + ReferenceSearchEngine + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return ReferenceSearchEngineImpl.frbInternalDcoDecode(raw as List); + } + + @protected + SearchEngine + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return SearchEngineImpl.frbInternalDcoDecode(raw as List); + } + + @protected + String dco_decode_String(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as String; + } + + @protected + bool dco_decode_bool(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as bool; + } + + @protected + int dco_decode_i_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + List dco_decode_list_String(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_String).toList(); + } + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as Uint8List; + } + + @protected + List dco_decode_list_reference_search_result( + dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List) + .map(dco_decode_reference_search_result) + .toList(); + } + + @protected + List dco_decode_list_search_result(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_search_result).toList(); + } + + @protected + ReferenceSearchResult dco_decode_reference_search_result(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 7) + throw Exception('unexpected arr length: expect 7 but see ${arr.length}'); + return ReferenceSearchResult( + title: dco_decode_String(arr[0]), + reference: dco_decode_String(arr[1]), + shortRef: dco_decode_String(arr[2]), + id: dco_decode_u_64(arr[3]), + segment: dco_decode_u_64(arr[4]), + isPdf: dco_decode_bool(arr[5]), + filePath: dco_decode_String(arr[6]), + ); + } + + @protected + ResultsOrder dco_decode_results_order(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return ResultsOrder.values[raw as int]; + } + + @protected + SearchResult dco_decode_search_result(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 7) + throw Exception('unexpected arr length: expect 7 but see ${arr.length}'); + return SearchResult( + title: dco_decode_String(arr[0]), + reference: dco_decode_String(arr[1]), + text: dco_decode_String(arr[2]), + id: dco_decode_u_64(arr[3]), + segment: dco_decode_u_64(arr[4]), + isPdf: dco_decode_bool(arr[5]), + filePath: dco_decode_String(arr[6]), + ); + } + + @protected + int dco_decode_u_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + BigInt dco_decode_u_64(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dcoDecodeU64(raw); + } + + @protected + int dco_decode_u_8(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + void dco_decode_unit(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return; + } + + @protected + BigInt dco_decode_usize(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dcoDecodeU64(raw); + } + + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_String(deserializer); + return AnyhowException(inner); + } + + @protected + BoxQuery + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return BoxQueryImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); + } + + @protected + ReferenceSearchEngine + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return ReferenceSearchEngineImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); + } + + @protected + SearchEngine + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return SearchEngineImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); + } + + @protected + ReferenceSearchEngine + sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return ReferenceSearchEngineImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); + } + + @protected + SearchEngine + sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return SearchEngineImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); + } + + @protected + Index + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return IndexImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); + } + + @protected + ReferenceSearchEngine + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return ReferenceSearchEngineImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); + } + + @protected + SearchEngine + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return SearchEngineImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); + } + + @protected + BoxQuery + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return BoxQueryImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); + } + + @protected + Index + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return IndexImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); + } + + @protected + ReferenceSearchEngine + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return ReferenceSearchEngineImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); + } + + @protected + SearchEngine + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return SearchEngineImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); + } + + @protected + String sse_decode_String(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_list_prim_u_8_strict(deserializer); + return utf8.decoder.convert(inner); + } + + @protected + bool sse_decode_bool(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8() != 0; + } + + @protected + int sse_decode_i_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getInt32(); + } + + @protected + List sse_decode_list_String(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_String(deserializer)); + } + return ans_; + } + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var len_ = sse_decode_i_32(deserializer); + return deserializer.buffer.getUint8List(len_); + } + + @protected + List sse_decode_list_reference_search_result( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_reference_search_result(deserializer)); + } + return ans_; + } + + @protected + List sse_decode_list_search_result( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_search_result(deserializer)); + } + return ans_; + } + + @protected + ReferenceSearchResult sse_decode_reference_search_result( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_title = sse_decode_String(deserializer); + var var_reference = sse_decode_String(deserializer); + var var_shortRef = sse_decode_String(deserializer); + var var_id = sse_decode_u_64(deserializer); + var var_segment = sse_decode_u_64(deserializer); + var var_isPdf = sse_decode_bool(deserializer); + var var_filePath = sse_decode_String(deserializer); + return ReferenceSearchResult( + title: var_title, + reference: var_reference, + shortRef: var_shortRef, + id: var_id, + segment: var_segment, + isPdf: var_isPdf, + filePath: var_filePath); + } + + @protected + ResultsOrder sse_decode_results_order(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_i_32(deserializer); + return ResultsOrder.values[inner]; + } + + @protected + SearchResult sse_decode_search_result(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_title = sse_decode_String(deserializer); + var var_reference = sse_decode_String(deserializer); + var var_text = sse_decode_String(deserializer); + var var_id = sse_decode_u_64(deserializer); + var var_segment = sse_decode_u_64(deserializer); + var var_isPdf = sse_decode_bool(deserializer); + var var_filePath = sse_decode_String(deserializer); + return SearchResult( + title: var_title, + reference: var_reference, + text: var_text, + id: var_id, + segment: var_segment, + isPdf: var_isPdf, + filePath: var_filePath); + } + + @protected + int sse_decode_u_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint32(); + } + + @protected + BigInt sse_decode_u_64(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getBigUint64(); + } + + @protected + int sse_decode_u_8(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8(); + } + + @protected + void sse_decode_unit(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } + + @protected + BigInt sse_decode_usize(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getBigUint64(); + } + + @protected + void sse_encode_AnyhowException( + AnyhowException self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.message, serializer); + } + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + BoxQuery self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as BoxQueryImpl).frbInternalSseEncode(move: true), serializer); + } + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + ReferenceSearchEngine self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as ReferenceSearchEngineImpl).frbInternalSseEncode(move: true), + serializer); + } + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SearchEngine self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as SearchEngineImpl).frbInternalSseEncode(move: true), + serializer); + } + + @protected + void + sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + ReferenceSearchEngine self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as ReferenceSearchEngineImpl).frbInternalSseEncode(move: false), + serializer); + } + + @protected + void + sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SearchEngine self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as SearchEngineImpl).frbInternalSseEncode(move: false), + serializer); + } + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + Index self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as IndexImpl).frbInternalSseEncode(move: false), serializer); + } + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + ReferenceSearchEngine self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as ReferenceSearchEngineImpl).frbInternalSseEncode(move: false), + serializer); + } + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SearchEngine self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as SearchEngineImpl).frbInternalSseEncode(move: false), + serializer); + } + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + BoxQuery self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as BoxQueryImpl).frbInternalSseEncode(move: null), serializer); + } + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + Index self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as IndexImpl).frbInternalSseEncode(move: null), serializer); + } + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + ReferenceSearchEngine self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as ReferenceSearchEngineImpl).frbInternalSseEncode(move: null), + serializer); + } + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SearchEngine self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as SearchEngineImpl).frbInternalSseEncode(move: null), + serializer); + } + + @protected + void sse_encode_String(String self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_list_prim_u_8_strict(utf8.encoder.convert(self), serializer); + } + + @protected + void sse_encode_bool(bool self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self ? 1 : 0); + } + + @protected + void sse_encode_i_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putInt32(self); + } + + @protected + void sse_encode_list_String(List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_String(item, serializer); + } + } + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + serializer.buffer.putUint8List(self); + } + + @protected + void sse_encode_list_reference_search_result( + List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_reference_search_result(item, serializer); + } + } + + @protected + void sse_encode_list_search_result( + List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_search_result(item, serializer); + } + } + + @protected + void sse_encode_reference_search_result( + ReferenceSearchResult self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.title, serializer); + sse_encode_String(self.reference, serializer); + sse_encode_String(self.shortRef, serializer); + sse_encode_u_64(self.id, serializer); + sse_encode_u_64(self.segment, serializer); + sse_encode_bool(self.isPdf, serializer); + sse_encode_String(self.filePath, serializer); + } + + @protected + void sse_encode_results_order(ResultsOrder self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.index, serializer); + } + + @protected + void sse_encode_search_result(SearchResult self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.title, serializer); + sse_encode_String(self.reference, serializer); + sse_encode_String(self.text, serializer); + sse_encode_u_64(self.id, serializer); + sse_encode_u_64(self.segment, serializer); + sse_encode_bool(self.isPdf, serializer); + sse_encode_String(self.filePath, serializer); + } + + @protected + void sse_encode_u_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint32(self); + } + + @protected + void sse_encode_u_64(BigInt self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putBigUint64(self); + } + + @protected + void sse_encode_u_8(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self); + } + + @protected + void sse_encode_unit(void self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } + + @protected + void sse_encode_usize(BigInt self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putBigUint64(self); + } +} + +@sealed +class BoxQueryImpl extends RustOpaque implements BoxQuery { + // Not to be used by end users + BoxQueryImpl.frbInternalDcoDecode(List wire) + : super.frbInternalDcoDecode(wire, _kStaticData); + + // Not to be used by end users + BoxQueryImpl.frbInternalSseDecode(BigInt ptr, int externalSizeOnNative) + : super.frbInternalSseDecode(ptr, externalSizeOnNative, _kStaticData); + + static final _kStaticData = RustArcStaticData( + rustArcIncrementStrongCount: + RustLib.instance.api.rust_arc_increment_strong_count_BoxQuery, + rustArcDecrementStrongCount: + RustLib.instance.api.rust_arc_decrement_strong_count_BoxQuery, + rustArcDecrementStrongCountPtr: + RustLib.instance.api.rust_arc_decrement_strong_count_BoxQueryPtr, + ); +} + +@sealed +class IndexImpl extends RustOpaque implements Index { + // Not to be used by end users + IndexImpl.frbInternalDcoDecode(List wire) + : super.frbInternalDcoDecode(wire, _kStaticData); + + // Not to be used by end users + IndexImpl.frbInternalSseDecode(BigInt ptr, int externalSizeOnNative) + : super.frbInternalSseDecode(ptr, externalSizeOnNative, _kStaticData); + + static final _kStaticData = RustArcStaticData( + rustArcIncrementStrongCount: + RustLib.instance.api.rust_arc_increment_strong_count_Index, + rustArcDecrementStrongCount: + RustLib.instance.api.rust_arc_decrement_strong_count_Index, + rustArcDecrementStrongCountPtr: + RustLib.instance.api.rust_arc_decrement_strong_count_IndexPtr, + ); +} + +@sealed +class ReferenceSearchEngineImpl extends RustOpaque + implements ReferenceSearchEngine { + // Not to be used by end users + ReferenceSearchEngineImpl.frbInternalDcoDecode(List wire) + : super.frbInternalDcoDecode(wire, _kStaticData); + + // Not to be used by end users + ReferenceSearchEngineImpl.frbInternalSseDecode( + BigInt ptr, int externalSizeOnNative) + : super.frbInternalSseDecode(ptr, externalSizeOnNative, _kStaticData); + + static final _kStaticData = RustArcStaticData( + rustArcIncrementStrongCount: RustLib + .instance.api.rust_arc_increment_strong_count_ReferenceSearchEngine, + rustArcDecrementStrongCount: RustLib + .instance.api.rust_arc_decrement_strong_count_ReferenceSearchEngine, + rustArcDecrementStrongCountPtr: RustLib + .instance.api.rust_arc_decrement_strong_count_ReferenceSearchEnginePtr, + ); + + Future addDocument( + {required BigInt id, + required String title, + required String reference, + required String shortRef, + required BigInt segment, + required bool isPdf, + required String filePath}) => + RustLib.instance.api + .crateApiReferenceSearchEngineReferenceSearchEngineAddDocument( + that: this, + id: id, + title: title, + reference: reference, + shortRef: shortRef, + segment: segment, + isPdf: isPdf, + filePath: filePath); + + Future clear() => RustLib.instance.api + .crateApiReferenceSearchEngineReferenceSearchEngineClear( + that: this, + ); + + Future commit() => RustLib.instance.api + .crateApiReferenceSearchEngineReferenceSearchEngineCommit( + that: this, + ); + + Future count({required String query, required bool fuzzy}) => + RustLib.instance.api + .crateApiReferenceSearchEngineReferenceSearchEngineCount( + that: this, query: query, fuzzy: fuzzy); + + Future> search( + {required String query, + required int limit, + required bool fuzzy, + required ResultsOrder order}) => + RustLib.instance.api + .crateApiReferenceSearchEngineReferenceSearchEngineSearch( + that: this, + query: query, + limit: limit, + fuzzy: fuzzy, + order: order); +} + +@sealed +class SearchEngineImpl extends RustOpaque implements SearchEngine { + // Not to be used by end users + SearchEngineImpl.frbInternalDcoDecode(List wire) + : super.frbInternalDcoDecode(wire, _kStaticData); + + // Not to be used by end users + SearchEngineImpl.frbInternalSseDecode(BigInt ptr, int externalSizeOnNative) + : super.frbInternalSseDecode(ptr, externalSizeOnNative, _kStaticData); + + static final _kStaticData = RustArcStaticData( + rustArcIncrementStrongCount: + RustLib.instance.api.rust_arc_increment_strong_count_SearchEngine, + rustArcDecrementStrongCount: + RustLib.instance.api.rust_arc_decrement_strong_count_SearchEngine, + rustArcDecrementStrongCountPtr: + RustLib.instance.api.rust_arc_decrement_strong_count_SearchEnginePtr, + ); + + Future addDocument( + {required BigInt id, + required String title, + required String reference, + required String topics, + required String text, + required BigInt segment, + required bool isPdf, + required String filePath}) => + RustLib.instance.api.crateApiSearchEngineSearchEngineAddDocument( + that: this, + id: id, + title: title, + reference: reference, + topics: topics, + text: text, + segment: segment, + isPdf: isPdf, + filePath: filePath); + + Future clear() => + RustLib.instance.api.crateApiSearchEngineSearchEngineClear( + that: this, + ); + + Future commit() => + RustLib.instance.api.crateApiSearchEngineSearchEngineCommit( + that: this, + ); + + Future count( + {required List regexTerms, + required List facets, + required int slop, + required int maxExpansions}) => + RustLib.instance.api.crateApiSearchEngineSearchEngineCount( + that: this, + regexTerms: regexTerms, + facets: facets, + slop: slop, + maxExpansions: maxExpansions); + + Future removeDocumentsByTitle({required String title}) => + RustLib.instance.api + .crateApiSearchEngineSearchEngineRemoveDocumentsByTitle( + that: this, title: title); + + Future> search( + {required List regexTerms, + required List facets, + required int limit, + required int slop, + required int maxExpansions, + required ResultsOrder order}) => + RustLib.instance.api.crateApiSearchEngineSearchEngineSearch( + that: this, + regexTerms: regexTerms, + facets: facets, + limit: limit, + slop: slop, + maxExpansions: maxExpansions, + order: order); +} + + + +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'api/reference_search_engine.dart'; +import 'api/search_engine.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi' as ffi; +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart'; + +abstract class RustLibApiImplPlatform extends BaseApiImpl { + RustLibApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + CrossPlatformFinalizerArg get rust_arc_decrement_strong_count_BoxQueryPtr => wire + ._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQueryPtr; + + CrossPlatformFinalizerArg get rust_arc_decrement_strong_count_IndexPtr => wire + ._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndexPtr; + + CrossPlatformFinalizerArg + get rust_arc_decrement_strong_count_ReferenceSearchEnginePtr => wire + ._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEnginePtr; + + CrossPlatformFinalizerArg + get rust_arc_decrement_strong_count_SearchEnginePtr => wire + ._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEnginePtr; + + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw); + + @protected + BoxQuery + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + dynamic raw); + + @protected + ReferenceSearchEngine + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + dynamic raw); + + @protected + SearchEngine + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + dynamic raw); + + @protected + ReferenceSearchEngine + dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + dynamic raw); + + @protected + SearchEngine + dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + dynamic raw); + + @protected + Index + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + dynamic raw); + + @protected + ReferenceSearchEngine + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + dynamic raw); + + @protected + SearchEngine + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + dynamic raw); + + @protected + BoxQuery + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + dynamic raw); + + @protected + Index + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + dynamic raw); + + @protected + ReferenceSearchEngine + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + dynamic raw); + + @protected + SearchEngine + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + dynamic raw); + + @protected + String dco_decode_String(dynamic raw); + + @protected + bool dco_decode_bool(dynamic raw); + + @protected + int dco_decode_i_32(dynamic raw); + + @protected + List dco_decode_list_String(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + List dco_decode_list_reference_search_result( + dynamic raw); + + @protected + List dco_decode_list_search_result(dynamic raw); + + @protected + ReferenceSearchResult dco_decode_reference_search_result(dynamic raw); + + @protected + ResultsOrder dco_decode_results_order(dynamic raw); + + @protected + SearchResult dco_decode_search_result(dynamic raw); + + @protected + int dco_decode_u_32(dynamic raw); + + @protected + BigInt dco_decode_u_64(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + BigInt dco_decode_usize(dynamic raw); + + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer); + + @protected + BoxQuery + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + SseDeserializer deserializer); + + @protected + ReferenceSearchEngine + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + SseDeserializer deserializer); + + @protected + SearchEngine + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SseDeserializer deserializer); + + @protected + ReferenceSearchEngine + sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + SseDeserializer deserializer); + + @protected + SearchEngine + sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SseDeserializer deserializer); + + @protected + Index + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + SseDeserializer deserializer); + + @protected + ReferenceSearchEngine + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + SseDeserializer deserializer); + + @protected + SearchEngine + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SseDeserializer deserializer); + + @protected + BoxQuery + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + SseDeserializer deserializer); + + @protected + Index + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + SseDeserializer deserializer); + + @protected + ReferenceSearchEngine + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + SseDeserializer deserializer); + + @protected + SearchEngine + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SseDeserializer deserializer); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + List sse_decode_list_String(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + List sse_decode_list_reference_search_result( + SseDeserializer deserializer); + + @protected + List sse_decode_list_search_result( + SseDeserializer deserializer); + + @protected + ReferenceSearchResult sse_decode_reference_search_result( + SseDeserializer deserializer); + + @protected + ResultsOrder sse_decode_results_order(SseDeserializer deserializer); + + @protected + SearchResult sse_decode_search_result(SseDeserializer deserializer); + + @protected + int sse_decode_u_32(SseDeserializer deserializer); + + @protected + BigInt sse_decode_u_64(SseDeserializer deserializer); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + BigInt sse_decode_usize(SseDeserializer deserializer); + + @protected + void sse_encode_AnyhowException( + AnyhowException self, SseSerializer serializer); + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + BoxQuery self, SseSerializer serializer); + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + ReferenceSearchEngine self, SseSerializer serializer); + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SearchEngine self, SseSerializer serializer); + + @protected + void + sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + ReferenceSearchEngine self, SseSerializer serializer); + + @protected + void + sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SearchEngine self, SseSerializer serializer); + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + Index self, SseSerializer serializer); + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + ReferenceSearchEngine self, SseSerializer serializer); + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SearchEngine self, SseSerializer serializer); + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + BoxQuery self, SseSerializer serializer); + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + Index self, SseSerializer serializer); + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + ReferenceSearchEngine self, SseSerializer serializer); + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SearchEngine self, SseSerializer serializer); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); + + @protected + void sse_encode_list_String(List self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer); + + @protected + void sse_encode_list_reference_search_result( + List self, SseSerializer serializer); + + @protected + void sse_encode_list_search_result( + List self, SseSerializer serializer); + + @protected + void sse_encode_reference_search_result( + ReferenceSearchResult self, SseSerializer serializer); + + @protected + void sse_encode_results_order(ResultsOrder self, SseSerializer serializer); + + @protected + void sse_encode_search_result(SearchResult self, SseSerializer serializer); + + @protected + void sse_encode_u_32(int self, SseSerializer serializer); + + @protected + void sse_encode_u_64(BigInt self, SseSerializer serializer); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); + + @protected + void sse_encode_usize(BigInt self, SseSerializer serializer); +} + +// Section: wire_class + +class RustLibWire implements BaseWire { + factory RustLibWire.fromExternalLibrary(ExternalLibrary lib) => + RustLibWire(lib.ffiDynamicLibrary); + + /// Holds the symbol lookup function. + final ffi.Pointer Function(String symbolName) + _lookup; + + /// The symbols are looked up in [dynamicLibrary]. + RustLibWire(ffi.DynamicLibrary dynamicLibrary) + : _lookup = dynamicLibrary.lookup; + + void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + ffi.Pointer ptr, + ) { + return _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + ptr, + ); + } + + late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQueryPtr = + _lookup)>>( + 'frbgen_search_engine_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery'); + late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery = + _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQueryPtr + .asFunction)>(); + + void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + ffi.Pointer ptr, + ) { + return _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + ptr, + ); + } + + late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQueryPtr = + _lookup)>>( + 'frbgen_search_engine_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery'); + late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery = + _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQueryPtr + .asFunction)>(); + + void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + ffi.Pointer ptr, + ) { + return _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + ptr, + ); + } + + late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndexPtr = + _lookup)>>( + 'frbgen_search_engine_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex'); + late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex = + _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndexPtr + .asFunction)>(); + + void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + ffi.Pointer ptr, + ) { + return _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + ptr, + ); + } + + late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndexPtr = + _lookup)>>( + 'frbgen_search_engine_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex'); + late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex = + _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndexPtr + .asFunction)>(); + + void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + ffi.Pointer ptr, + ) { + return _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + ptr, + ); + } + + late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEnginePtr = + _lookup)>>( + 'frbgen_search_engine_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine'); + late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine = + _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEnginePtr + .asFunction)>(); + + void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + ffi.Pointer ptr, + ) { + return _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + ptr, + ); + } + + late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEnginePtr = + _lookup)>>( + 'frbgen_search_engine_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine'); + late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine = + _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEnginePtr + .asFunction)>(); + + void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + ffi.Pointer ptr, + ) { + return _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + ptr, + ); + } + + late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEnginePtr = + _lookup)>>( + 'frbgen_search_engine_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine'); + late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine = + _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEnginePtr + .asFunction)>(); + + void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + ffi.Pointer ptr, + ) { + return _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + ptr, + ); + } + + late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEnginePtr = + _lookup)>>( + 'frbgen_search_engine_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine'); + late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine = + _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEnginePtr + .asFunction)>(); +} + + + +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +// Static analysis wrongly picks the IO variant, thus ignore this +// ignore_for_file: argument_type_not_assignable + +import 'api/reference_search_engine.dart'; +import 'api/search_engine.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart'; + +abstract class RustLibApiImplPlatform extends BaseApiImpl { + RustLibApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + CrossPlatformFinalizerArg get rust_arc_decrement_strong_count_BoxQueryPtr => wire + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery; + + CrossPlatformFinalizerArg get rust_arc_decrement_strong_count_IndexPtr => wire + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex; + + CrossPlatformFinalizerArg + get rust_arc_decrement_strong_count_ReferenceSearchEnginePtr => wire + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine; + + CrossPlatformFinalizerArg + get rust_arc_decrement_strong_count_SearchEnginePtr => wire + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine; + + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw); + + @protected + BoxQuery + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + dynamic raw); + + @protected + ReferenceSearchEngine + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + dynamic raw); + + @protected + SearchEngine + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + dynamic raw); + + @protected + ReferenceSearchEngine + dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + dynamic raw); + + @protected + SearchEngine + dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + dynamic raw); + + @protected + Index + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + dynamic raw); + + @protected + ReferenceSearchEngine + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + dynamic raw); + + @protected + SearchEngine + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + dynamic raw); + + @protected + BoxQuery + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + dynamic raw); + + @protected + Index + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + dynamic raw); + + @protected + ReferenceSearchEngine + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + dynamic raw); + + @protected + SearchEngine + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + dynamic raw); + + @protected + String dco_decode_String(dynamic raw); + + @protected + bool dco_decode_bool(dynamic raw); + + @protected + int dco_decode_i_32(dynamic raw); + + @protected + List dco_decode_list_String(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + List dco_decode_list_reference_search_result( + dynamic raw); + + @protected + List dco_decode_list_search_result(dynamic raw); + + @protected + ReferenceSearchResult dco_decode_reference_search_result(dynamic raw); + + @protected + ResultsOrder dco_decode_results_order(dynamic raw); + + @protected + SearchResult dco_decode_search_result(dynamic raw); + + @protected + int dco_decode_u_32(dynamic raw); + + @protected + BigInt dco_decode_u_64(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + BigInt dco_decode_usize(dynamic raw); + + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer); + + @protected + BoxQuery + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + SseDeserializer deserializer); + + @protected + ReferenceSearchEngine + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + SseDeserializer deserializer); + + @protected + SearchEngine + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SseDeserializer deserializer); + + @protected + ReferenceSearchEngine + sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + SseDeserializer deserializer); + + @protected + SearchEngine + sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SseDeserializer deserializer); + + @protected + Index + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + SseDeserializer deserializer); + + @protected + ReferenceSearchEngine + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + SseDeserializer deserializer); + + @protected + SearchEngine + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SseDeserializer deserializer); + + @protected + BoxQuery + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + SseDeserializer deserializer); + + @protected + Index + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + SseDeserializer deserializer); + + @protected + ReferenceSearchEngine + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + SseDeserializer deserializer); + + @protected + SearchEngine + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SseDeserializer deserializer); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + List sse_decode_list_String(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + List sse_decode_list_reference_search_result( + SseDeserializer deserializer); + + @protected + List sse_decode_list_search_result( + SseDeserializer deserializer); + + @protected + ReferenceSearchResult sse_decode_reference_search_result( + SseDeserializer deserializer); + + @protected + ResultsOrder sse_decode_results_order(SseDeserializer deserializer); + + @protected + SearchResult sse_decode_search_result(SseDeserializer deserializer); + + @protected + int sse_decode_u_32(SseDeserializer deserializer); + + @protected + BigInt sse_decode_u_64(SseDeserializer deserializer); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + BigInt sse_decode_usize(SseDeserializer deserializer); + + @protected + void sse_encode_AnyhowException( + AnyhowException self, SseSerializer serializer); + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + BoxQuery self, SseSerializer serializer); + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + ReferenceSearchEngine self, SseSerializer serializer); + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SearchEngine self, SseSerializer serializer); + + @protected + void + sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + ReferenceSearchEngine self, SseSerializer serializer); + + @protected + void + sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SearchEngine self, SseSerializer serializer); + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + Index self, SseSerializer serializer); + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + ReferenceSearchEngine self, SseSerializer serializer); + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SearchEngine self, SseSerializer serializer); + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + BoxQuery self, SseSerializer serializer); + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + Index self, SseSerializer serializer); + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + ReferenceSearchEngine self, SseSerializer serializer); + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + SearchEngine self, SseSerializer serializer); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); + + @protected + void sse_encode_list_String(List self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer); + + @protected + void sse_encode_list_reference_search_result( + List self, SseSerializer serializer); + + @protected + void sse_encode_list_search_result( + List self, SseSerializer serializer); + + @protected + void sse_encode_reference_search_result( + ReferenceSearchResult self, SseSerializer serializer); + + @protected + void sse_encode_results_order(ResultsOrder self, SseSerializer serializer); + + @protected + void sse_encode_search_result(SearchResult self, SseSerializer serializer); + + @protected + void sse_encode_u_32(int self, SseSerializer serializer); + + @protected + void sse_encode_u_64(BigInt self, SseSerializer serializer); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); + + @protected + void sse_encode_usize(BigInt self, SseSerializer serializer); +} + +// Section: wire_class + +class RustLibWire implements BaseWire { + RustLibWire.fromExternalLibrary(ExternalLibrary lib); + + void rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + int ptr) => + wasmModule + .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + ptr); + + void rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + int ptr) => + wasmModule + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + ptr); + + void rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + int ptr) => + wasmModule + .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + ptr); + + void rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + int ptr) => + wasmModule + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + ptr); + + void rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + int ptr) => + wasmModule + .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + ptr); + + void rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + int ptr) => + wasmModule + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + ptr); + + void rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + int ptr) => + wasmModule + .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + ptr); + + void rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + int ptr) => + wasmModule + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + ptr); +} + +@JS('wasm_bindgen') +external RustLibWasmModule get wasmModule; + +@JS() +@anonymous +extension type RustLibWasmModule._(JSObject _) implements JSObject { + external void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + int ptr); + + external void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( + int ptr); + + external void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + int ptr); + + external void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( + int ptr); + + external void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + int ptr); + + external void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( + int ptr); + + external void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + int ptr); + + external void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( + int ptr); +} + + + From f96f120d124f5b2a12ecb776d34cc86b4409ad40 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 29 Jul 2025 14:54:09 +0300 Subject: [PATCH 041/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=9E?= =?UTF-8?q?=D7=A8=D7=95=D7=95=D7=97=20=D7=91=D7=99=D7=9F=20=D7=9E=D7=99?= =?UTF-8?q?=D7=9C=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data_providers/tantivy_data_provider.dart | 40 +++++--- lib/search/bloc/search_bloc.dart | 1 + lib/search/bloc/search_event.dart | 3 +- lib/search/search_repository.dart | 95 +++++++++++++------ lib/search/view/enhanced_search_field.dart | 7 +- .../view/full_text_settings_widgets.dart | 45 ++++++++- lib/tabs/models/searching_tab.dart | 4 + 7 files changed, 142 insertions(+), 53 deletions(-) diff --git a/lib/data/data_providers/tantivy_data_provider.dart b/lib/data/data_providers/tantivy_data_provider.dart index a34880c7c..234c5f1b1 100644 --- a/lib/data/data_providers/tantivy_data_provider.dart +++ b/lib/data/data_providers/tantivy_data_provider.dart @@ -52,22 +52,22 @@ class TantivyDataProvider { //test the engine engine.then((value) { try { - print('🧪 בודק מנוע חיפוש...'); + // Test the search engine value .search( regexTerms: ['a'], limit: 10, slop: 0, - maxExpansions: 0, + maxExpansions: 10, facets: ["/"], order: ResultsOrder.catalogue) .then((results) { - print('🧪 בדיקת מנוע הצליחה - נמצאו ${results.length} תוצאות'); + // Engine test successful }).catchError((e) { - print('❌ שגיאה בבדיקת מנוע: $e'); + // Log engine test error }); } catch (e) { - print('❌ שגיאה בבדיקת מנוע (sync): $e'); + // Log sync engine test error if (e.toString() == "PanicException(Failed to create index: SchemaError(\"An index exists but the schema does not match.\"))") { resetIndex(indexPath); @@ -106,33 +106,45 @@ class TantivyDataProvider { {bool fuzzy = false, int distance = 2}) async { final index = await engine; - print('🔢 CountTexts: מתחיל ספירה'); - print('🔢 Query: "$query"'); - print('🔢 Facets: $facets'); + // Debug: CountTexts for "$query" // המרת החיפוש הפשוט לפורמט החדש - ללא רגקס אמיתי! List regexTerms; if (!fuzzy) { - // חיפוש מדוייק - ננסה בלי מירכאות תחילה - regexTerms = [query]; + // חיפוש מדוייק - נפצל למילים אם יש יותר ממילה אחת + final words = query.trim().split(RegExp(r'\s+')); + if (words.length > 1) { + regexTerms = words; + } else { + regexTerms = [query]; + } } else { // חיפוש מקורב - נשתמש במילים בודדות regexTerms = query.trim().split(RegExp(r'\s+')); } - print('🔢 RegexTerms: $regexTerms'); + // חישוב maxExpansions בהתבסס על סוג החיפוש + int maxExpansions; + if (fuzzy) { + maxExpansions = 50; // חיפוש מקורב + } else if (regexTerms.length > 1) { + maxExpansions = 100; // חיפוש של כמה מילים - צריך expansions גבוה יותר + } else { + maxExpansions = 10; // מילה אחת - expansions נמוך + } + + // Debug: RegexTerms: $regexTerms, MaxExpansions: $maxExpansions try { final count = await index.count( regexTerms: regexTerms, facets: facets, slop: distance, - maxExpansions: fuzzy ? 50 : 0); + maxExpansions: maxExpansions); - print('🔢 ספירה: נמצאו $count תוצאות'); return count; } catch (e) { - print('❌ שגיאה בספירה: $e'); + // Log error in production rethrow; } } diff --git a/lib/search/bloc/search_bloc.dart b/lib/search/bloc/search_bloc.dart index cbabe7ea4..5957066c7 100644 --- a/lib/search/bloc/search_bloc.dart +++ b/lib/search/bloc/search_bloc.dart @@ -71,6 +71,7 @@ class SearchBloc extends Bloc { fuzzy: state.fuzzy, distance: state.distance, order: state.sortBy, + customSpacing: event.customSpacing, ); emit(state.copyWith( diff --git a/lib/search/bloc/search_event.dart b/lib/search/bloc/search_event.dart index a248b2b6b..5931e6b1d 100644 --- a/lib/search/bloc/search_event.dart +++ b/lib/search/bloc/search_event.dart @@ -16,7 +16,8 @@ class ClearFilter extends SearchEvent { class UpdateSearchQuery extends SearchEvent { final String query; - UpdateSearchQuery(this.query); + final Map? customSpacing; + UpdateSearchQuery(this.query, {this.customSpacing}); } class UpdateDistance extends SearchEvent { diff --git a/lib/search/search_repository.dart b/lib/search/search_repository.dart index 693c5fb86..0e807e919 100644 --- a/lib/search/search_repository.dart +++ b/lib/search/search_repository.dart @@ -1,12 +1,15 @@ import 'package:otzaria/data/data_providers/tantivy_data_provider.dart'; import 'package:search_engine/search_engine.dart'; -/// Performs a synchronous search operation across indexed texts. +/// Performs a search operation across indexed texts. /// /// [query] The search query string -/// [books] List of book identifiers to search within +/// [facets] List of facets to search within /// [limit] Maximum number of results to return +/// [order] Sort order for results /// [fuzzy] Whether to perform fuzzy matching +/// [distance] Default distance between words (slop) +/// [customSpacing] Custom spacing between specific word pairs /// /// Returns a Future containing a list of search results /// @@ -15,43 +18,73 @@ class SearchRepository { String query, List facets, int limit, {ResultsOrder order = ResultsOrder.relevance, bool fuzzy = false, - int distance = 2}) async { - SearchEngine index; - - index = await TantivyDataProvider.instance.engine; + int distance = 2, + Map? customSpacing}) async { + final index = await TantivyDataProvider.instance.engine; + + // בדיקה אם יש מרווחים מותאמים אישית + final hasCustomSpacing = customSpacing != null && customSpacing.isNotEmpty; - print('🔍 SearchRepository: מתחיל חיפוש'); - print('🔍 Query: "$query"'); - print('🔍 Facets: $facets'); - print('🔍 Fuzzy: $fuzzy, Distance: $distance'); + // המרת החיפוש לפורמט המנוע החדש + final words = query.trim().split(RegExp(r'\s+')); + final List regexTerms; + final int effectiveSlop; - // המרת החיפוש הפשוט לפורמט החדש - ללא רגקס אמיתי! - List regexTerms; - if (!fuzzy) { - // חיפוש מדוייק - ננסה בלי מירכאות תחילה + if (fuzzy) { + // חיפוש מקורב - נשתמש במילים בודדות + regexTerms = words; + effectiveSlop = distance; + } else if (words.length == 1) { + // מילה אחת - חיפוש פשוט regexTerms = [query]; + effectiveSlop = 0; + } else if (hasCustomSpacing) { + // מרווחים מותאמים אישית + regexTerms = words; + effectiveSlop = _getMaxCustomSpacing(customSpacing, words.length); } else { - // חיפוש מקורב - נשתמש במילים בודדות - regexTerms = query.trim().split(RegExp(r'\s+')); + // חיפוש מדוייק של כמה מילים + regexTerms = words; + effectiveSlop = distance; } - print('🔍 RegexTerms: $regexTerms'); - print('🔍 Slop: $distance, MaxExpansions: ${fuzzy ? 50 : 0}'); + // חישוב maxExpansions בהתבסס על סוג החיפוש + final int maxExpansions = _calculateMaxExpansions(fuzzy, regexTerms.length); + + return await index.search( + regexTerms: regexTerms, + facets: facets, + limit: limit, + slop: effectiveSlop, + maxExpansions: maxExpansions, + order: order); + } + + /// מחשב את המרווח המקסימלי מהמרווחים המותאמים אישית + int _getMaxCustomSpacing(Map customSpacing, int wordCount) { + int maxSpacing = 0; - try { - final results = await index.search( - regexTerms: regexTerms, - facets: facets, - limit: limit, - slop: distance, - maxExpansions: fuzzy ? 50 : 0, - order: order); + for (int i = 0; i < wordCount - 1; i++) { + final spacingKey = '$i-${i + 1}'; + final customSpacingValue = customSpacing[spacingKey]; - print('🔍 תוצאות: נמצאו ${results.length} תוצאות'); - return results; - } catch (e) { - print('❌ שגיאה בחיפוש: $e'); - rethrow; + if (customSpacingValue != null && customSpacingValue.isNotEmpty) { + final spacingNum = int.tryParse(customSpacingValue) ?? 0; + maxSpacing = maxSpacing > spacingNum ? maxSpacing : spacingNum; + } + } + + return maxSpacing; + } + + /// מחשב את maxExpansions בהתבסס על סוג החיפוש + int _calculateMaxExpansions(bool fuzzy, int termCount) { + if (fuzzy) { + return 50; // חיפוש מקורב + } else if (termCount > 1) { + return 100; // חיפוש של כמה מילים - צריך expansions גבוה יותר + } else { + return 10; // מילה אחת - expansions נמוך } } } diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 77ef4f1da..784a54c0c 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -990,6 +990,7 @@ class _EnhancedSearchFieldState extends State { } // עדכון התצוגה widget.widget.tab.searchOptionsChanged.value++; + widget.widget.tab.spacingValuesChanged.value++; } @override @@ -1048,7 +1049,8 @@ class _EnhancedSearchFieldState extends State { }); }, onSubmitted: (e) { - context.read().add(UpdateSearchQuery(e)); + context.read().add(UpdateSearchQuery(e, + customSpacing: widget.widget.tab.spacingValues)); widget.widget.tab.isLeftPaneOpen.value = false; }, decoration: InputDecoration( @@ -1058,7 +1060,8 @@ class _EnhancedSearchFieldState extends State { prefixIcon: IconButton( onPressed: () { context.read().add(UpdateSearchQuery( - widget.widget.tab.queryController.text)); + widget.widget.tab.queryController.text, + customSpacing: widget.widget.tab.spacingValues)); }, icon: const Icon(Icons.search), ), diff --git a/lib/search/view/full_text_settings_widgets.dart b/lib/search/view/full_text_settings_widgets.dart index 8e9aaa902..dacada3bf 100644 --- a/lib/search/view/full_text_settings_widgets.dart +++ b/lib/search/view/full_text_settings_widgets.dart @@ -42,7 +42,7 @@ class FuzzyToggle extends StatelessWidget { } } -class FuzzyDistance extends StatelessWidget { +class FuzzyDistance extends StatefulWidget { const FuzzyDistance({ super.key, required this.tab, @@ -50,22 +50,57 @@ class FuzzyDistance extends StatelessWidget { final SearchingTab tab; + @override + State createState() => _FuzzyDistanceState(); +} + +class _FuzzyDistanceState extends State { + @override + void initState() { + super.initState(); + // מאזין לשינויים במרווחים המותאמים אישית + widget.tab.spacingValuesChanged.addListener(_onSpacingChanged); + } + + @override + void dispose() { + widget.tab.spacingValuesChanged.removeListener(_onSpacingChanged); + super.dispose(); + } + + void _onSpacingChanged() { + setState(() { + // עדכון התצוגה כשמשתמש משנה מרווחים + }); + } + @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { + // בדיקה אם יש מרווחים מותאמים אישית + final hasCustomSpacing = widget.tab.spacingValues.isNotEmpty; + final isEnabled = !state.fuzzy && !hasCustomSpacing; + return SizedBox( width: 200, child: Padding( padding: const EdgeInsets.all(16.0), child: SpinBox( - enabled: !state.fuzzy, - decoration: const InputDecoration(labelText: 'מרווח בין מילים'), + enabled: isEnabled, + decoration: InputDecoration( + labelText: hasCustomSpacing + ? 'מרווח בין מילים (מושבת - יש מרווחים מותאמים)' + : 'מרווח בין מילים', + labelStyle: TextStyle( + color: hasCustomSpacing ? Colors.grey : null, + ), + ), min: 0, max: 30, value: state.distance.toDouble(), - onChanged: (value) => - context.read().add(UpdateDistance(value.toInt())), + onChanged: isEnabled ? (value) => + context.read().add(UpdateDistance(value.toInt())) : null, ), ), ); diff --git a/lib/tabs/models/searching_tab.dart b/lib/tabs/models/searching_tab.dart index fc3c33446..65645b76a 100644 --- a/lib/tabs/models/searching_tab.dart +++ b/lib/tabs/models/searching_tab.dart @@ -31,6 +31,9 @@ class SearchingTab extends OpenedTab { // notifier לעדכון התצוגה כשמשתמש משנה מילים חילופיות final ValueNotifier alternativeWordsChanged = ValueNotifier(0); + // notifier לעדכון התצוגה כשמשתמש משנה מרווחים + final ValueNotifier spacingValuesChanged = ValueNotifier(0); + SearchingTab( super.title, String? searchText, @@ -50,6 +53,7 @@ class SearchingTab extends OpenedTab { searchFieldFocusNode.dispose(); searchOptionsChanged.dispose(); alternativeWordsChanged.dispose(); + spacingValuesChanged.dispose(); super.dispose(); } From d2b73caee3d1503898e6884ca532f15f39c5f537 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 29 Jul 2025 16:11:43 +0300 Subject: [PATCH 042/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=9E?= =?UTF-8?q?=D7=99=D7=9C=D7=99=D7=9D=20=D7=97=D7=99=D7=9C=D7=95=D7=A4=D7=99?= =?UTF-8?q?=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/bloc/search_bloc.dart | 1 + lib/search/bloc/search_event.dart | 3 +- lib/search/search_repository.dart | 65 +++++++++++++++++---- lib/search/view/enhanced_search_field.dart | 6 +- lib/search/view/tantivy_search_results.dart | 17 +++++- 5 files changed, 76 insertions(+), 16 deletions(-) diff --git a/lib/search/bloc/search_bloc.dart b/lib/search/bloc/search_bloc.dart index 5957066c7..546b3d86c 100644 --- a/lib/search/bloc/search_bloc.dart +++ b/lib/search/bloc/search_bloc.dart @@ -72,6 +72,7 @@ class SearchBloc extends Bloc { distance: state.distance, order: state.sortBy, customSpacing: event.customSpacing, + alternativeWords: event.alternativeWords, ); emit(state.copyWith( diff --git a/lib/search/bloc/search_event.dart b/lib/search/bloc/search_event.dart index 5931e6b1d..4c8cd9113 100644 --- a/lib/search/bloc/search_event.dart +++ b/lib/search/bloc/search_event.dart @@ -17,7 +17,8 @@ class ClearFilter extends SearchEvent { class UpdateSearchQuery extends SearchEvent { final String query; final Map? customSpacing; - UpdateSearchQuery(this.query, {this.customSpacing}); + final Map>? alternativeWords; + UpdateSearchQuery(this.query, {this.customSpacing, this.alternativeWords}); } class UpdateDistance extends SearchEvent { diff --git a/lib/search/search_repository.dart b/lib/search/search_repository.dart index 0e807e919..f451cd31f 100644 --- a/lib/search/search_repository.dart +++ b/lib/search/search_repository.dart @@ -10,6 +10,7 @@ import 'package:search_engine/search_engine.dart'; /// [fuzzy] Whether to perform fuzzy matching /// [distance] Default distance between words (slop) /// [customSpacing] Custom spacing between specific word pairs +/// [alternativeWords] Alternative words for each word position (OR queries) /// /// Returns a Future containing a list of search results /// @@ -19,18 +20,29 @@ class SearchRepository { {ResultsOrder order = ResultsOrder.relevance, bool fuzzy = false, int distance = 2, - Map? customSpacing}) async { + Map? customSpacing, + Map>? alternativeWords}) async { final index = await TantivyDataProvider.instance.engine; - - // בדיקה אם יש מרווחים מותאמים אישית + + // בדיקה אם יש מרווחים מותאמים אישית או מילים חילופיות final hasCustomSpacing = customSpacing != null && customSpacing.isNotEmpty; - + final hasAlternativeWords = + alternativeWords != null && alternativeWords.isNotEmpty; + // המרת החיפוש לפורמט המנוע החדש final words = query.trim().split(RegExp(r'\s+')); final List regexTerms; final int effectiveSlop; - - if (fuzzy) { + + if (hasAlternativeWords) { + // יש מילים חילופיות - נבנה OR queries + print('🔄 בונה query עם מילים חילופיות: $alternativeWords'); + regexTerms = _buildAlternativeWordsQuery(words, alternativeWords); + print('🔄 RegexTerms עם חלופות: $regexTerms'); + effectiveSlop = hasCustomSpacing + ? _getMaxCustomSpacing(customSpacing, words.length) + : (fuzzy ? distance : 0); + } else if (fuzzy) { // חיפוש מקורב - נשתמש במילים בודדות regexTerms = words; effectiveSlop = distance; @@ -47,10 +59,10 @@ class SearchRepository { regexTerms = words; effectiveSlop = distance; } - + // חישוב maxExpansions בהתבסס על סוג החיפוש final int maxExpansions = _calculateMaxExpansions(fuzzy, regexTerms.length); - + return await index.search( regexTerms: regexTerms, facets: facets, @@ -63,20 +75,51 @@ class SearchRepository { /// מחשב את המרווח המקסימלי מהמרווחים המותאמים אישית int _getMaxCustomSpacing(Map customSpacing, int wordCount) { int maxSpacing = 0; - + for (int i = 0; i < wordCount - 1; i++) { final spacingKey = '$i-${i + 1}'; final customSpacingValue = customSpacing[spacingKey]; - + if (customSpacingValue != null && customSpacingValue.isNotEmpty) { final spacingNum = int.tryParse(customSpacingValue) ?? 0; maxSpacing = maxSpacing > spacingNum ? maxSpacing : spacingNum; } } - + return maxSpacing; } + /// בונה query עם מילים חילופיות באמצעות רגקס + List _buildAlternativeWordsQuery( + List words, Map> alternativeWords) { + List regexTerms = []; + + for (int i = 0; i < words.length; i++) { + final word = words[i]; + final alternatives = alternativeWords[i]; + + if (alternatives != null && alternatives.isNotEmpty) { + // יש מילים חילופיות - נבנה רגקס עם OR + final allOptions = + [word, ...alternatives].where((w) => w.trim().isNotEmpty).toList(); + if (allOptions.isNotEmpty) { + // נבנה רגקס: (word1|word2|word3) + final regexPattern = '(${allOptions.join('|')})'; + regexTerms.add(regexPattern); + print('🔄 מילה $i עם חלופות: $regexPattern'); + } else { + // אם כל האפשרויות ריקות, נשתמש במילה המקורית + regexTerms.add(word); + } + } else { + // אין מילים חילופיות - מילה רגילה + regexTerms.add(word); + } + } + + return regexTerms; + } + /// מחשב את maxExpansions בהתבסס על סוג החיפוש int _calculateMaxExpansions(bool fuzzy, int termCount) { if (fuzzy) { diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 784a54c0c..2e4ca69d6 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -1050,7 +1050,8 @@ class _EnhancedSearchFieldState extends State { }, onSubmitted: (e) { context.read().add(UpdateSearchQuery(e, - customSpacing: widget.widget.tab.spacingValues)); + customSpacing: widget.widget.tab.spacingValues, + alternativeWords: widget.widget.tab.alternativeWords)); widget.widget.tab.isLeftPaneOpen.value = false; }, decoration: InputDecoration( @@ -1061,7 +1062,8 @@ class _EnhancedSearchFieldState extends State { onPressed: () { context.read().add(UpdateSearchQuery( widget.widget.tab.queryController.text, - customSpacing: widget.widget.tab.spacingValues)); + customSpacing: widget.widget.tab.spacingValues, + alternativeWords: widget.widget.tab.alternativeWords)); }, icon: const Icon(Icons.search), ), diff --git a/lib/search/view/tantivy_search_results.dart b/lib/search/view/tantivy_search_results.dart index 16e2eaf70..92723c4ea 100644 --- a/lib/search/view/tantivy_search_results.dart +++ b/lib/search/view/tantivy_search_results.dart @@ -57,13 +57,26 @@ class _TantivySearchResultsState extends State { final plainText = html_parser.parse(fullHtml).documentElement?.text.trim() ?? ''; - // 2. חילוץ מילות החיפוש - final searchTerms = query + // 2. חילוץ מילות החיפוש כולל מילים חילופיות + final originalWords = query .trim() .replaceAll(RegExp(r'[~"*\(\)]'), ' ') .split(RegExp(r'\s+')) .where((s) => s.isNotEmpty) .toList(); + + // הוספת מילים חילופיות למילות החיפוש + final searchTerms = []; + for (int i = 0; i < originalWords.length; i++) { + final word = originalWords[i]; + searchTerms.add(word); + + // הוספת מילים חילופיות אם יש + final alternatives = widget.tab.alternativeWords[i]; + if (alternatives != null && alternatives.isNotEmpty) { + searchTerms.addAll(alternatives); + } + } if (searchTerms.isEmpty || plainText.isEmpty) { return [TextSpan(text: plainText, style: defaultStyle)]; From e9f769d6f660f28944e421723cf84e5c7bd2f35c Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 29 Jul 2025 21:14:31 +0300 Subject: [PATCH 043/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=A7?= =?UTF-8?q?=D7=99=D7=93=D7=95=D7=9E=D7=95=D7=AA/=D7=A1=D7=99=D7=95=D7=9E?= =?UTF-8?q?=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/bloc/search_bloc.dart | 1 + lib/search/bloc/search_event.dart | 3 +- lib/search/search_repository.dart | 78 +++++++++++++++------- lib/search/view/enhanced_search_field.dart | 6 +- 4 files changed, 61 insertions(+), 27 deletions(-) diff --git a/lib/search/bloc/search_bloc.dart b/lib/search/bloc/search_bloc.dart index 546b3d86c..56ea7cc88 100644 --- a/lib/search/bloc/search_bloc.dart +++ b/lib/search/bloc/search_bloc.dart @@ -73,6 +73,7 @@ class SearchBloc extends Bloc { order: state.sortBy, customSpacing: event.customSpacing, alternativeWords: event.alternativeWords, + searchOptions: event.searchOptions, ); emit(state.copyWith( diff --git a/lib/search/bloc/search_event.dart b/lib/search/bloc/search_event.dart index 4c8cd9113..ad9894243 100644 --- a/lib/search/bloc/search_event.dart +++ b/lib/search/bloc/search_event.dart @@ -18,7 +18,8 @@ class UpdateSearchQuery extends SearchEvent { final String query; final Map? customSpacing; final Map>? alternativeWords; - UpdateSearchQuery(this.query, {this.customSpacing, this.alternativeWords}); + final Map>? searchOptions; + UpdateSearchQuery(this.query, {this.customSpacing, this.alternativeWords, this.searchOptions}); } class UpdateDistance extends SearchEvent { diff --git a/lib/search/search_repository.dart b/lib/search/search_repository.dart index f451cd31f..1c8748186 100644 --- a/lib/search/search_repository.dart +++ b/lib/search/search_repository.dart @@ -11,6 +11,7 @@ import 'package:search_engine/search_engine.dart'; /// [distance] Default distance between words (slop) /// [customSpacing] Custom spacing between specific word pairs /// [alternativeWords] Alternative words for each word position (OR queries) +/// [searchOptions] Search options for each word (prefixes, suffixes, etc.) /// /// Returns a Future containing a list of search results /// @@ -21,24 +22,29 @@ class SearchRepository { bool fuzzy = false, int distance = 2, Map? customSpacing, - Map>? alternativeWords}) async { + Map>? alternativeWords, + Map>? searchOptions}) async { final index = await TantivyDataProvider.instance.engine; - // בדיקה אם יש מרווחים מותאמים אישית או מילים חילופיות + // בדיקה אם יש מרווחים מותאמים אישית, מילים חילופיות או אפשרויות חיפוש final hasCustomSpacing = customSpacing != null && customSpacing.isNotEmpty; final hasAlternativeWords = alternativeWords != null && alternativeWords.isNotEmpty; + final hasSearchOptions = searchOptions != null && searchOptions.isNotEmpty; // המרת החיפוש לפורמט המנוע החדש final words = query.trim().split(RegExp(r'\s+')); final List regexTerms; final int effectiveSlop; - if (hasAlternativeWords) { - // יש מילים חילופיות - נבנה OR queries - print('🔄 בונה query עם מילים חילופיות: $alternativeWords'); - regexTerms = _buildAlternativeWordsQuery(words, alternativeWords); - print('🔄 RegexTerms עם חלופות: $regexTerms'); + if (hasAlternativeWords || hasSearchOptions) { + // יש מילים חילופיות או אפשרויות חיפוש - נבנה queries מתקדמים + print('🔄 בונה query מתקדם'); + if (hasAlternativeWords) print('🔄 מילים חילופיות: $alternativeWords'); + if (hasSearchOptions) print('🔄 אפשרויות חיפוש: $searchOptions'); + + regexTerms = _buildAdvancedQuery(words, alternativeWords, searchOptions); + print('🔄 RegexTerms מתקדם: $regexTerms'); effectiveSlop = hasCustomSpacing ? _getMaxCustomSpacing(customSpacing, words.length) : (fuzzy ? distance : 0); @@ -89,30 +95,54 @@ class SearchRepository { return maxSpacing; } - /// בונה query עם מילים חילופיות באמצעות רגקס - List _buildAlternativeWordsQuery( - List words, Map> alternativeWords) { + /// בונה query מתקדם עם מילים חילופיות ואפשרויות חיפוש + List _buildAdvancedQuery( + List words, + Map>? alternativeWords, + Map>? searchOptions) { List regexTerms = []; for (int i = 0; i < words.length; i++) { final word = words[i]; - final alternatives = alternativeWords[i]; + final wordKey = '${word}_$i'; + + // קבלת אפשרויות החיפוש למילה הזו + final wordOptions = searchOptions?[wordKey] ?? {}; + final hasPrefix = wordOptions['קידומות'] == true; + final hasSuffix = wordOptions['סיומות'] == true; + // קבלת מילים חילופיות + final alternatives = alternativeWords?[i]; + + // בניית רשימת כל האפשרויות (מילה מקורית + חלופות) + final allOptions = [word]; if (alternatives != null && alternatives.isNotEmpty) { - // יש מילים חילופיות - נבנה רגקס עם OR - final allOptions = - [word, ...alternatives].where((w) => w.trim().isNotEmpty).toList(); - if (allOptions.isNotEmpty) { - // נבנה רגקס: (word1|word2|word3) - final regexPattern = '(${allOptions.join('|')})'; - regexTerms.add(regexPattern); - print('🔄 מילה $i עם חלופות: $regexPattern'); - } else { - // אם כל האפשרויות ריקות, נשתמש במילה המקורית - regexTerms.add(word); - } + allOptions.addAll(alternatives); + } + + // סינון אפשרויות ריקות + final validOptions = + allOptions.where((w) => w.trim().isNotEmpty).toList(); + + if (validOptions.isNotEmpty) { + // בניית רגקס לכל אפשרות עם קידומות/סיומות + final regexOptions = validOptions.map((option) { + String pattern = option; + if (hasPrefix) pattern = '.*$pattern'; + if (hasSuffix) pattern = '$pattern.*'; + return pattern; + }).toList(); + + // בניית הרגקס הסופי + final regexPattern = regexOptions.length == 1 + ? regexOptions.first + : '(${regexOptions.join('|')})'; + + regexTerms.add(regexPattern); + print( + '🔄 מילה $i: $regexPattern (קידומות: $hasPrefix, סיומות: $hasSuffix)'); } else { - // אין מילים חילופיות - מילה רגילה + // fallback למילה המקורית regexTerms.add(word); } } diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 2e4ca69d6..9111fb22e 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -1051,7 +1051,8 @@ class _EnhancedSearchFieldState extends State { onSubmitted: (e) { context.read().add(UpdateSearchQuery(e, customSpacing: widget.widget.tab.spacingValues, - alternativeWords: widget.widget.tab.alternativeWords)); + alternativeWords: widget.widget.tab.alternativeWords, + searchOptions: widget.widget.tab.searchOptions)); widget.widget.tab.isLeftPaneOpen.value = false; }, decoration: InputDecoration( @@ -1063,7 +1064,8 @@ class _EnhancedSearchFieldState extends State { context.read().add(UpdateSearchQuery( widget.widget.tab.queryController.text, customSpacing: widget.widget.tab.spacingValues, - alternativeWords: widget.widget.tab.alternativeWords)); + alternativeWords: widget.widget.tab.alternativeWords, + searchOptions: widget.widget.tab.searchOptions)); }, icon: const Icon(Icons.search), ), From 412b92b7796ca9bb691ca942432d9d41954e6893 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 29 Jul 2025 22:56:17 +0300 Subject: [PATCH 044/197] =?UTF-8?q?=D7=A7=D7=9E=D7=A4=D7=95=D7=9C=20=D7=9C?= =?UTF-8?q?=D7=97=D7=99=D7=A4=D7=95=D7=A9=20=D7=94=D7=9E=D7=AA=D7=A7=D7=93?= =?UTF-8?q?=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/flutter.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index 3f5046df0..8c0f20996 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -10,6 +10,7 @@ on: - main - dev - dev_dev2 + - n-search2 pull_request: types: [opened, synchronize, reopened] workflow_dispatch: From de4c04e74f8dec2bbdfa8d6190fafd307b6763b6 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 30 Jul 2025 14:31:58 +0300 Subject: [PATCH 045/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=A7?= =?UTF-8?q?=D7=99=D7=93=D7=95=D7=9E=D7=95=D7=AA/=D7=A1=D7=99=D7=95=D7=9E?= =?UTF-8?q?=D7=95=D7=AA=20=D7=93=D7=A7=D7=93=D7=95=D7=A7=D7=99=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/hebrew_morphology_search.md | 80 ++++++++++ lib/search/search_repository.dart | 50 +++++-- lib/search/utils/hebrew_morphology.dart | 190 ++++++++++++++++++++++++ 3 files changed, 307 insertions(+), 13 deletions(-) create mode 100644 docs/hebrew_morphology_search.md create mode 100644 lib/search/utils/hebrew_morphology.dart diff --git a/docs/hebrew_morphology_search.md b/docs/hebrew_morphology_search.md new file mode 100644 index 000000000..92ad27e67 --- /dev/null +++ b/docs/hebrew_morphology_search.md @@ -0,0 +1,80 @@ +# חיפוש עם קידומות וסיומות דקדוקיות + +## סקירה כללית + +התכונה החדשה מאפשרת חיפוש מתקדם של מילים עבריות עם קידומות וסיומות דקדוקיות. זה מאפשר למצוא מילים בכל הצורות הדקדוקיות שלהן. + +## איך זה עובד + +### קידומות דקדוקיות בלבד +כאשר מסמנים **רק** "קידומות דקדוקיות" למילה, המערכת תחפש את המילה עם קידומות דקדוקיות בלבד (ללא סיומות): + +- **קידומות בסיסיות**: ד, ה, ו, ב, ל, מ, כ, ש +- **צירופי קידומות**: וה, וב, ול, ומ, וכ, שה, שב, של, שמ, שמה, שכ, דה, דב, דל, דמ, דמה, דכ, כב, כל, כש, כשה, לכ, לכש, ולכ, ולכש, ולכשה, מש, משה + +**דוגמה**: חיפוש המילה "ברא" עם קידומות דקדוקיות בלבד ימצא: +- ברא ✓ +- הברא ✓ +- וברא ✓ +- בברא ✓ +- לברא ✓ +- מברא ✓ +- כברא ✓ +- שברא ✓ +- **לא ימצא**: בראשית ✗, ויברא ✗ (כי אלה לא מתחילות בקידומת + "ברא") + +### סיומות דקדוקיות בלבד +כאשר מסמנים **רק** "סיומות דקדוקיות" למילה, המערכת תחפש את המילה עם סיומות דקדוקיות בלבד (ללא קידומות): + +- **סיומות ריבוי**: ים, ות +- **סיומות שייכות**: י, ך, ו, ה, נו, כם, כן, ם, ן +- **צירופי ריבוי + שייכות**: יי, יך, יו, יה, יא, ינו, יכם, יכן, יהם, יהן, יות +- **צירופים לנקבה רבות**: ותי, ותיך, ותיו, ותיה, ותינו, ותיכם, ותיכן, ותיהם, ותיהן + +**דוגמה**: חיפוש המילה "ברא" עם סיומות דקדוקיות בלבד ימצא: +- ברא ✓ +- בראים ✓ +- בראות ✓ +- בראי ✓ +- בראך ✓ +- בראו ✓ +- בראה ✓ +- בראנו ✓ +- **לא ימצא**: בראשית ✗, ויברא ✗ (כי אלה לא מתחילות ב"ברא" + סיומת) + +### שילוב קידומות וסיומות יחד +רק כאשר מסמנים **גם** קידומות **וגם** סיומות דקדוקיות לאותה מילה, המערכת תחפש את המילה עם כל השילובים האפשריים. + +**דוגמה**: חיפוש המילה "ברא" עם קידומות וסיומות דקדוקיות יחד ימצא: +- ברא ✓ (המילה עצמה) +- הברא ✓ (קידומת בלבד) +- בראים ✓ (סיומת בלבד) +- והבראים ✓ (קידומת + סיומת) +- לבראנו ✓ (קידומת + סיומת) +- בבראיהם ✓ (קידומת + סיומת) +- **עדיין לא ימצא**: בראשית ✗, ויברא ✗ (כי אלה לא בפורמט קידומת + "ברא" + סיומת) + +## איך להשתמש + +1. **הפעל חיפוש מתקדם**: לחץ על כפתור החיפוש המתקדם +2. **הקלד מילה**: הקלד את המילה שברצונך לחפש +3. **מקם את הסמן**: מקם את הסמן על המילה +4. **פתח אפשרויות**: לחץ על כפתור החץ למטה כדי לפתוח את מגירת האפשרויות +5. **בחר אפשרויות**: סמן "קידומות דקדוקיות" ו/או "סיומות דקדוקיות" +6. **בצע חיפוש**: לחץ Enter או על כפתור החיפוש + +## יתרונות + +- **חיפוש מקיף**: מוצא את המילה בכל הצורות הדקדוקיות שלה +- **חיסכון בזמן**: אין צורך לחפש כל צורה בנפרד +- **דיוק גבוה**: משתמש בדפוסי רגקס מדויקים המבוססים על הדקדוק העברי עם anchors (^$) +- **גמישות**: ניתן לבחור רק קידומות, רק סיומות, או שניהם יחד +- **דיוק מקסימלי**: כל אפשרות פועלת בנפרד - לא יהיו תוצאות לא רלוונטיות + +## הערות טכניות + +- המערכת יוצרת רשימות של כל הווריאציות האפשריות במקום להסתמך רק על רגקס +- הדפוסים מבוססים על הדקדוק העברי המסורתי ועודכנו לכלול את כל הקידומות והסיומות הנפוצות +- החיפוש מתבצע במנוע החיפוש Tantivy +- התכונה תומכת בשילוב עם תכונות חיפוש אחרות כמו מילים חילופיות ומרווחים מותאמים אישית +- **עדכון חדש**: נוספו קידומות עם ד' וסיומות נוספות (יא, יות) לפי המלצות מומחים \ No newline at end of file diff --git a/lib/search/search_repository.dart b/lib/search/search_repository.dart index 1c8748186..3062044d3 100644 --- a/lib/search/search_repository.dart +++ b/lib/search/search_repository.dart @@ -1,4 +1,5 @@ import 'package:otzaria/data/data_providers/tantivy_data_provider.dart'; +import 'package:otzaria/search/utils/hebrew_morphology.dart'; import 'package:search_engine/search_engine.dart'; /// Performs a search operation across indexed texts. @@ -110,6 +111,8 @@ class SearchRepository { final wordOptions = searchOptions?[wordKey] ?? {}; final hasPrefix = wordOptions['קידומות'] == true; final hasSuffix = wordOptions['סיומות'] == true; + final hasGrammaticalPrefixes = wordOptions['קידומות דקדוקיות'] == true; + final hasGrammaticalSuffixes = wordOptions['סיומות דקדוקיות'] == true; // קבלת מילים חילופיות final alternatives = alternativeWords?[i]; @@ -125,22 +128,43 @@ class SearchRepository { allOptions.where((w) => w.trim().isNotEmpty).toList(); if (validOptions.isNotEmpty) { - // בניית רגקס לכל אפשרות עם קידומות/סיומות - final regexOptions = validOptions.map((option) { - String pattern = option; - if (hasPrefix) pattern = '.*$pattern'; - if (hasSuffix) pattern = '$pattern.*'; - return pattern; - }).toList(); - - // בניית הרגקס הסופי - final regexPattern = regexOptions.length == 1 - ? regexOptions.first - : '(${regexOptions.join('|')})'; + // בניית רשימת כל האפשרויות לכל מילה + final allVariations = {}; + + for (final option in validOptions) { + // קביעת הווריאציות לפי האפשרויות שנבחרו + if (hasGrammaticalPrefixes && hasGrammaticalSuffixes) { + // שתי האפשרויות יחד + allVariations.addAll( + HebrewMorphology.generateFullMorphologicalVariations(option)); + } else if (hasGrammaticalPrefixes) { + // רק קידומות דקדוקיות + allVariations + .addAll(HebrewMorphology.generatePrefixVariations(option)); + } else if (hasGrammaticalSuffixes) { + // רק סיומות דקדוקיות + allVariations + .addAll(HebrewMorphology.generateSuffixVariations(option)); + } else if (hasPrefix) { + // קידומות רגילות + allVariations.add('.*${RegExp.escape(option)}'); + } else if (hasSuffix) { + // סיומות רגילות + allVariations.add('${RegExp.escape(option)}.*'); + } else { + // ללא אפשרויות מיוחדות + allVariations.add(option); + } + } + + // בניית הרגקס הסופי מכל הווריאציות + final regexPattern = allVariations.length == 1 + ? allVariations.first + : '(${allVariations.join('|')})'; regexTerms.add(regexPattern); print( - '🔄 מילה $i: $regexPattern (קידומות: $hasPrefix, סיומות: $hasSuffix)'); + '🔄 מילה $i: $regexPattern (קידומות: $hasPrefix, סיומות: $hasSuffix, קידומות דקדוקיות: $hasGrammaticalPrefixes, סיומות דקדוקיות: $hasGrammaticalSuffixes)'); } else { // fallback למילה המקורית regexTerms.add(word); diff --git a/lib/search/utils/hebrew_morphology.dart b/lib/search/utils/hebrew_morphology.dart new file mode 100644 index 000000000..c5193112d --- /dev/null +++ b/lib/search/utils/hebrew_morphology.dart @@ -0,0 +1,190 @@ +/// כלים לטיפול בקידומות וסיומות דקדוקיות בעברית +class HebrewMorphology { + // קידומות דקדוקיות בסיסיות + static const List _basicPrefixes = [ + 'ד', + 'ה', + 'ו', + 'ב', + 'ל', + 'מ', + 'כ', + 'ש' + ]; + + // צירופי קידומות נפוצים + static const List _combinedPrefixes = [ + // צירופים עם ו' + 'וה', 'וב', 'ול', 'ומ', 'וכ', + // צירופים עם ש' + 'שה', 'שב', 'של', 'שמ', 'שמה', 'שכ', + // צירופים עם ד' + 'דה', 'דב', 'דל', 'דמ', 'דמה', 'דכ', + // צירופים עם כ' + 'כב', 'כל', 'כש', 'כשה', + // צירופים עם ל' + 'לכ', 'לכש', + // צירופים מורכבים עם ו' + 'ולכ', 'ולכש', 'ולכשה', + // צירופים עם מ' + 'מש', 'משה' + ]; + + // סיומות דקדוקיות (מסודרות לפי אורך יורד) + static const List _allSuffixes = [ + // צירופים לנקבה רבות + שייכות (הארוכים ביותר) + 'ותיהם', 'ותיהן', 'ותיכם', 'ותיכן', 'ותינו', + 'ותֵיהם', 'ותֵיהן', 'ותֵיכם', 'ותֵיכן', 'ותֵינוּ', + 'ותיך', 'ותיו', 'ותיה', 'ותי', + 'ותֶיךָ', 'ותַיִךְ', 'ותָיו', 'ותֶיהָ', 'ותַי', + // צירופי ריבוי + שייכות + 'יהם', 'יהן', 'יכם', 'יכן', 'ינו', 'יות', 'יי', 'יך', 'יו', 'יה', 'יא', + 'יַי', 'יךָ', 'יִךְ', 'יהָ', + // סיומות בסיסיות + 'ים', 'ות', 'כם', 'כן', 'נו', 'הּ', + 'י', 'ך', 'ו', 'ה', 'ם', 'ן', + 'ךָ', 'ךְ' + ]; + + /// יוצר דפוס רגקס לחיפוש מילה עם קידומות דקדוקיות בלבד + static String createPrefixRegexPattern(String word) { + if (word.isEmpty) return word; + return r'(ו|מ|כ|ב|ש|ל|ה|ד)?(כ|ב|ש|ל|ה|ד)?(ה)?' + RegExp.escape(word); + } + + /// יוצר רשימה של כל האפשרויות עם קידומות דקדוקיות + static List generatePrefixVariations(String word) { + if (word.isEmpty) return [word]; + + final variations = {word}; // המילה המקורית + + // הוספת קידומות בסיסיות + for (final prefix in _basicPrefixes) { + variations.add('$prefix$word'); + } + + // הוספת צירופי קידומות + for (final prefix in _combinedPrefixes) { + variations.add('$prefix$word'); + } + + return variations.toList(); + } + + /// יוצר דפוס רגקס לחיפוש מילה עם סיומות דקדוקיות בלבד + static String createSuffixRegexPattern(String word) { + if (word.isEmpty) return word; + + // בונים דפוס מאוחד לכל הסיומות (מסודר לפי אורך יורד כדי לתפוס את הארוכות יותר קודם) + const suffixPattern = + r'(ותי|ותַי|ותיך|ותֶיךָ|ותַיִךְ|ותיו|ותָיו|ותיה|ותֶיהָ|ותינו|ותֵינוּ|ותיכם|ותֵיכם|ותיכן|ותֵיכן|ותיהם|ותֵיהם|ותיהן|ותֵיהן|יות|יי|יַי|יך|יךָ|יִךְ|יו|יה|יא|יהָ|ינו|יכם|יכן|יהם|יהן|י|ך|ךָ|ךְ|ו|ה|הּ|נו|כם|כן|ם|ן|ים|ות)?'; + + return RegExp.escape(word) + suffixPattern; + } + + /// יוצר רשימה של כל האפשרויות עם סיומות דקדוקיות + static List generateSuffixVariations(String word) { + if (word.isEmpty) return [word]; + + final variations = {word}; // המילה המקורית + + // הוספת כל הסיומות + for (final suffix in _allSuffixes) { + variations.add('$word$suffix'); + } + + return variations.toList(); + } + + /// יוצר דפוס רגקס לחיפוש מילה עם קידומות וסיומות דקדוקיות יחד + static String createFullMorphologicalRegexPattern( + String word, { + bool includePrefixes = true, + bool includeSuffixes = true, + }) { + if (word.isEmpty) return word; + + String pattern = RegExp.escape(word); + + if (includePrefixes) { + pattern = r'(ו|מ|כ|ב|ש|ל|ה|ד)?(כ|ב|ש|ל|ה|ד)?(ה)?' + pattern; + } + + if (includeSuffixes) { + const suffixPattern = + r'(ותי|ותַי|ותיך|ותֶיךָ|ותַיִךְ|ותיו|ותָיו|ותיה|ותֶיהָ|ותינו|ותֵינוּ|ותיכם|ותֵיכם|ותיכן|ותֵיכן|ותיהם|ותֵיהם|ותיהן|ותֵיהן|יות|יי|יַי|יך|יךָ|יִךְ|יו|יה|יא|יהָ|ינו|יכם|יכן|יהם|יהן|י|ך|ךָ|ךְ|ו|ה|הּ|נו|כם|כן|ם|ן|ים|ות)?'; + pattern = pattern + suffixPattern; + } + + return pattern; + } + + /// יוצר רשימה של כל האפשרויות עם קידומות וסיומות יחד + static List generateFullMorphologicalVariations(String word) { + if (word.isEmpty) return [word]; + + final variations = {word}; // המילה המקורית + + // קידומות בלבד + final prefixVariations = generatePrefixVariations(word); + variations.addAll(prefixVariations); + + // סיומות בלבד + final suffixVariations = generateSuffixVariations(word); + variations.addAll(suffixVariations); + + // שילובים של קידומות + סיומות + final allPrefixes = [''] + _basicPrefixes + _combinedPrefixes; + for (final prefix in allPrefixes) { + for (final suffix in _allSuffixes) { + variations.add('$prefix$word$suffix'); + } + } + + return variations.toList(); + } + + /// בודק אם מילה מכילה קידומת דקדוקית + static bool hasGrammaticalPrefix(String word) { + if (word.isEmpty) return false; + final regex = RegExp(r'^(ו|מ|כ|ב|ש|ל|ה)+(.+)'); + return regex.hasMatch(word); + } + + /// בודק אם מילה מכילה סיומת דקדוקית + static bool hasGrammaticalSuffix(String word) { + if (word.isEmpty) return false; + const suffixPattern = + r'(ותי|ותַי|ותיך|ותֶיךָ|ותַיִךְ|ותיו|ותָיו|ותיה|ותֶיהָ|ותינו|ותֵינוּ|ותיכם|ותֵיכם|ותיכן|ותֵיכן|ותיהם|ותֵיהם|ותיהן|ותֵיהן|יי|יַי|יך|יךָ|יִךְ|יו|יה|יהָ|ינו|יכם|יכן|יהם|יהן|י|ך|ךָ|ךְ|ו|ה|הּ|נו|כם|כן|ם|ן|ים|ות)$'; + final regex = RegExp(suffixPattern); + return regex.hasMatch(word); + } + + /// מחלץ את השורש של מילה (מסיר קידומות וסיומות) + static String extractRoot(String word) { + if (word.isEmpty) return word; + + String result = word; + + // מסירת קידומות + final prefixRegex = RegExp(r'^(ו|מ|כ|ב|ש|ל|ה)+'); + result = result.replaceFirst(prefixRegex, ''); + + // מסירת סיומות + const suffixPattern = + r'(ותי|ותַי|ותיך|ותֶיךָ|ותַיִךְ|ותיו|ותָיו|ותיה|ותֶיהָ|ותינו|ותֵינוּ|ותיכם|ותֵיכם|ותיכן|ותֵיכן|ותיהם|ותֵיהם|ותיהן|ותֵיהן|יי|יַי|יך|יךָ|יִךְ|יו|יה|יהָ|ינו|יכם|יכן|יהם|יהן|י|ך|ךָ|ךְ|ו|ה|הּ|נו|כם|כן|ם|ן|ים|ות)$'; + final suffixRegex = RegExp(suffixPattern); + result = result.replaceFirst(suffixRegex, ''); + + return result.isEmpty + ? word + : result; // אם נשאר ריק, נחזיר את המילה המקורית + } + + /// מחזיר רשימה של קידומות בסיסיות (לתמיכה לאחור) + static List getBasicPrefixes() => ['ה', 'ו', 'ב', 'ל', 'מ', 'כ', 'ש']; + + /// מחזיר רשימה של סיומות בסיסיות (לתמיכה לאחור) + static List getBasicSuffixes() => + ['ים', 'ות', 'י', 'ך', 'ו', 'ה', 'נו', 'כם', 'כן', 'ם', 'ן']; +} From e65942f0457f225de9269c65bf0f808e29bfd16a Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 30 Jul 2025 15:50:08 +0300 Subject: [PATCH 046/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=9B?= =?UTF-8?q?=D7=AA=D7=99=D7=91=20=D7=9E=D7=9C=D7=90/=D7=97=D7=A1=D7=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/hebrew_morphology_search.md | 26 +- lib/search/search_repository.dart | 64 +++-- lib/search/utils/hebrew_morphology.dart | 137 ++++++----- lib/search/view/tantivy_search_results.dart | 254 +++++++++++++------- 4 files changed, 301 insertions(+), 180 deletions(-) diff --git a/docs/hebrew_morphology_search.md b/docs/hebrew_morphology_search.md index 92ad27e67..8880aa835 100644 --- a/docs/hebrew_morphology_search.md +++ b/docs/hebrew_morphology_search.md @@ -54,13 +54,33 @@ - בבראיהם ✓ (קידומת + סיומת) - **עדיין לא ימצא**: בראשית ✗, ויברא ✗ (כי אלה לא בפורמט קידומת + "ברא" + סיומת) +### כתיב מלא/חסר +כאשר מסמנים "כתיב מלא/חסר" למילה, המערכת תחפש את המילה בכל הווריאציות האפשריות של נוכחות או היעדרות האותיות י', ו', וגרשיים. + +**איך זה עובד**: כל אות י', ו', או גרשיים במילה הופכת לאופציונלית - יכולה להיות נוכחת או חסרה. + +**דוגמאות**: +- חיפוש "כתיב" ימצא: כתב ✓, כתיב ✓ +- חיפוש "ויאמר" ימצא: אמר ✓, ואמר ✓, יאמר ✓, ויאמר ✓ +- חיפוש "בו" ימצא: ב ✓, בו ✓ +- חיפוש "שלם" ימצא: שלם ✓ (ללא שינוי כי אין י'/ו'/גרשיים) + +**שילוב עם אפשרויות אחרות**: ניתן לשלב כתיב מלא/חסר עם קידומות וסיומות דקדוקיות. במקרה זה, המערכת תיצור את כל הווריאציות של הכתיב ותחיל עליהן את האפשרויות הדקדוקיות. + +**הערה חשובה**: כתיב מלא/חסר משתמש ב-word boundaries מדויקים כדי למנוע התאמות חלקיות, בעוד שקידומות וסיומות דקדוקיות מחפשות בכל הטקסט (כפי שצריך להיות). + +**הדגשה אוטומטית**: כל הווריאציות של כתיב מלא/חסר מודגשות אוטומטית בתוצאות החיפוש בצבע אדום, כמו בחיפוש רגיל. + ## איך להשתמש 1. **הפעל חיפוש מתקדם**: לחץ על כפתור החיפוש המתקדם 2. **הקלד מילה**: הקלד את המילה שברצונך לחפש 3. **מקם את הסמן**: מקם את הסמן על המילה 4. **פתח אפשרויות**: לחץ על כפתור החץ למטה כדי לפתוח את מגירת האפשרויות -5. **בחר אפשרויות**: סמן "קידומות דקדוקיות" ו/או "סיומות דקדוקיות" +5. **בחר אפשרויות**: סמן את האפשרויות הרצויות: + - "קידומות דקדוקיות" - לחיפוש עם קידומות + - "סיומות דקדוקיות" - לחיפוש עם סיומות + - "כתיב מלא/חסר" - לחיפוש בכל וריאציות הכתיב 6. **בצע חיפוש**: לחץ Enter או על כפתור החיפוש ## יתרונות @@ -77,4 +97,6 @@ - הדפוסים מבוססים על הדקדוק העברי המסורתי ועודכנו לכלול את כל הקידומות והסיומות הנפוצות - החיפוש מתבצע במנוע החיפוש Tantivy - התכונה תומכת בשילוב עם תכונות חיפוש אחרות כמו מילים חילופיות ומרווחים מותאמים אישית -- **עדכון חדש**: נוספו קידומות עם ד' וסיומות נוספות (יא, יות) לפי המלצות מומחים \ No newline at end of file +- **עדכון חדש**: נוספו קידומות עם ד' וסיומות נוספות (יא, יות) לפי המלצות מומחים +- **תכונה חדשה**: כתיב מלא/חסר - מאפשר חיפוש בכל וריאציות הכתיב של י', ו', וגרשיים +- **תיקון חשוב**: שימוש ב-word boundaries מתקדמים רק עבור "כתיב מלא/חסר" למניעת התאמות חלקיות (למשל "חי" לא ימצא ב"אחיו" או "מזבח") \ No newline at end of file diff --git a/lib/search/search_repository.dart b/lib/search/search_repository.dart index 3062044d3..e7605d3b9 100644 --- a/lib/search/search_repository.dart +++ b/lib/search/search_repository.dart @@ -113,6 +113,7 @@ class SearchRepository { final hasSuffix = wordOptions['סיומות'] == true; final hasGrammaticalPrefixes = wordOptions['קידומות דקדוקיות'] == true; final hasGrammaticalSuffixes = wordOptions['סיומות דקדוקיות'] == true; + final hasFullPartialSpelling = wordOptions['כתיב מלא/חסר'] == true; // קבלת מילים חילופיות final alternatives = alternativeWords?[i]; @@ -132,39 +133,50 @@ class SearchRepository { final allVariations = {}; for (final option in validOptions) { - // קביעת הווריאציות לפי האפשרויות שנבחרו - if (hasGrammaticalPrefixes && hasGrammaticalSuffixes) { - // שתי האפשרויות יחד - allVariations.addAll( - HebrewMorphology.generateFullMorphologicalVariations(option)); - } else if (hasGrammaticalPrefixes) { - // רק קידומות דקדוקיות - allVariations - .addAll(HebrewMorphology.generatePrefixVariations(option)); - } else if (hasGrammaticalSuffixes) { - // רק סיומות דקדוקיות - allVariations - .addAll(HebrewMorphology.generateSuffixVariations(option)); - } else if (hasPrefix) { - // קידומות רגילות - allVariations.add('.*${RegExp.escape(option)}'); - } else if (hasSuffix) { - // סיומות רגילות - allVariations.add('${RegExp.escape(option)}.*'); - } else { - // ללא אפשרויות מיוחדות - allVariations.add(option); + List baseVariations = [option]; + + // אם יש כתיב מלא/חסר, נוצר את כל הווריאציות של כתיב + if (hasFullPartialSpelling) { + baseVariations = + HebrewMorphology.generateFullPartialSpellingVariations(option); + } + + // עבור כל וריאציה של כתיב, מוסיפים את האפשרויות הדקדוקיות + for (final baseVariation in baseVariations) { + if (hasGrammaticalPrefixes && hasGrammaticalSuffixes) { + // שתי האפשרויות יחד + allVariations.addAll( + HebrewMorphology.generateFullMorphologicalVariations( + baseVariation)); + } else if (hasGrammaticalPrefixes) { + // רק קידומות דקדוקיות + allVariations.addAll( + HebrewMorphology.generatePrefixVariations(baseVariation)); + } else if (hasGrammaticalSuffixes) { + // רק סיומות דקדוקיות + allVariations.addAll( + HebrewMorphology.generateSuffixVariations(baseVariation)); + } else if (hasPrefix) { + // קידומות רגילות + allVariations.add('.*' + RegExp.escape(baseVariation)); + } else if (hasSuffix) { + // סיומות רגילות + allVariations.add(RegExp.escape(baseVariation) + '.*'); + } else { + // ללא אפשרויות מיוחדות - מילה מדויקת + allVariations.add(RegExp.escape(baseVariation)); + } } } - // בניית הרגקס הסופי מכל הווריאציות - final regexPattern = allVariations.length == 1 + // במקום רגקס מורכב, נוסיף כל וריאציה בנפרד + final finalPattern = allVariations.length == 1 ? allVariations.first : '(${allVariations.join('|')})'; - regexTerms.add(regexPattern); + regexTerms.add(finalPattern); print( - '🔄 מילה $i: $regexPattern (קידומות: $hasPrefix, סיומות: $hasSuffix, קידומות דקדוקיות: $hasGrammaticalPrefixes, סיומות דקדוקיות: $hasGrammaticalSuffixes)'); + '🔄 מילה $i: $finalPattern (קידומות: $hasPrefix, סיומות: $hasSuffix, קידומות דקדוקיות: $hasGrammaticalPrefixes, סיומות דקדוקיות: $hasGrammaticalSuffixes, כתיב מלא/חסר: $hasFullPartialSpelling)'); } else { // fallback למילה המקורית regexTerms.add(word); diff --git a/lib/search/utils/hebrew_morphology.dart b/lib/search/utils/hebrew_morphology.dart index c5193112d..98c88fda1 100644 --- a/lib/search/utils/hebrew_morphology.dart +++ b/lib/search/utils/hebrew_morphology.dart @@ -1,4 +1,4 @@ -/// כלים לטיפול בקידומות וסיומות דקדוקיות בעברית +/// כלים לטיפול בקידומות, סיומות וכתיב מלא/חסר בעברית (גרסה משולבת ומשופרת) class HebrewMorphology { // קידומות דקדוקיות בסיסיות static const List _basicPrefixes = [ @@ -46,57 +46,27 @@ class HebrewMorphology { 'ךָ', 'ךְ' ]; - /// יוצר דפוס רגקס לחיפוש מילה עם קידומות דקדוקיות בלבד + // --- מתודות ליצירת Regex (מהקוד הראשון - היעיל יותר) --- + + /// יוצר דפוס רגקס קומפקטי לחיפוש מילה עם קידומות דקדוקיות static String createPrefixRegexPattern(String word) { if (word.isEmpty) return word; + // שימוש בתבנית קבועה ויעילה return r'(ו|מ|כ|ב|ש|ל|ה|ד)?(כ|ב|ש|ל|ה|ד)?(ה)?' + RegExp.escape(word); } - /// יוצר רשימה של כל האפשרויות עם קידומות דקדוקיות - static List generatePrefixVariations(String word) { - if (word.isEmpty) return [word]; - - final variations = {word}; // המילה המקורית - - // הוספת קידומות בסיסיות - for (final prefix in _basicPrefixes) { - variations.add('$prefix$word'); - } - - // הוספת צירופי קידומות - for (final prefix in _combinedPrefixes) { - variations.add('$prefix$word'); - } - - return variations.toList(); - } - - /// יוצר דפוס רגקס לחיפוש מילה עם סיומות דקדוקיות בלבד + /// יוצר דפוס רגקס קומפקטי לחיפוש מילה עם סיומות דקדוקיות static String createSuffixRegexPattern(String word) { if (word.isEmpty) return word; - // בונים דפוס מאוחד לכל הסיומות (מסודר לפי אורך יורד כדי לתפוס את הארוכות יותר קודם) + // שימוש בתבנית קבועה ויעילה, מסודרת לפי אורך const suffixPattern = r'(ותי|ותַי|ותיך|ותֶיךָ|ותַיִךְ|ותיו|ותָיו|ותיה|ותֶיהָ|ותינו|ותֵינוּ|ותיכם|ותֵיכם|ותיכן|ותֵיכן|ותיהם|ותֵיהם|ותיהן|ותֵיהן|יות|יי|יַי|יך|יךָ|יִךְ|יו|יה|יא|יהָ|ינו|יכם|יכן|יהם|יהן|י|ך|ךָ|ךְ|ו|ה|הּ|נו|כם|כן|ם|ן|ים|ות)?'; return RegExp.escape(word) + suffixPattern; } - /// יוצר רשימה של כל האפשרויות עם סיומות דקדוקיות - static List generateSuffixVariations(String word) { - if (word.isEmpty) return [word]; - - final variations = {word}; // המילה המקורית - - // הוספת כל הסיומות - for (final suffix in _allSuffixes) { - variations.add('$word$suffix'); - } - - return variations.toList(); - } - - /// יוצר דפוס רגקס לחיפוש מילה עם קידומות וסיומות דקדוקיות יחד + /// יוצר דפוס רגקס קומפקטי לחיפוש מילה עם קידומות וסיומות דקדוקיות יחד static String createFullMorphologicalRegexPattern( String word, { bool includePrefixes = true, @@ -107,10 +77,12 @@ class HebrewMorphology { String pattern = RegExp.escape(word); if (includePrefixes) { + // שימוש בתבנית קבועה ויעילה pattern = r'(ו|מ|כ|ב|ש|ל|ה|ד)?(כ|ב|ש|ל|ה|ד)?(ה)?' + pattern; } if (includeSuffixes) { + // שימוש בתבנית קבועה ויעילה const suffixPattern = r'(ותי|ותַי|ותיך|ותֶיךָ|ותַיִךְ|ותיו|ותָיו|ותיה|ותֶיהָ|ותינו|ותֵינוּ|ותיכם|ותֵיכם|ותיכן|ותֵיכן|ותיהם|ותֵיהם|ותיהן|ותֵיהן|יות|יי|יַי|יך|יךָ|יִךְ|יו|יה|יא|יהָ|ינו|יכם|יכן|יהם|יהן|י|ך|ךָ|ךְ|ו|ה|הּ|נו|כם|כן|ם|ן|ים|ות)?'; pattern = pattern + suffixPattern; @@ -119,31 +91,40 @@ class HebrewMorphology { return pattern; } - /// יוצר רשימה של כל האפשרויות עם קידומות וסיומות יחד - static List generateFullMorphologicalVariations(String word) { - if (word.isEmpty) return [word]; + // --- מתודות ליצירת רשימות וריאציות (נשמרו כפי שהן) --- - final variations = {word}; // המילה המקורית - - // קידומות בלבד - final prefixVariations = generatePrefixVariations(word); - variations.addAll(prefixVariations); + /// יוצר רשימה של כל האפשרויות עם קידומות דקדוקיות + static List generatePrefixVariations(String word) { + if (word.isEmpty) return [word]; + final variations = {word}; + variations.addAll(_basicPrefixes.map((p) => '$p$word')); + variations.addAll(_combinedPrefixes.map((p) => '$p$word')); + return variations.toList(); + } - // סיומות בלבד - final suffixVariations = generateSuffixVariations(word); - variations.addAll(suffixVariations); + /// יוצר רשימה של כל האפשרויות עם סיומות דקדוקיות + static List generateSuffixVariations(String word) { + if (word.isEmpty) return [word]; + final variations = {word}; + variations.addAll(_allSuffixes.map((s) => '$word$s')); + return variations.toList(); + } - // שילובים של קידומות + סיומות + /// יוצר רשימה של כל האפשרויות עם קידומות וסיומות יחד + static List generateFullMorphologicalVariations(String word) { + if (word.isEmpty) return [word]; + final variations = {word}; final allPrefixes = [''] + _basicPrefixes + _combinedPrefixes; for (final prefix in allPrefixes) { for (final suffix in _allSuffixes) { variations.add('$prefix$word$suffix'); } } - return variations.toList(); } + // --- מתודות שירות (נשארו כפי שהן) --- + /// בודק אם מילה מכילה קידומת דקדוקית static bool hasGrammaticalPrefix(String word) { if (word.isEmpty) return false; @@ -163,22 +144,14 @@ class HebrewMorphology { /// מחלץ את השורש של מילה (מסיר קידומות וסיומות) static String extractRoot(String word) { if (word.isEmpty) return word; - String result = word; - - // מסירת קידומות final prefixRegex = RegExp(r'^(ו|מ|כ|ב|ש|ל|ה)+'); result = result.replaceFirst(prefixRegex, ''); - - // מסירת סיומות const suffixPattern = r'(ותי|ותַי|ותיך|ותֶיךָ|ותַיִךְ|ותיו|ותָיו|ותיה|ותֶיהָ|ותינו|ותֵינוּ|ותיכם|ותֵיכם|ותיכן|ותֵיכן|ותיהם|ותֵיהם|ותיהן|ותֵיהן|יי|יַי|יך|יךָ|יִךְ|יו|יה|יהָ|ינו|יכם|יכן|יהם|יהן|י|ך|ךָ|ךְ|ו|ה|הּ|נו|כם|כן|ם|ן|ים|ות)$'; final suffixRegex = RegExp(suffixPattern); result = result.replaceFirst(suffixRegex, ''); - - return result.isEmpty - ? word - : result; // אם נשאר ריק, נחזיר את המילה המקורית + return result.isEmpty ? word : result; } /// מחזיר רשימה של קידומות בסיסיות (לתמיכה לאחור) @@ -187,4 +160,48 @@ class HebrewMorphology { /// מחזיר רשימה של סיומות בסיסיות (לתמיכה לאחור) static List getBasicSuffixes() => ['ים', 'ות', 'י', 'ך', 'ו', 'ה', 'נו', 'כם', 'כן', 'ם', 'ן']; + + // --- מתודות לכתיב מלא/חסר (מהקוד השני) --- + + /// יוצר דפוס רגקס לכתיב מלא/חסר על בסיס רשימת וריאציות + static String createFullPartialSpellingPattern(String word) { + if (word.isEmpty) return word; + final variations = generateFullPartialSpellingVariations(word); + final escapedVariations = variations.map((v) => RegExp.escape(v)).toList(); + return r'(?:^|\s)(' + escapedVariations.join('|') + r')(?=\s|$)'; + } + + /// יוצר רשימה של וריאציות כתיב מלא/חסר + static List generateFullPartialSpellingVariations(String word) { + if (word.isEmpty) return [word]; + final variations = {}; + final chars = word.split(''); + final optionalIndices = []; + + for (int i = 0; i < chars.length; i++) { + if (['י', 'ו', "'", '"'].contains(chars[i])) { + optionalIndices.add(i); + } + } + + final numCombinations = 1 << optionalIndices.length; // 2^n + for (int i = 0; i < numCombinations; i++) { + final variant = StringBuffer(); + int originalCharIndex = 0; + for (int optionalCharIndex = 0; + optionalCharIndex < optionalIndices.length; + optionalCharIndex++) { + int nextOptional = optionalIndices[optionalCharIndex]; + variant.write(word.substring(originalCharIndex, nextOptional)); + if ((i & (1 << optionalCharIndex)) != 0) { + variant.write(chars[nextOptional]); + } + originalCharIndex = nextOptional + 1; + } + variant.write(word.substring(originalCharIndex)); + variations.add(variant.toString()); + } + + return variations.toList(); + } } diff --git a/lib/search/view/tantivy_search_results.dart b/lib/search/view/tantivy_search_results.dart index 92723c4ea..22e1a3894 100644 --- a/lib/search/view/tantivy_search_results.dart +++ b/lib/search/view/tantivy_search_results.dart @@ -29,6 +29,53 @@ class TantivySearchResults extends StatefulWidget { } class _TantivySearchResultsState extends State { + // פונקציה עזר ליצירת וריאציות כתיב מלא/חסר + List _generateFullPartialSpellingVariations(String word) { + if (word.isEmpty) return [word]; + + final variations = {word}; // המילה המקורית + + // מוצא את כל המיקומים של י, ו, וגרשיים + final chars = word.split(''); + final optionalIndices = []; + + // מוצא אינדקסים של תווים שיכולים להיות אופציונליים + for (int i = 0; i < chars.length; i++) { + if (chars[i] == 'י' || + chars[i] == 'ו' || + chars[i] == "'" || + chars[i] == '"') { + optionalIndices.add(i); + } + } + + // יוצר את כל הצירופים האפשריים (2^n אפשרויות) + final numCombinations = 1 << optionalIndices.length; // 2^n + + for (int combination = 0; combination < numCombinations; combination++) { + final variant = []; + + for (int i = 0; i < chars.length; i++) { + final optionalIndex = optionalIndices.indexOf(i); + + if (optionalIndex != -1) { + // זה תו אופציונלי - בודק אם לכלול אותו בצירוף הזה + final shouldInclude = (combination & (1 << optionalIndex)) != 0; + if (shouldInclude) { + variant.add(chars[i]); + } + } else { + // תו רגיל - תמיד כולל + variant.add(chars[i]); + } + } + + variations.add(variant.join('')); + } + + return variations.toList(); + } + // פונקציה לחישוב כמה תווים יכולים להיכנס בשורה אחת int _calculateCharsPerLine(double availableWidth, TextStyle textStyle) { final textPainter = TextPainter( @@ -64,17 +111,48 @@ class _TantivySearchResultsState extends State { .split(RegExp(r'\s+')) .where((s) => s.isNotEmpty) .toList(); - - // הוספת מילים חילופיות למילות החיפוש + + // הוספת מילים חילופיות ווריאציות כתיב מלא/חסר למילות החיפוש final searchTerms = []; for (int i = 0; i < originalWords.length; i++) { final word = originalWords[i]; - searchTerms.add(word); - + final wordKey = '${word}_$i'; + + // בדיקת אפשרויות החיפוש למילה הזו + final wordOptions = widget.tab.searchOptions[wordKey] ?? {}; + final hasFullPartialSpelling = wordOptions['כתיב מלא/חסר'] == true; + + if (hasFullPartialSpelling) { + // אם יש כתיב מלא/חסר, נוסיף את כל הווריאציות + try { + // ייבוא דינמי של HebrewMorphology + final variations = _generateFullPartialSpellingVariations(word); + searchTerms.addAll(variations); + } catch (e) { + // אם יש בעיה, נוסיף לפחות את המילה המקורית + searchTerms.add(word); + } + } else { + // אם אין כתיב מלא/חסר, נוסיף את המילה המקורית + searchTerms.add(word); + } + // הוספת מילים חילופיות אם יש final alternatives = widget.tab.alternativeWords[i]; if (alternatives != null && alternatives.isNotEmpty) { - searchTerms.addAll(alternatives); + if (hasFullPartialSpelling) { + // אם יש כתיב מלא/חסר, נוסיף גם את הווריאציות של המילים החילופיות + for (final alt in alternatives) { + try { + final altVariations = _generateFullPartialSpellingVariations(alt); + searchTerms.addAll(altVariations); + } catch (e) { + searchTerms.add(alt); + } + } + } else { + searchTerms.addAll(alternatives); + } } } @@ -285,92 +363,84 @@ class _TantivySearchResultsState extends State { shrinkWrap: true, itemCount: state.results.length, itemBuilder: (context, index) { - final result = state.results[index]; - return BlocBuilder( - builder: (context, settingsState) { - String titleText = - '[תוצאה ${index + 1}] ${result.reference}'; - String rawHtml = result.text; - if (settingsState.replaceHolyNames) { - titleText = utils.replaceHolyNames(titleText); - rawHtml = utils.replaceHolyNames(rawHtml); - } - - // חישוב רוחב זמין לטקסט (מינוס אייקון ו-padding) - final availableWidth = constrains.maxWidth - - (result.isPdf - ? 56.0 - : 16.0) - // רוחב האייקון או padding - 32.0; // padding נוסף של ListTile - - // Create the snippet using the new robust function - final snippetSpans = createSnippetSpans( - rawHtml, - state.searchQuery, - TextStyle( - fontSize: settingsState.fontSize, - fontFamily: settingsState.fontFamily, - ), - TextStyle( - fontSize: settingsState.fontSize, - fontFamily: settingsState.fontFamily, - color: Colors.red, - fontWeight: FontWeight.bold, - ), - availableWidth, - ); - - return ListTile( - leading: result.isPdf - ? const Icon(Icons.picture_as_pdf) - : null, - onTap: () { - if (result.isPdf) { - context.read().add(AddTab( - PdfBookTab( - book: PdfBook( - title: result.title, - path: result.filePath), - pageNumber: result.segment.toInt() + 1, - searchText: - widget.tab.queryController.text, - openLeftPane: (Settings.getValue( - 'key-pin-sidebar') ?? - false) || - (Settings.getValue( - 'key-default-sidebar-open') ?? - false), - ), - )); - } else { - context.read().add(AddTab( - TextBookTab( - book: TextBook( - title: result.title, - ), - index: result.segment.toInt(), - searchText: - widget.tab.queryController.text, - openLeftPane: (Settings.getValue( - 'key-pin-sidebar') ?? - false) || - (Settings.getValue( - 'key-default-sidebar-open') ?? - false)), - )); - } - }, - title: Text(titleText), - subtitle: Text.rich( - TextSpan(children: snippetSpans), - maxLines: null, // אין הגבלה על מספר השורות! - textAlign: TextAlign.justify, - textDirection: TextDirection.rtl, - ), - ); - }, - ); - }, - ); + final result = state.results[index]; + return BlocBuilder( + builder: (context, settingsState) { + String titleText = '[תוצאה ${index + 1}] ${result.reference}'; + String rawHtml = result.text; + if (settingsState.replaceHolyNames) { + titleText = utils.replaceHolyNames(titleText); + rawHtml = utils.replaceHolyNames(rawHtml); + } + + // חישוב רוחב זמין לטקסט (מינוס אייקון ו-padding) + final availableWidth = constrains.maxWidth - + (result.isPdf ? 56.0 : 16.0) - // רוחב האייקון או padding + 32.0; // padding נוסף של ListTile + + // Create the snippet using the new robust function + final snippetSpans = createSnippetSpans( + rawHtml, + state.searchQuery, + TextStyle( + fontSize: settingsState.fontSize, + fontFamily: settingsState.fontFamily, + ), + TextStyle( + fontSize: settingsState.fontSize, + fontFamily: settingsState.fontFamily, + color: Colors.red, + fontWeight: FontWeight.bold, + ), + availableWidth, + ); + + return ListTile( + leading: result.isPdf ? const Icon(Icons.picture_as_pdf) : null, + onTap: () { + if (result.isPdf) { + context.read().add(AddTab( + PdfBookTab( + book: PdfBook( + title: result.title, path: result.filePath), + pageNumber: result.segment.toInt() + 1, + searchText: widget.tab.queryController.text, + openLeftPane: + (Settings.getValue('key-pin-sidebar') ?? + false) || + (Settings.getValue( + 'key-default-sidebar-open') ?? + false), + ), + )); + } else { + context.read().add(AddTab( + TextBookTab( + book: TextBook( + title: result.title, + ), + index: result.segment.toInt(), + searchText: widget.tab.queryController.text, + openLeftPane: + (Settings.getValue('key-pin-sidebar') ?? + false) || + (Settings.getValue( + 'key-default-sidebar-open') ?? + false)), + )); + } + }, + title: Text(titleText), + subtitle: Text.rich( + TextSpan(children: snippetSpans), + maxLines: null, // אין הגבלה על מספר השורות! + textAlign: TextAlign.justify, + textDirection: TextDirection.rtl, + ), + ); + }, + ); + }, + ); } } From dd5603ab431b66912c30a681dbd233803062284a Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 30 Jul 2025 20:17:38 +0300 Subject: [PATCH 047/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=97?= =?UTF-8?q?=D7=9C=D7=A7=20=D7=9E=D7=9E=D7=99=D7=9C=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/enhanced_search_field.dart | 7 +++--- .../view/full_text_settings_widgets.dart | 13 ++++++----- lib/search/view/search_options_dropdown.dart | 22 +++++++++---------- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 9111fb22e..67b12a7f7 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -1049,7 +1049,7 @@ class _EnhancedSearchFieldState extends State { }); }, onSubmitted: (e) { - context.read().add(UpdateSearchQuery(e, + context.read().add(UpdateSearchQuery(e, customSpacing: widget.widget.tab.spacingValues, alternativeWords: widget.widget.tab.alternativeWords, searchOptions: widget.widget.tab.searchOptions)); @@ -1064,7 +1064,8 @@ class _EnhancedSearchFieldState extends State { context.read().add(UpdateSearchQuery( widget.widget.tab.queryController.text, customSpacing: widget.widget.tab.spacingValues, - alternativeWords: widget.widget.tab.alternativeWords, + alternativeWords: + widget.widget.tab.alternativeWords, searchOptions: widget.widget.tab.searchOptions)); }, icon: const Icon(Icons.search), @@ -1160,7 +1161,7 @@ class _SearchOptionsContentState extends State<_SearchOptionsContent> { 'קידומות דקדוקיות', 'סיומות דקדוקיות', 'כתיב מלא/חסר', - 'שורש', + 'חלק ממילה', ]; String get _wordKey => '${widget.currentWord}_${widget.wordIndex}'; diff --git a/lib/search/view/full_text_settings_widgets.dart b/lib/search/view/full_text_settings_widgets.dart index dacada3bf..2bcb40223 100644 --- a/lib/search/view/full_text_settings_widgets.dart +++ b/lib/search/view/full_text_settings_widgets.dart @@ -81,7 +81,7 @@ class _FuzzyDistanceState extends State { // בדיקה אם יש מרווחים מותאמים אישית final hasCustomSpacing = widget.tab.spacingValues.isNotEmpty; final isEnabled = !state.fuzzy && !hasCustomSpacing; - + return SizedBox( width: 200, child: Padding( @@ -89,7 +89,7 @@ class _FuzzyDistanceState extends State { child: SpinBox( enabled: isEnabled, decoration: InputDecoration( - labelText: hasCustomSpacing + labelText: hasCustomSpacing ? 'מרווח בין מילים (מושבת - יש מרווחים מותאמים)' : 'מרווח בין מילים', labelStyle: TextStyle( @@ -99,8 +99,11 @@ class _FuzzyDistanceState extends State { min: 0, max: 30, value: state.distance.toDouble(), - onChanged: isEnabled ? (value) => - context.read().add(UpdateDistance(value.toInt())) : null, + onChanged: isEnabled + ? (value) => context + .read() + .add(UpdateDistance(value.toInt())) + : null, ), ), ); @@ -236,7 +239,7 @@ class _SearchTermsDisplayState extends State { 'קידומות דקדוקיות': 'קד', 'סיומות דקדוקיות': 'סד', 'כתיב מלא/חסר': 'מח', - 'שורש': 'ש', + 'חלק ממילה': 'ש', }; // אפשרויות שמופיעות אחרי המילה (סיומות) diff --git a/lib/search/view/search_options_dropdown.dart b/lib/search/view/search_options_dropdown.dart index 0b6a40408..414e989fb 100644 --- a/lib/search/view/search_options_dropdown.dart +++ b/lib/search/view/search_options_dropdown.dart @@ -5,7 +5,7 @@ class SearchOptionsDropdown extends StatefulWidget { final bool isExpanded; const SearchOptionsDropdown({ - super.key, + super.key, this.onToggle, this.isExpanded = false, }); @@ -54,9 +54,9 @@ class _SearchOptionsDropdownState extends State { class SearchOptionsRow extends StatefulWidget { final bool isVisible; final String? currentWord; // המילה הנוכחית - + const SearchOptionsRow({ - super.key, + super.key, required this.isVisible, this.currentWord, }); @@ -68,15 +68,15 @@ class SearchOptionsRow extends StatefulWidget { class _SearchOptionsRowState extends State { // מפה שמחזיקה אפשרויות לכל מילה static final Map> _wordOptions = {}; - + // רשימת האפשרויות הזמינות static const List _availableOptions = [ 'קידומות', - 'סיומות', + 'סיומות', 'קידומות דקדוקיות', 'סיומות דקדוקיות', 'כתיב מלא/חסר', - 'שורש', + 'חלק ממילה', ]; Map _getCurrentWordOptions() { @@ -84,25 +84,25 @@ class _SearchOptionsRowState extends State { if (currentWord == null || currentWord.isEmpty) { return Map.fromIterable(_availableOptions, value: (_) => false); } - + // אם אין אפשרויות למילה הזו, ניצור אותן if (!_wordOptions.containsKey(currentWord)) { - _wordOptions[currentWord] = Map.fromIterable(_availableOptions, value: (_) => false); + _wordOptions[currentWord] = + Map.fromIterable(_availableOptions, value: (_) => false); } - + return _wordOptions[currentWord]!; } Widget _buildCheckbox(String option) { final currentOptions = _getCurrentWordOptions(); - + return InkWell( onTap: () { setState(() { final currentWord = widget.currentWord; if (currentWord != null && currentWord.isNotEmpty) { currentOptions[option] = !currentOptions[option]!; - } }); }, From 6fbf72bcadf831026f8b8591fe5f1c5d8ac0dfd9 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 30 Jul 2025 23:03:29 +0300 Subject: [PATCH 048/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=AA?= =?UTF-8?q?=D7=99=D7=91=D7=AA=20=D7=9E=D7=99=D7=9C=D7=95=D7=AA=20=D7=94?= =?UTF-8?q?=D7=97=D7=99=D7=A4=D7=95=D7=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../view/full_text_settings_widgets.dart | 101 ++++++++++++------ 1 file changed, 69 insertions(+), 32 deletions(-) diff --git a/lib/search/view/full_text_settings_widgets.dart b/lib/search/view/full_text_settings_widgets.dart index 2bcb40223..1814e89de 100644 --- a/lib/search/view/full_text_settings_widgets.dart +++ b/lib/search/view/full_text_settings_widgets.dart @@ -162,9 +162,12 @@ class SearchTermsDisplay extends StatefulWidget { } class _SearchTermsDisplayState extends State { + late ScrollController _scrollController; + @override void initState() { super.initState(); + _scrollController = ScrollController(); // מאזין לשינויים בקונטרולר widget.tab.queryController.addListener(_onTextChanged); // מאזין לשינויים באפשרויות החיפוש @@ -202,10 +205,11 @@ class _SearchTermsDisplayState extends State { final textPainter = TextPainter( text: TextSpan(children: spans), textDirection: TextDirection.rtl, + maxLines: 1, ); - textPainter.layout(); - return textPainter.width; + textPainter.layout(maxWidth: double.infinity); + return textPainter.size.width; } // פונקציה להמרת מספרים לתת-כתב Unicode @@ -359,9 +363,9 @@ class _SearchTermsDisplayState extends State { // הוספת + עם המספר כתת-כתב spans.add( - TextSpan( + const TextSpan( text: '+', - style: const TextStyle( + style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black, @@ -403,6 +407,7 @@ class _SearchTermsDisplayState extends State { @override void dispose() { + _scrollController.dispose(); widget.tab.queryController.removeListener(_onTextChanged); widget.tab.searchOptionsChanged.removeListener(_onSearchOptionsChanged); widget.tab.alternativeWordsChanged @@ -442,42 +447,74 @@ class _SearchTermsDisplayState extends State { return LayoutBuilder( builder: (context, constraints) { - // חישוב רוחב דינמי בהתבסס על אורך הטקסט המעוצב - const minWidth = 120.0; // רוחב מינימלי גדול יותר - final maxWidth = constraints.maxWidth; // כל הרוחב הזמין + // הגדרת רוחב מינימלי ומקסימלי + const minWidth = 150.0; // רוחב מינימלי + final maxWidth = + constraints.maxWidth - 20; // כל הרוחב הזמין פחות מרווח // חישוב רוחב בהתבסס על הרוחב האמיתי של הטקסט המעוצב - final formattedTextWidth = - _calculateFormattedTextWidth(displayText, context); - final calculatedWidth = formattedTextWidth == 0 - ? minWidth - : (formattedTextWidth + 40) - .clamp(minWidth, maxWidth); // מרווח מותאם לגופן הגדול + final formattedTextWidth = displayText.isEmpty + ? 0 + : _calculateFormattedTextWidth(displayText, context); + + // חישוב רוחב סופי + double calculatedWidth; + if (displayText.isEmpty) { + calculatedWidth = minWidth; + } else { + // הוספת מרווח נוסף לטקסט (padding + border + scroll space) + final textWithPadding = formattedTextWidth + 60; + calculatedWidth = + textWithPadding.clamp(minWidth, maxWidth).toDouble(); + } return Align( - alignment: Alignment.center, // ממורכז תמיד - child: SizedBox( + alignment: Alignment.center, // ממורכז במרכז המסך + child: Container( width: calculatedWidth, height: 52, // גובה קבוע כמו שאר הבקרות - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, vertical: 4.0), - child: InputDecorator( - decoration: const InputDecoration( - labelText: 'מילות החיפוש', - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric( - horizontal: 8.0, vertical: 8.0), // padding מותאם - ), - child: displayText.isEmpty - ? const SizedBox.shrink() - : SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Center( - child: _buildFormattedText(displayText, context), + margin: + const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: InputDecorator( + decoration: const InputDecoration( + labelText: 'מילות החיפוש', + border: OutlineInputBorder(), + contentPadding: + EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + ), + child: displayText.isEmpty + ? const SizedBox( + width: double.infinity, + child: Center( + child: Text( + '', + textAlign: TextAlign.center, ), ), - ), + ) + : SizedBox( + width: double.infinity, + height: double.infinity, + child: formattedTextWidth <= (calculatedWidth - 60) + ? Center( + child: + _buildFormattedText(displayText, context), + ) + : Scrollbar( + controller: _scrollController, + thumbVisibility: true, + trackVisibility: true, + child: SingleChildScrollView( + controller: _scrollController, + scrollDirection: Axis.horizontal, + child: Align( + alignment: Alignment.centerRight, + child: _buildFormattedText( + displayText, context), + ), + ), + ), + ), ), ), ); From 93c6a692aa649fa49836be09a5739ea0abee8a1c Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 30 Jul 2025 23:23:34 +0300 Subject: [PATCH 049/197] =?UTF-8?q?=D7=94=D7=95=D7=93=D7=A2=D7=AA=20=D7=9E?= =?UTF-8?q?=D7=92=D7=99=D7=A8=D7=94=20=D7=A9=D7=9B=D7=94=D7=A7=D7=9C=D7=98?= =?UTF-8?q?=20=D7=A8=D7=99=D7=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/enhanced_search_field.dart | 33 +++------------------- 1 file changed, 4 insertions(+), 29 deletions(-) diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 67b12a7f7..95d502954 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -880,18 +880,13 @@ class _EnhancedSearchFieldState extends State { Widget _buildSearchOptionsContent() { final wordInfo = _getCurrentWordInfo(); - // אם אין מילה נוכחית, נציג הודעה + // אם אין מילה נוכחית, נציג הודעה המתאימה if (wordInfo == null || wordInfo['word'] == null || wordInfo['word'].isEmpty) { - final text = widget.widget.tab.queryController.text; - final message = text.trim().isEmpty - ? 'הקלד טקסט ומקם את הסמן על מילה לבחירת אפשרויות' - : 'מקם את הסמן על מילה לבחירת אפשרויות'; - return Center( child: Text( - message, + 'הקלד או הצב את הסמן על מילה כלשהיא, כדי לבחור אפשרויות חיפוש', style: const TextStyle(fontSize: 12, color: Colors.grey), textAlign: TextAlign.center, ), @@ -923,28 +918,8 @@ class _EnhancedSearchFieldState extends State { void _toggleSearchOptions(bool isExpanded) { if (isExpanded) { - // בדיקה שיש טקסט בשדה החיפוש ושהסמן על מילה - final text = widget.widget.tab.queryController.text.trim(); - final wordInfo = _getCurrentWordInfo(); - - if (text.isNotEmpty && - wordInfo != null && - wordInfo['word'] != null && - wordInfo['word'].isNotEmpty) { - _showSearchOptionsOverlay(); - } else { - // אם אין טקסט או הסמן לא על מילה, עדכן את המצב של הכפתור - setState(() { - // זה יגרום לכפתור לחזור למצב לא לחוץ - }); - - // הצגת הודעה קצרה למשתמש (אופציונלי) - if (text.isEmpty) { - // יכול להוסיף כאן הודעה שצריך להקליד טקסט - } else { - // יכול להוסיף כאן הודעה שצריך למקם את הסמן על מילה - } - } + // פתיחת המגירה תמיד, ללא תלות בטקסט או מיקום הסמן + _showSearchOptionsOverlay(); } else { _hideSearchOptionsOverlay(); } From 70740c9f7530ef7710b8fb6a1db017ae96e1cb06 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 30 Jul 2025 23:23:50 +0300 Subject: [PATCH 050/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=A1?= =?UTF-8?q?=D7=99=D7=95=D7=9E=D7=AA=20=D7=90=D7=A8=D7=9E=D7=99=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/utils/hebrew_morphology.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/search/utils/hebrew_morphology.dart b/lib/search/utils/hebrew_morphology.dart index 98c88fda1..079b09bdb 100644 --- a/lib/search/utils/hebrew_morphology.dart +++ b/lib/search/utils/hebrew_morphology.dart @@ -84,7 +84,7 @@ class HebrewMorphology { if (includeSuffixes) { // שימוש בתבנית קבועה ויעילה const suffixPattern = - r'(ותי|ותַי|ותיך|ותֶיךָ|ותַיִךְ|ותיו|ותָיו|ותיה|ותֶיהָ|ותינו|ותֵינוּ|ותיכם|ותֵיכם|ותיכן|ותֵיכן|ותיהם|ותֵיהם|ותיהן|ותֵיהן|יות|יי|יַי|יך|יךָ|יִךְ|יו|יה|יא|יהָ|ינו|יכם|יכן|יהם|יהן|י|ך|ךָ|ךְ|ו|ה|הּ|נו|כם|כן|ם|ן|ים|ות)?'; + r'(ותי|ותַי|ותיך|ותֶיךָ|ותַיִךְ|ותיו|ותָיו|ותיה|ותֶיהָ|ותינו|ותֵינוּ|ותיכם|ותֵיכם|ותיכן|ותֵיכן|ותיהם|ותֵיהם|ותיהן|ותֵיהן|יות|יי|יַי|יך|יךָ|יִךְ|יו|יה|יא|תא|יהָ|ינו|יכם|יכן|יהם|יהן|י|ך|ךָ|ךְ|ו|ה|הּ|נו|כם|כן|ם|ן|ים|ות)?'; pattern = pattern + suffixPattern; } From 1772a7b56f1559e344a5982a386070dd944bc488 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 31 Jul 2025 01:08:08 +0300 Subject: [PATCH 051/197] =?UTF-8?q?=D7=A0=D7=99=D7=A1=D7=99=D7=95=D7=9F=20?= =?UTF-8?q?=D7=9C=D7=94=D7=A6=D7=92=D7=AA=20=D7=91=D7=95=D7=A2=D7=95=D7=AA?= =?UTF-8?q?=20=D7=91=D7=97=D7=96=D7=A8=D7=94=20=D7=9C=D7=9E=D7=A1=D7=9A=20?= =?UTF-8?q?=D7=97=D7=99=D7=A4=D7=95=D7=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/enhanced_search_field.dart | 661 ++++++++++++++++----- 1 file changed, 513 insertions(+), 148 deletions(-) diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 67b12a7f7..84cab7df9 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -6,6 +6,8 @@ import 'package:otzaria/search/bloc/search_bloc.dart'; import 'package:otzaria/search/bloc/search_event.dart'; import 'package:otzaria/search/models/search_terms_model.dart'; import 'package:otzaria/search/view/tantivy_full_text_search.dart'; +import 'package:otzaria/navigation/bloc/navigation_bloc.dart'; +import 'package:otzaria/navigation/bloc/navigation_state.dart'; // הווידג'ט החדש לניהול מצבי הכפתור class _PlusButton extends StatefulWidget { @@ -419,25 +421,191 @@ class _EnhancedSearchFieldState extends State { widget.widget.tab.queryController.removeListener(_onTextChanged); widget.widget.tab.searchFieldFocusNode .removeListener(_onCursorPositionChanged); - _disposeControllers(); + _disposeControllers(); // במצב dispose אנחנו רוצים למחוק הכל // ניקוי אפשרויות החיפוש כשסוגרים את המסך widget.widget.tab.searchOptions.clear(); super.dispose(); } - void _clearAllOverlays({bool keepSearchDrawer = false}) { - // ניקוי אלטרנטיבות ומרווחים - for (final entries in _alternativeOverlays.values) { - for (final entry in entries) { - entry.remove(); + // שמירת נתונים לפני ניקוי + void _saveDataToTab() { + // שמירת מילים חילופיות + widget.widget.tab.alternativeWords.clear(); + for (int termIndex in _alternativeControllers.keys) { + final alternatives = _alternativeControllers[termIndex]! + .map((controller) => controller.text.trim()) + .where((text) => text.isNotEmpty) + .toList(); + if (alternatives.isNotEmpty) { + widget.widget.tab.alternativeWords[termIndex] = alternatives; + } + } + + // שמירת מרווחים + widget.widget.tab.spacingValues.clear(); + for (String key in _spacingControllers.keys) { + final spacingText = _spacingControllers[key]!.text.trim(); + if (spacingText.isNotEmpty) { + widget.widget.tab.spacingValues[key] = spacingText; + } + } + } + + // שחזור נתונים מה-tab + void _restoreDataFromTab() { + // ניקוי controllers קיימים לפני השחזור + _disposeControllers(); + + // עדכון ה-searchQuery מהטקסט הנוכחי + final text = widget.widget.tab.queryController.text; + if (text.isNotEmpty) { + _searchQuery = SearchQuery.fromString(text); + } + + // שחזור מילים חילופיות + for (final entry in widget.widget.tab.alternativeWords.entries) { + final termIndex = entry.key; + final alternatives = entry.value; + + _alternativeControllers[termIndex] = alternatives.map((alt) { + final controller = TextEditingController(text: alt); + controller.addListener(() => _updateAlternativeWordsInTab()); + return controller; + }).toList(); + } + + // שחזור מרווחים + for (final entry in widget.widget.tab.spacingValues.entries) { + final key = entry.key; + final value = entry.value; + + final controller = TextEditingController(text: value); + controller.addListener(() => _updateSpacingInTab()); + _spacingControllers[key] = controller; + } + } + + // הצגת בועות שחוזרו + void _showRestoredBubbles() { + // הצגת מילים חילופיות + for (final entry in _alternativeControllers.entries) { + final termIndex = entry.key; + final controllers = entry.value; + for (int j = 0; j < controllers.length; j++) { + if (termIndex < _wordPositions.length) { + _showAlternativeOverlay(termIndex, j); + } + } + } + + // הצגת מרווחים + for (final key in _spacingControllers.keys) { + final parts = key.split('-'); + if (parts.length == 2) { + final leftIndex = int.tryParse(parts[0]); + final rightIndex = int.tryParse(parts[1]); + if (leftIndex != null && + rightIndex != null && + leftIndex < _wordPositions.length && + rightIndex < _wordPositions.length) { + _showSpacingOverlay(leftIndex, rightIndex); + } + } + } + } + + // ניקוי נתונים לא רלוונטיים כשהמילים משתנות + void _cleanupIrrelevantData(Set newWords) { + // ניקוי אפשרויות חיפוש למילים שלא קיימות יותר + final searchOptionsKeysToRemove = []; + for (final key in widget.widget.tab.searchOptions.keys) { + final parts = key.split('_'); + if (parts.isNotEmpty) { + final word = parts[0]; + if (!newWords.contains(word)) { + searchOptionsKeysToRemove.add(key); + } } } - _alternativeOverlays.clear(); - for (final entry in _spacingOverlays.values) { - entry.remove(); + for (final key in searchOptionsKeysToRemove) { + widget.widget.tab.searchOptions.remove(key); + } + } + + void _clearAllOverlays( + {bool keepSearchDrawer = false, bool keepFilledBubbles = false}) { + // ניקוי אלטרנטיבות - רק אם לא ביקשנו לשמור בועות מלאות או אם הן ריקות + if (!keepFilledBubbles) { + for (final entries in _alternativeOverlays.values) { + for (final entry in entries) { + entry.remove(); + } + } + _alternativeOverlays.clear(); + } else { + // שמירה רק על בועות עם טקסט + final keysToRemove = []; + for (final termIndex in _alternativeOverlays.keys) { + final controllers = _alternativeControllers[termIndex] ?? []; + final overlays = _alternativeOverlays[termIndex] ?? []; + + final indicesToRemove = []; + for (int i = 0; i < controllers.length; i++) { + if (controllers[i].text.trim().isEmpty) { + if (i < overlays.length) { + overlays[i].remove(); + indicesToRemove.add(i); + } + } + } + + // הסרה בסדר הפוך כדי לא לפגוע באינדקסים + for (int i = indicesToRemove.length - 1; i >= 0; i--) { + final indexToRemove = indicesToRemove[i]; + if (indexToRemove < overlays.length) { + overlays.removeAt(indexToRemove); + } + if (indexToRemove < controllers.length) { + controllers[indexToRemove].dispose(); + controllers.removeAt(indexToRemove); + } + } + + if (overlays.isEmpty) { + keysToRemove.add(termIndex); + } + } + + for (final key in keysToRemove) { + _alternativeOverlays.remove(key); + _alternativeControllers.remove(key); + } + } + + // ניקוי מרווחים - רק אם לא ביקשנו לשמור בועות מלאות או אם הן ריקות + if (!keepFilledBubbles) { + for (final entry in _spacingOverlays.values) { + entry.remove(); + } + _spacingOverlays.clear(); + } else { + // שמירה רק על בועות עם טקסט + final keysToRemove = []; + for (final key in _spacingOverlays.keys) { + final controller = _spacingControllers[key]; + if (controller == null || controller.text.trim().isEmpty) { + _spacingOverlays[key]?.remove(); + keysToRemove.add(key); + } + } + + for (final key in keysToRemove) { + _spacingOverlays.remove(key); + _spacingControllers[key]?.dispose(); + _spacingControllers.remove(key); + } } - _spacingOverlays.clear(); // סגירת מגירת האפשרויות רק אם לא ביקשנו לשמור אותה if (!keepSearchDrawer) { @@ -446,28 +614,85 @@ class _EnhancedSearchFieldState extends State { } } - void _disposeControllers() { - for (final controllers in _alternativeControllers.values) { - for (final controller in controllers) { + void _disposeControllers({bool keepFilledControllers = false}) { + if (!keepFilledControllers) { + // מחיקה מלאה של כל ה-controllers + for (final controllers in _alternativeControllers.values) { + for (final controller in controllers) { + controller.dispose(); + } + } + _alternativeControllers.clear(); + for (final controller in _spacingControllers.values) { controller.dispose(); } + _spacingControllers.clear(); + } else { + // מחיקה רק של controllers ריקים + final alternativeKeysToRemove = []; + for (final entry in _alternativeControllers.entries) { + final termIndex = entry.key; + final controllers = entry.value; + final indicesToRemove = []; + + for (int i = 0; i < controllers.length; i++) { + if (controllers[i].text.trim().isEmpty) { + controllers[i].dispose(); + indicesToRemove.add(i); + } + } + + // הסרה בסדר הפוך + for (int i = indicesToRemove.length - 1; i >= 0; i--) { + controllers.removeAt(indicesToRemove[i]); + } + + if (controllers.isEmpty) { + alternativeKeysToRemove.add(termIndex); + } + } + + for (final key in alternativeKeysToRemove) { + _alternativeControllers.remove(key); + } + + // מחיקת spacing controllers ריקים + final spacingKeysToRemove = []; + for (final entry in _spacingControllers.entries) { + if (entry.value.text.trim().isEmpty) { + entry.value.dispose(); + spacingKeysToRemove.add(entry.key); + } + } + + for (final key in spacingKeysToRemove) { + _spacingControllers.remove(key); + } } - _alternativeControllers.clear(); - for (final controller in _spacingControllers.values) { - controller.dispose(); - } - _spacingControllers.clear(); } void _onTextChanged() { // בודקים אם המגירה הייתה פתוחה לפני השינוי final bool drawerWasOpen = _searchOptionsOverlay != null; - // מנקים את כל הבועות, אבל משאירים את המגירה פתוחה אם היא הייתה פתוחה - _clearAllOverlays(keepSearchDrawer: drawerWasOpen); - final text = widget.widget.tab.queryController.text; + // בדיקה אם המילים השתנו באופן משמעותי - אם כן, נקה נתונים ישנים + final newWords = + text.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toSet(); + final oldWords = _searchQuery.terms.map((t) => t.word).toSet(); + + final bool wordsChangedSignificantly = + !newWords.containsAll(oldWords) || !oldWords.containsAll(newWords); + + if (wordsChangedSignificantly) { + // אם המילים השתנו משמעותית, נקה נתונים ישנים שלא רלוונטיים + _cleanupIrrelevantData(newWords); + } + + // מנקים את כל הבועות, אבל שומרים על בועות עם טקסט ועל המגירה אם הייתה פתוחה + _clearAllOverlays(keepSearchDrawer: drawerWasOpen, keepFilledBubbles: true); + // אם שדה החיפוש התרוקן, נסגור את המגירה בכל זאת if (text.trim().isEmpty && drawerWasOpen) { _hideSearchOptionsOverlay(); @@ -484,12 +709,49 @@ class _EnhancedSearchFieldState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { _calculateWordPositions(); + // הצגת alternatives מה-SearchQuery for (int i = 0; i < _searchQuery.terms.length; i++) { for (int j = 0; j < _searchQuery.terms[i].alternatives.length; j++) { _showAlternativeOverlay(i, j); } } + // הצגת alternatives קיימים שנשמרו + for (final entry in _alternativeControllers.entries) { + final termIndex = entry.key; + final controllers = entry.value; + for (int j = 0; j < controllers.length; j++) { + if (controllers[j].text.trim().isNotEmpty) { + // בדיקה שהבועה לא מוצגת כבר + final existingOverlays = _alternativeOverlays[termIndex] ?? []; + if (j >= existingOverlays.length) { + _showAlternativeOverlay(termIndex, j); + } + } + } + } + + // הצגת spacing overlays קיימים + for (final entry in _spacingControllers.entries) { + final key = entry.key; + final controller = entry.value; + if (controller.text.trim().isNotEmpty && + !_spacingOverlays.containsKey(key)) { + // פירוק המפתח לאינדקסים + final parts = key.split('-'); + if (parts.length == 2) { + final leftIndex = int.tryParse(parts[0]); + final rightIndex = int.tryParse(parts[1]); + if (leftIndex != null && + rightIndex != null && + leftIndex < _wordPositions.length && + rightIndex < _wordPositions.length) { + _showSpacingOverlay(leftIndex, rightIndex); + } + } + } + } + // אם המגירה הייתה פתוחה, מרעננים את התוכן שלה if (drawerWasOpen) { _updateSearchOptionsOverlay(); @@ -515,15 +777,49 @@ class _EnhancedSearchFieldState extends State { } void _updateAlternativeControllers() { - _disposeControllers(); + // שמירה על controllers קיימים שיש בהם טקסט + final Map> existingControllers = {}; + for (final entry in _alternativeControllers.entries) { + final termIndex = entry.key; + final controllers = entry.value; + final controllersWithText = + controllers.where((c) => c.text.trim().isNotEmpty).toList(); + if (controllersWithText.isNotEmpty) { + existingControllers[termIndex] = controllersWithText; + } + } + + // מחיקת controllers ריקים בלבד + for (final entry in _alternativeControllers.entries) { + final controllers = entry.value; + for (final controller in controllers) { + if (controller.text.trim().isEmpty) { + controller.dispose(); + } + } + } + + // איפוס המפה + _alternativeControllers.clear(); + + // החזרת controllers עם טקסט + _alternativeControllers.addAll(existingControllers); + + // הוספת controllers חדשים מה-SearchQuery for (int i = 0; i < _searchQuery.terms.length; i++) { final term = _searchQuery.terms[i]; - _alternativeControllers[i] = term.alternatives.map((alt) { - final controller = TextEditingController(text: alt); - // הוספת listener לעדכון המידע ב-tab כשהטקסט משתנה - controller.addListener(() => _updateAlternativeWordsInTab()); - return controller; - }).toList(); + _alternativeControllers.putIfAbsent(i, () => []); + + // הוספת alternatives מה-SearchQuery שלא קיימים כבר + for (final alt in term.alternatives) { + final existingTexts = + _alternativeControllers[i]!.map((c) => c.text).toList(); + if (!existingTexts.contains(alt)) { + final controller = TextEditingController(text: alt); + controller.addListener(() => _updateAlternativeWordsInTab()); + _alternativeControllers[i]!.add(controller); + } + } } } @@ -590,6 +886,17 @@ class _EnhancedSearchFieldState extends State { idx = end; } + +if (text.isNotEmpty && _wordPositions.isEmpty) { +// החישוב נכשל למרות שיש טקסט. ננסה שוב ב-frame הבא. +WidgetsBinding.instance.addPostFrameCallback((_) { +if (mounted) { // ודא שהווידג'ט עדיין קיים +_calculateWordPositions(); +} +}); +return; // צא מהפונקציה כדי לא לקרוא ל-setState עם מידע שגוי +} + setState(() {}); } @@ -647,6 +954,23 @@ class _EnhancedSearchFieldState extends State { } void _showAlternativeOverlay(int termIndex, int altIndex) { + // בדיקה שהאינדקסים תקינים + if (termIndex >= _wordPositions.length || + !_alternativeControllers.containsKey(termIndex) || + altIndex >= _alternativeControllers[termIndex]!.length) { + return; + } + + // בדיקה שהבועה לא מוצגת כבר + final existingOverlays = _alternativeOverlays[termIndex]; + if (existingOverlays != null && + altIndex < existingOverlays.length && + mounted && // ודא שה-State עדיין קיים + Overlay.of(context).mounted && // ודא שה-Overlay קיים + existingOverlays[altIndex].mounted) { // ודא שהבועה הספציפית הזו עדיין על המסך + return; // אם הבועה כבר קיימת ומוצגת, אל תעשה כלום + } + final overlayState = Overlay.of(context); final RenderBox? textFieldBox = _textFieldKey.currentContext?.findRenderObject() as RenderBox?; @@ -689,6 +1013,13 @@ class _EnhancedSearchFieldState extends State { void _showSpacingOverlay(int leftIndex, int rightIndex) { final key = _spaceKey(leftIndex, rightIndex); if (_spacingOverlays.containsKey(key)) return; + + // בדיקה שהאינדקסים תקינים + if (leftIndex >= _wordRightEdges.length || + rightIndex >= _wordLeftEdges.length) { + return; + } + final overlayState = Overlay.of(context); final RenderBox? textFieldBox = _textFieldKey.currentContext?.findRenderObject() as RenderBox?; @@ -995,142 +1326,176 @@ class _EnhancedSearchFieldState extends State { @override Widget build(BuildContext context) { - return Stack( - key: _stackKey, - clipBehavior: Clip.none, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: SizedBox( - width: double.infinity, - child: ConstrainedBox( - constraints: const BoxConstraints( - minWidth: _kSearchFieldMinWidth, - minHeight: _kControlHeight, - ), - child: KeyboardListener( - focusNode: FocusNode(), - onKeyEvent: (KeyEvent event) { - // עדכון המגירה כשמשתמשים בחצים במקלדת - if (event is KeyDownEvent) { - final isArrowKey = - event.logicalKey.keyLabel == 'Arrow Left' || - event.logicalKey.keyLabel == 'Arrow Right' || - event.logicalKey.keyLabel == 'Arrow Up' || - event.logicalKey.keyLabel == 'Arrow Down'; - - if (isArrowKey) { + return BlocListener( + listener: (context, state) { + // כשעוברים ממסך החיפוש למסך אחר, שמור נתונים ונקה את כל הבועות + if (state.currentScreen != Screen.search) { + _saveDataToTab(); + _clearAllOverlays(); + } else if (state.currentScreen == Screen.search) { + // כשחוזרים למסך החיפוש, שחזר את הנתונים והצג את הבועות + WidgetsBinding.instance.addPostFrameCallback((_) { + _restoreDataFromTab(); // 1. שחזר את תוכן הבועות מהזיכרון + // עיכוב נוסף כדי לוודא שהטקסט מעודכן + Future.delayed(const Duration(milliseconds: 50), () { // השאר את העיכוב הקטן הזה + if (mounted) { + _calculateWordPositions(); // 2. חשב מיקומים (עכשיו זה יעבוד) + _showRestoredBubbles(); // 3. הצג את הבועות המשוחזרות + } + }); + }); + } + }, + child: Stack( + key: _stackKey, + clipBehavior: Clip.none, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( + width: double.infinity, + child: ConstrainedBox( + constraints: const BoxConstraints( + minWidth: _kSearchFieldMinWidth, + minHeight: _kControlHeight, + ), + child: KeyboardListener( + focusNode: FocusNode(), + onKeyEvent: (KeyEvent event) { + // עדכון המגירה כשמשתמשים בחצים במקלדת + if (event is KeyDownEvent) { + final isArrowKey = + event.logicalKey.keyLabel == 'Arrow Left' || + event.logicalKey.keyLabel == 'Arrow Right' || + event.logicalKey.keyLabel == 'Arrow Up' || + event.logicalKey.keyLabel == 'Arrow Down'; + + if (isArrowKey) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_searchOptionsOverlay != null) { + _updateSearchOptionsOverlay(); + } + }); + } + } + }, + child: TextField( + key: _textFieldKey, + focusNode: widget.widget.tab.searchFieldFocusNode, + controller: widget.widget.tab.queryController, + onTap: () { + // עדכון המגירה כשלוחצים בשדה הטקסט WidgetsBinding.instance.addPostFrameCallback((_) { if (_searchOptionsOverlay != null) { _updateSearchOptionsOverlay(); } }); - } - } - }, - child: TextField( - key: _textFieldKey, - focusNode: widget.widget.tab.searchFieldFocusNode, - controller: widget.widget.tab.queryController, - onTap: () { - // עדכון המגירה כשלוחצים בשדה הטקסט - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_searchOptionsOverlay != null) { - _updateSearchOptionsOverlay(); - } - }); - }, - onChanged: (text) { - // עדכון המגירה כשהטקסט משתנה - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_searchOptionsOverlay != null) { - _updateSearchOptionsOverlay(); - } - }); - }, - onSubmitted: (e) { - context.read().add(UpdateSearchQuery(e, - customSpacing: widget.widget.tab.spacingValues, - alternativeWords: widget.widget.tab.alternativeWords, - searchOptions: widget.widget.tab.searchOptions)); - widget.widget.tab.isLeftPaneOpen.value = false; - }, - decoration: InputDecoration( - border: const OutlineInputBorder(), - hintText: "חפש כאן..", - labelText: "לחיפוש הקש אנטר או לחץ על סמל החיפוש", - prefixIcon: IconButton( - onPressed: () { - context.read().add(UpdateSearchQuery( - widget.widget.tab.queryController.text, - customSpacing: widget.widget.tab.spacingValues, - alternativeWords: - widget.widget.tab.alternativeWords, - searchOptions: widget.widget.tab.searchOptions)); - }, - icon: const Icon(Icons.search), - ), - // החלף את כל ה-Row הקיים בזה: - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.widget.tab.isAdvancedSearchEnabled) - IconButton( - onPressed: () => - _toggleSearchOptions(!_isSearchOptionsVisible), - icon: const Icon(Icons.keyboard_arrow_down), - focusNode: FocusNode( - // <-- התוספת המרכזית - canRequestFocus: false, // מונע מהכפתור לבקש פוקוס - skipTraversal: true, // מדלג עליו בניווט מקלדת + }, + onChanged: (text) { + // עדכון המגירה כשהטקסט משתנה + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_searchOptionsOverlay != null) { + _updateSearchOptionsOverlay(); + } + }); + }, + onSubmitted: (e) { + context.read().add(UpdateSearchQuery(e, + customSpacing: widget.widget.tab.spacingValues, + alternativeWords: widget.widget.tab.alternativeWords, + searchOptions: widget.widget.tab.searchOptions)); + widget.widget.tab.isLeftPaneOpen.value = false; + }, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: "חפש כאן..", + labelText: "לחיפוש הקש אנטר או לחץ על סמל החיפוש", + prefixIcon: IconButton( + onPressed: () { + context.read().add(UpdateSearchQuery( + widget.widget.tab.queryController.text, + customSpacing: widget.widget.tab.spacingValues, + alternativeWords: + widget.widget.tab.alternativeWords, + searchOptions: widget.widget.tab.searchOptions)); + }, + icon: const Icon(Icons.search), + ), + // החלף את כל ה-Row הקיים בזה: + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.widget.tab.isAdvancedSearchEnabled) + IconButton( + onPressed: () => _toggleSearchOptions( + !_isSearchOptionsVisible), + icon: const Icon(Icons.keyboard_arrow_down), + focusNode: FocusNode( + // <-- התוספת המרכזית + canRequestFocus: + false, // מונע מהכפתור לבקש פוקוס + skipTraversal: true, // מדלג עליו בניווט מקלדת + ), ), + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + // ניקוי מלא של כל הנתונים + widget.widget.tab.queryController.clear(); + widget.widget.tab.searchOptions.clear(); + widget.widget.tab.alternativeWords.clear(); + widget.widget.tab.spacingValues.clear(); + _clearAllOverlays(); + _disposeControllers(); + setState(() { + _searchQuery = SearchQuery(); + _wordPositions.clear(); + _wordLeftEdges.clear(); + _wordRightEdges.clear(); + }); + context + .read() + .add(UpdateSearchQuery('')); + }, ), - IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - widget.widget.tab.queryController.clear(); - context - .read() - .add(UpdateSearchQuery('')); - }, - ), - ], + ], + ), ), ), ), ), ), ), - ), - // אזורי ריחוף על המילים - רק בחלק העליון - ..._wordPositions.asMap().entries.map((entry) { - final wordIndex = entry.key; - final position = entry.value; - return Positioned( - left: position.dx - 30, - top: position.dy - 47, // יותר למעלה כדי לא לחסום את שדה החיפוש - child: MouseRegion( - onEnter: (_) => setState(() => _hoveredWordIndex = wordIndex), - onExit: (_) => setState(() => _hoveredWordIndex = null), - child: IgnorePointer( - child: Container( - width: 60, - height: 20, // גובה קטן יותר - color: Colors.transparent, + // אזורי ריחוף על המילים - רק בחלק העליון + ..._wordPositions.asMap().entries.map((entry) { + final wordIndex = entry.key; + final position = entry.value; + return Positioned( + left: position.dx - 30, + top: position.dy - 47, // יותר למעלה כדי לא לחסום את שדה החיפוש + child: MouseRegion( + onEnter: (_) => setState(() => _hoveredWordIndex = wordIndex), + onExit: (_) => setState(() => _hoveredWordIndex = null), + child: IgnorePointer( + child: Container( + width: 60, + height: 20, // גובה קטן יותר + color: Colors.transparent, + ), ), ), - ), - ); - }).toList(), - // כפתורי ה+ (רק בחיפוש מתקדם) - if (widget.widget.tab.isAdvancedSearchEnabled) - ..._wordPositions.asMap().entries.map((entry) { - return _buildPlusButton(entry.key, entry.value); + ); }).toList(), - // כפתורי המרווח (רק בחיפוש מתקדם) - if (widget.widget.tab.isAdvancedSearchEnabled) - ..._buildSpacingButtons(), - ], + // כפתורי ה+ (רק בחיפוש מתקדם) + if (widget.widget.tab.isAdvancedSearchEnabled) + ..._wordPositions.asMap().entries.map((entry) { + return _buildPlusButton(entry.key, entry.value); + }).toList(), + // כפתורי המרווח (רק בחיפוש מתקדם) + if (widget.widget.tab.isAdvancedSearchEnabled) + ..._buildSpacingButtons(), + ], + ), ); } } From f32c033de0f248bae374515dcb08b6e94c80733e Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 31 Jul 2025 02:09:04 +0300 Subject: [PATCH 052/197] =?UTF-8?q?=D7=9C=D7=97=D7=99=D7=A6=D7=94=20=D7=91?= =?UTF-8?q?=D7=95=D7=93=D7=93=D7=AA=20=D7=91=D7=9E=D7=92=D7=99=D7=A8=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/enhanced_search_field.dart | 176 +++++++++++-------- lib/search/view/search_options_dropdown.dart | 102 +++++------ 2 files changed, 160 insertions(+), 118 deletions(-) diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index ba47111e2..11e35d56e 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -771,8 +771,20 @@ class _EnhancedSearchFieldState extends State { void _updateSearchOptionsOverlay() { // עדכון המגירה אם היא פתוחה if (_searchOptionsOverlay != null) { + // שמירת מיקום הסמן לפני העדכון + final currentSelection = widget.widget.tab.queryController.selection; + _hideSearchOptionsOverlay(); _showSearchOptionsOverlay(); + + // החזרת מיקום הסמן אחרי העדכון + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + print( + 'DEBUG: Restoring cursor position in update: ${currentSelection.baseOffset}'); + widget.widget.tab.queryController.selection = currentSelection; + } + }); } } @@ -887,15 +899,16 @@ class _EnhancedSearchFieldState extends State { idx = end; } -if (text.isNotEmpty && _wordPositions.isEmpty) { + if (text.isNotEmpty && _wordPositions.isEmpty) { // החישוב נכשל למרות שיש טקסט. ננסה שוב ב-frame הבא. -WidgetsBinding.instance.addPostFrameCallback((_) { -if (mounted) { // ודא שהווידג'ט עדיין קיים -_calculateWordPositions(); -} -}); -return; // צא מהפונקציה כדי לא לקרוא ל-setState עם מידע שגוי -} + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + // ודא שהווידג'ט עדיין קיים + _calculateWordPositions(); + } + }); + return; // צא מהפונקציה כדי לא לקרוא ל-setState עם מידע שגוי + } setState(() {}); } @@ -967,7 +980,8 @@ return; // צא מהפונקציה כדי לא לקרוא ל-setState עם מי altIndex < existingOverlays.length && mounted && // ודא שה-State עדיין קיים Overlay.of(context).mounted && // ודא שה-Overlay קיים - existingOverlays[altIndex].mounted) { // ודא שהבועה הספציפית הזו עדיין על המסך + existingOverlays[altIndex].mounted) { + // ודא שהבועה הספציפית הזו עדיין על המסך return; // אם הבועה כבר קיימת ומוצגת, אל תעשה כלום } @@ -1096,11 +1110,18 @@ return; // צא מהפונקציה כדי לא לקרוא ל-setState עם מי void _showSearchOptionsOverlay() { if (_searchOptionsOverlay != null) return; + + // שמירת מיקום הסמן הנוכחי + final currentSelection = widget.widget.tab.queryController.selection; + print('DEBUG: Saving cursor position: ${currentSelection.baseOffset}'); + final overlayState = Overlay.of(context); final RenderBox? textFieldBox = _textFieldKey.currentContext?.findRenderObject() as RenderBox?; if (textFieldBox == null) return; final textFieldGlobalPosition = textFieldBox.localToGlobal(Offset.zero); + + // יצירת ה-overlay עם delay קטן כדי לוודא שהוא מוכן לקבל לחיצות _searchOptionsOverlay = OverlayEntry( builder: (context) { return Listener( @@ -1156,8 +1177,8 @@ return; // צא מהפונקציה כדי לא לקרוא ל-setState עם מי bottom: BorderSide(color: Colors.grey.shade400, width: 1), ), ), - child: Material( - color: Theme.of(context).scaffoldBackgroundColor, + child: IgnorePointer( + ignoring: false, child: Padding( padding: const EdgeInsets.only( left: 48.0, right: 16.0, top: 8.0, bottom: 8.0), @@ -1172,6 +1193,18 @@ return; // צא מהפונקציה כדי לא לקרוא ל-setState עם מי }, ); overlayState.insert(_searchOptionsOverlay!); + + // החזרת מיקום הסמן אחרי יצירת ה-overlay + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + widget.widget.tab.queryController.selection = currentSelection; + } + }); + + // וידוא שה-overlay מוכן לקבל לחיצות + WidgetsBinding.instance.addPostFrameCallback((_) { + // ה-overlay כעת מוכן לקבל לחיצות + }); } // המילה הנוכחית (לפי מיקום הסמן) @@ -1307,16 +1340,17 @@ return; // צא מהפונקציה כדי לא לקרוא ל-setState עם מי if (state.currentScreen != Screen.search) { _saveDataToTab(); _clearAllOverlays(); - } else if (state.currentScreen == Screen.search) { - // כשחוזרים למסך החיפוש, שחזר את הנתונים והצג את הבועות - WidgetsBinding.instance.addPostFrameCallback((_) { - _restoreDataFromTab(); // 1. שחזר את תוכן הבועות מהזיכרון - // עיכוב נוסף כדי לוודא שהטקסט מעודכן - Future.delayed(const Duration(milliseconds: 50), () { // השאר את העיכוב הקטן הזה - if (mounted) { - _calculateWordPositions(); // 2. חשב מיקומים (עכשיו זה יעבוד) - _showRestoredBubbles(); // 3. הצג את הבועות המשוחזרות - } + } else if (state.currentScreen == Screen.search) { + // כשחוזרים למסך החיפוש, שחזר את הנתונים והצג את הבועות + WidgetsBinding.instance.addPostFrameCallback((_) { + _restoreDataFromTab(); // 1. שחזר את תוכן הבועות מהזיכרון + // עיכוב נוסף כדי לוודא שהטקסט מעודכן + Future.delayed(const Duration(milliseconds: 50), () { + // השאר את העיכוב הקטן הזה + if (mounted) { + _calculateWordPositions(); // 2. חשב מיקומים (עכשיו זה יעבוד) + _showRestoredBubbles(); // 3. הצג את הבועות המשוחזרות + } }); }); } @@ -1519,59 +1553,63 @@ class _SearchOptionsContentState extends State<_SearchOptionsContent> { Widget _buildCheckbox(String option) { final currentOptions = _getCurrentWordOptions(); - return InkWell( - onTap: () { - setState(() { - currentOptions[option] = !currentOptions[option]!; - }); - // עדכון מיידי של התצוגה - widget.onOptionsChanged?.call(); - }, - borderRadius: BorderRadius.circular(4), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 18, - height: 18, - decoration: BoxDecoration( - border: Border.all( + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (details) { + setState(() { + currentOptions[option] = !currentOptions[option]!; + }); + // עדכון מיידי של התצוגה + widget.onOptionsChanged?.call(); + }, + borderRadius: BorderRadius.circular(4), + canRequestFocus: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + border: Border.all( + color: currentOptions[option]! + ? Theme.of(context).primaryColor + : Colors.grey.shade600, + width: 2, + ), + borderRadius: BorderRadius.circular(3), color: currentOptions[option]! - ? Theme.of(context).primaryColor - : Colors.grey.shade600, - width: 2, + ? Theme.of(context).primaryColor.withValues(alpha: 0.1) + : Colors.transparent, ), - borderRadius: BorderRadius.circular(3), - color: currentOptions[option]! - ? Theme.of(context).primaryColor.withValues(alpha: 0.1) - : Colors.transparent, + child: currentOptions[option]! + ? Icon( + Icons.check, + size: 14, + color: Theme.of(context).primaryColor, + ) + : null, ), - child: currentOptions[option]! - ? Icon( - Icons.check, - size: 14, - color: Theme.of(context).primaryColor, - ) - : null, - ), - const SizedBox(width: 6), - Align( - alignment: Alignment.center, - child: Text( - option, - style: TextStyle( - fontSize: 13, - color: Theme.of(context).textTheme.bodyMedium?.color, - height: 1.0, + const SizedBox(width: 6), + Align( + alignment: Alignment.center, + child: Text( + option, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).textTheme.bodyMedium?.color, + height: 1.0, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), - ), - ], + ], + ), ), ), ); diff --git a/lib/search/view/search_options_dropdown.dart b/lib/search/view/search_options_dropdown.dart index 414e989fb..e4c80ee36 100644 --- a/lib/search/view/search_options_dropdown.dart +++ b/lib/search/view/search_options_dropdown.dart @@ -97,60 +97,64 @@ class _SearchOptionsRowState extends State { Widget _buildCheckbox(String option) { final currentOptions = _getCurrentWordOptions(); - return InkWell( - onTap: () { - setState(() { - final currentWord = widget.currentWord; - if (currentWord != null && currentWord.isNotEmpty) { - currentOptions[option] = !currentOptions[option]!; - } - }); - }, - borderRadius: BorderRadius.circular(4), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 18, - height: 18, - decoration: BoxDecoration( - border: Border.all( + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + setState(() { + final currentWord = widget.currentWord; + if (currentWord != null && currentWord.isNotEmpty) { + currentOptions[option] = !currentOptions[option]!; + } + }); + }, + borderRadius: BorderRadius.circular(4), + canRequestFocus: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + border: Border.all( + color: currentOptions[option]! + ? Theme.of(context).primaryColor + : Colors.grey.shade600, + width: 2, + ), + borderRadius: BorderRadius.circular(3), color: currentOptions[option]! - ? Theme.of(context).primaryColor - : Colors.grey.shade600, - width: 2, + ? Theme.of(context).primaryColor.withValues(alpha: 0.1) + : Colors.transparent, ), - borderRadius: BorderRadius.circular(3), - color: currentOptions[option]! - ? Theme.of(context).primaryColor.withValues(alpha: 0.1) - : Colors.transparent, + child: currentOptions[option]! + ? Icon( + Icons.check, + size: 14, + color: Theme.of(context).primaryColor, + ) + : null, ), - child: currentOptions[option]! - ? Icon( - Icons.check, - size: 14, - color: Theme.of(context).primaryColor, - ) - : null, - ), - const SizedBox(width: 6), - Align( - alignment: Alignment.center, - child: Text( - option, - style: TextStyle( - fontSize: 13, - color: Theme.of(context).textTheme.bodyMedium?.color, - height: 1.0, // מבטיח שהטקסט לא יהיה גבוה מדי + const SizedBox(width: 6), + Align( + alignment: Alignment.center, + child: Text( + option, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).textTheme.bodyMedium?.color, + height: 1.0, // מבטיח שהטקסט לא יהיה גבוה מדי + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), - ), - ], + ], + ), ), ), ); From 8546f7003c26f115f4a6ddc6d8f2c24372c5c88a Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 31 Jul 2025 08:44:41 +0300 Subject: [PATCH 053/197] =?UTF-8?q?=D7=91=D7=A0=D7=99=D7=99=D7=AA=20=D7=A8?= =?UTF-8?q?=D7=A9=D7=99=D7=9E=D7=AA=20=D7=94=D7=9E=D7=A7=D7=95=D7=A8=D7=95?= =?UTF-8?q?=D7=AA=20=D7=9E=D7=97=D7=93=D7=A9,=20=D7=91=D7=9B=D7=9C=20?= =?UTF-8?q?=D7=97=D7=99=D7=A4=D7=95=D7=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../view/full_text_facet_filtering.dart | 126 +++++++++++------- 1 file changed, 76 insertions(+), 50 deletions(-) diff --git a/lib/search/view/full_text_facet_filtering.dart b/lib/search/view/full_text_facet_filtering.dart index 836051c27..086d16f82 100644 --- a/lib/search/view/full_text_facet_filtering.dart +++ b/lib/search/view/full_text_facet_filtering.dart @@ -135,7 +135,7 @@ class _SearchFacetFilteringState extends State ? Theme.of(context) .colorScheme .surfaceTint - .withOpacity(_kBackgroundOpacity) + .withValues(alpha: _kBackgroundOpacity) : null, title: Text("${book.title} ($count)"), onTap: () => HardwareKeyboard.instance.isControlPressed @@ -149,22 +149,29 @@ class _SearchFacetFilteringState extends State } Widget _buildBooksList(List books) { - return ListView.builder( - shrinkWrap: true, - itemCount: books.length, - itemBuilder: (context, index) { - final book = books[index]; - final facet = "/${book.topics.replaceAll(', ', '/')}/${book.title}"; - return Builder( - builder: (context) { - final countFuture = context.read().countForFacet(facet); - return FutureBuilder( - future: countFuture, - builder: (context, snapshot) { - if (snapshot.hasData) { - return _buildBookTile(book, snapshot.data!, 0); - } - return const SizedBox.shrink(); + return BlocBuilder( + builder: (context, state) { + return ListView.builder( + shrinkWrap: true, + itemCount: books.length, + itemBuilder: (context, index) { + final book = books[index]; + final facet = "/${book.topics.replaceAll(', ', '/')}/${book.title}"; + return Builder( + builder: (context) { + final countFuture = + context.read().countForFacet(facet); + return FutureBuilder( + key: ValueKey( + '${state.searchQuery}_$facet'), // מפתח שמשתנה עם החיפוש + future: countFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + return _buildBookTile(book, snapshot.data!, 0); + } + return const SizedBox.shrink(); + }, + ); }, ); }, @@ -189,13 +196,13 @@ class _SearchFacetFilteringState extends State ? Theme.of(context) .colorScheme .surfaceTint - .withOpacity(_kBackgroundOpacity) + .withValues(alpha: _kBackgroundOpacity) : null, collapsedBackgroundColor: isSelected ? Theme.of(context) .colorScheme .surfaceTint - .withOpacity(_kBackgroundOpacity) + .withValues(alpha: _kBackgroundOpacity) : null, leading: const Icon(Icons.chevron_right_rounded), trailing: const SizedBox.shrink(), @@ -222,28 +229,41 @@ class _SearchFacetFilteringState extends State List _buildCategoryChildren(Category category, int level) { return [ ...category.subCategories.map((subCategory) { - final countFuture = - context.read().countForFacet(subCategory.path); - return FutureBuilder( - future: countFuture, - builder: (context, snapshot) { - if (snapshot.hasData) { - return _buildCategoryTile(subCategory, snapshot.data!, level + 1); - } - return const SizedBox.shrink(); + return BlocBuilder( + builder: (context, state) { + final countFuture = + context.read().countForFacet(subCategory.path); + return FutureBuilder( + key: ValueKey( + '${state.searchQuery}_${subCategory.path}'), // מפתח שמשתנה עם החיפוש + future: countFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + return _buildCategoryTile( + subCategory, snapshot.data!, level + 1); + } + return const SizedBox.shrink(); + }, + ); }, ); }), ...category.books.map((book) { - final facet = "/${book.topics.replaceAll(', ', '/')}/${book.title}"; - final countFuture = context.read().countForFacet(facet); - return FutureBuilder( - future: countFuture, - builder: (context, snapshot) { - if (snapshot.hasData) { - return _buildBookTile(book, snapshot.data!, level + 1); - } - return const SizedBox.shrink(); + return BlocBuilder( + builder: (context, state) { + final facet = "/${book.topics.replaceAll(', ', '/')}/${book.title}"; + final countFuture = context.read().countForFacet(facet); + return FutureBuilder( + key: ValueKey( + '${state.searchQuery}_$facet'), // מפתח שמשתנה עם החיפוש + future: countFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + return _buildBookTile(book, snapshot.data!, level + 1); + } + return const SizedBox.shrink(); + }, + ); }, ); }), @@ -270,19 +290,25 @@ class _SearchFacetFilteringState extends State return const Center(child: Text('No library data available')); } - final rootCategory = libraryState.library!; - final countFuture = - context.read().countForFacet(rootCategory.path); - return FutureBuilder( - future: countFuture, - builder: (context, snapshot) { - if (snapshot.hasData) { - return SingleChildScrollView( - key: PageStorageKey(widget.tab), - child: _buildCategoryTile(rootCategory, snapshot.data!, 0), - ); - } - return const Center(child: CircularProgressIndicator()); + return BlocBuilder( + builder: (context, searchState) { + final rootCategory = libraryState.library!; + final countFuture = + context.read().countForFacet(rootCategory.path); + return FutureBuilder( + key: ValueKey( + '${searchState.searchQuery}_${rootCategory.path}'), // מפתח שמשתנה עם החיפוש + future: countFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + return SingleChildScrollView( + key: PageStorageKey(widget.tab), + child: _buildCategoryTile(rootCategory, snapshot.data!, 0), + ); + } + return const Center(child: CircularProgressIndicator()); + }, + ); }, ); }, From 1337d350b10da5f7e01efa548c2173eccb7d4df8 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 31 Jul 2025 16:19:15 +0300 Subject: [PATCH 054/197] =?UTF-8?q?=D7=94=D7=AA=D7=90=D7=9E=D7=94=20=D7=9C?= =?UTF-8?q?=D7=9E=D7=A1=D7=9B=D7=99=D7=9D=20=D7=90=D7=A0=D7=9B=D7=99=D7=99?= =?UTF-8?q?=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../view/full_text_settings_widgets.dart | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/lib/search/view/full_text_settings_widgets.dart b/lib/search/view/full_text_settings_widgets.dart index 1814e89de..268493c36 100644 --- a/lib/search/view/full_text_settings_widgets.dart +++ b/lib/search/view/full_text_settings_widgets.dart @@ -196,7 +196,7 @@ class _SearchTermsDisplayState extends State { } double _calculateFormattedTextWidth(String text, BuildContext context) { - if (text.trim().isEmpty) return 0; + if (text.trim().isEmpty) return 0.0; // יצירת TextSpan עם הטקסט המעוצב final spans = _buildFormattedTextSpans(text, context); @@ -447,23 +447,21 @@ class _SearchTermsDisplayState extends State { return LayoutBuilder( builder: (context, constraints) { - // הגדרת רוחב מינימלי ומקסימלי - const minWidth = 150.0; // רוחב מינימלי - final maxWidth = - constraints.maxWidth - 20; // כל הרוחב הזמין פחות מרווח - - // חישוב רוחב בהתבסס על הרוחב האמיתי של הטקסט המעוצב - final formattedTextWidth = displayText.isEmpty - ? 0 + const double desiredMinWidth = 150.0; + final double maxWidth = constraints.maxWidth - 20; + final double minWidth = desiredMinWidth.clamp(0.0, maxWidth); + + final double formattedTextWidth = displayText.isEmpty + ? 0.0 // ודא שגם כאן זה double : _calculateFormattedTextWidth(displayText, context); - // חישוב רוחב סופי double calculatedWidth; if (displayText.isEmpty) { calculatedWidth = minWidth; } else { - // הוספת מרווח נוסף לטקסט (padding + border + scroll space) final textWithPadding = formattedTextWidth + 60; + + // התיקון: מוסיפים .toDouble() כדי להבטיח המרה בטוחה calculatedWidth = textWithPadding.clamp(minWidth, maxWidth).toDouble(); } From e1ce440602a4708644035da733fa0846f68038c9 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 31 Jul 2025 16:47:57 +0300 Subject: [PATCH 055/197] =?UTF-8?q?=D7=94=D7=AA=D7=90=D7=9E=D7=AA=20=D7=9E?= =?UTF-8?q?=D7=92=D7=99=D7=A8=D7=AA=20=D7=94=D7=90=D7=A4=D7=A9=D7=A8=D7=95?= =?UTF-8?q?=D7=99=D7=95=D7=AA=20=D7=9C=D7=9E=D7=A1=D7=9B=D7=99=D7=9D=20?= =?UTF-8?q?=D7=90=D7=A0=D7=9B=D7=99=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/enhanced_search_field.dart | 125 +++++++++++++------ lib/search/view/search_options_dropdown.dart | 81 ++++++------ 2 files changed, 122 insertions(+), 84 deletions(-) diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 11e35d56e..3b340d107 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -1111,42 +1111,34 @@ class _EnhancedSearchFieldState extends State { void _showSearchOptionsOverlay() { if (_searchOptionsOverlay != null) return; - // שמירת מיקום הסמן הנוכחי final currentSelection = widget.widget.tab.queryController.selection; - print('DEBUG: Saving cursor position: ${currentSelection.baseOffset}'); - final overlayState = Overlay.of(context); final RenderBox? textFieldBox = _textFieldKey.currentContext?.findRenderObject() as RenderBox?; if (textFieldBox == null) return; final textFieldGlobalPosition = textFieldBox.localToGlobal(Offset.zero); - // יצירת ה-overlay עם delay קטן כדי לוודא שהוא מוכן לקבל לחיצות _searchOptionsOverlay = OverlayEntry( builder: (context) { return Listener( behavior: HitTestBehavior.translucent, onPointerDown: (PointerDownEvent event) { - // בדיקה אם הלחיצה היא מחוץ לאזור שדה החיפוש והמגירה final clickPosition = event.position; - - // אזור שדה החיפוש (מורחב כדי לכלול את כל האזור כולל הכפתורים) final textFieldRect = Rect.fromLTWH( - textFieldGlobalPosition.dx - 20, // מרווח נוסף משמאל - textFieldGlobalPosition.dy - 20, // מרווח נוסף מלמעלה - textFieldBox.size.width + 40, // רוחב מורחב יותר - textFieldBox.size.height + 40, // גובה מורחב יותר + textFieldGlobalPosition.dx, + textFieldGlobalPosition.dy, + textFieldBox.size.width, + textFieldBox.size.height, ); - // אזור המגירה (מורחב מעט) + // אזור המגירה המשוער - אנחנו לא יודעים את הגובה המדויק אז ניקח טווח סביר final drawerRect = Rect.fromLTWH( - textFieldGlobalPosition.dx - 10, - textFieldGlobalPosition.dy + textFieldBox.size.height - 5, - textFieldBox.size.width + 20, - 50.0, // גובה מורחב + textFieldGlobalPosition.dx, + textFieldGlobalPosition.dy + textFieldBox.size.height, + textFieldBox.size.width, + 120.0, // גובה משוער מקסימלי לשתי שורות ); - // אם הלחיצה מחוץ לשני האזורים, סגור את המגירה if (!textFieldRect.contains(clickPosition) && !drawerRect.contains(clickPosition)) { _hideSearchOptionsOverlay(); @@ -1155,30 +1147,35 @@ class _EnhancedSearchFieldState extends State { }, child: Stack( children: [ - // המגירה עצמה Positioned( left: textFieldGlobalPosition.dx, top: textFieldGlobalPosition.dy + textFieldBox.size.height, width: textFieldBox.size.width, - child: Container( - height: 40.0, - decoration: BoxDecoration( - color: Theme.of(context).scaffoldBackgroundColor, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.25), - blurRadius: 8, - offset: const Offset(0, 4), + // ======== התיקון מתחיל כאן ======== + child: AnimatedSize( + // 1. עוטפים ב-AnimatedSize + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: Container( + // height: 40.0, // 2. מסירים את הגובה הקבוע + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.25), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + border: Border( + left: BorderSide(color: Colors.grey.shade400, width: 1), + right: + BorderSide(color: Colors.grey.shade400, width: 1), + bottom: + BorderSide(color: Colors.grey.shade400, width: 1), ), - ], - border: Border( - left: BorderSide(color: Colors.grey.shade400, width: 1), - right: BorderSide(color: Colors.grey.shade400, width: 1), - bottom: BorderSide(color: Colors.grey.shade400, width: 1), ), - ), - child: IgnorePointer( - ignoring: false, child: Padding( padding: const EdgeInsets.only( left: 48.0, right: 16.0, top: 8.0, bottom: 8.0), @@ -1617,11 +1614,59 @@ class _SearchOptionsContentState extends State<_SearchOptionsContent> { @override Widget build(BuildContext context) { - return Wrap( - spacing: 16.0, - runSpacing: 8.0, - children: - _availableOptions.map((option) => _buildCheckbox(option)).toList(), + // 1. נקודת ההכרעה: מה הרוחב המינימלי שדרוש כדי להציג הכל בשורה אחת? + // אפשר לשחק עם הערך הזה, אבל 650 הוא נקודת התחלה טובה. + const double singleRowThreshold = 650.0; + + return LayoutBuilder( + builder: (context, constraints) { + // constraints.maxWidth נותן לנו את הרוחב הזמין האמיתי למגירה + final availableWidth = constraints.maxWidth; + + // 2. אם המסך רחב מספיק - נשתמש ב-Wrap (שיראה כמו שורה אחת) + if (availableWidth >= singleRowThreshold) { + return Wrap( + spacing: 16.0, + runSpacing: 8.0, + alignment: WrapAlignment.center, // מרכוז יפה של הפריטים + children: _availableOptions + .map((option) => _buildCheckbox(option)) + .toList(), + ); + } + // 3. אם המסך צר מדי - נעבור לתצוגת טורים מסודרת + else { + // מחלקים את רשימת האפשרויות לשתי עמודות + final int middle = (_availableOptions.length / 2).ceil(); + final List column1Options = + _availableOptions.sublist(0, middle); + final List column2Options = _availableOptions.sublist(middle); + + // פונקציית עזר לבניית עמודה + Widget buildColumn(List options) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: options + .map((option) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: _buildCheckbox(option), + )) + .toList(), + ); + } + + // מחזירים שורה שמכילה את שתי העמודות + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + buildColumn(column1Options), + buildColumn(column2Options), + ], + ); + } + }, ); } } diff --git a/lib/search/view/search_options_dropdown.dart b/lib/search/view/search_options_dropdown.dart index e4c80ee36..ad14e3d3c 100644 --- a/lib/search/view/search_options_dropdown.dart +++ b/lib/search/view/search_options_dropdown.dart @@ -160,55 +160,48 @@ class _SearchOptionsRowState extends State { ); } +// lib\search\view\search_options_dropdown.dart + @override Widget build(BuildContext context) { - return AnimatedContainer( + return AnimatedSize( + // הוחלף מ-AnimatedContainer duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, - height: widget.isVisible ? 40.0 : 0.0, - width: double.infinity, - child: widget.isVisible - ? ColoredBox( - color: Colors.white, // רקע אטום מלא - child: Container( - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.25), - blurRadius: 8, - offset: const Offset(0, 4), - ), - ], - border: Border( - left: BorderSide(color: Colors.grey.shade400, width: 1), - right: BorderSide(color: Colors.grey.shade400, width: 1), - bottom: BorderSide(color: Colors.grey.shade400, width: 1), - ), - ), - child: ColoredBox( - color: Colors.white, // עוד שכבת רקע אטום - child: Material( - color: Colors.white, - child: ColoredBox( - color: Colors.white, // שכבה נוספת - child: Padding( - padding: const EdgeInsets.only( - left: 48.0, right: 16.0, top: 8.0, bottom: 8.0), - child: Wrap( - spacing: 16.0, - runSpacing: 8.0, - children: _availableOptions - .map((option) => _buildCheckbox(option)) - .toList(), - ), - ), - ), - ), - ), + alignment: Alignment.topCenter, + child: Visibility( + visible: widget.isVisible, + maintainState: true, // שומר את המצב של ה-Checkboxes גם כשהמגירה סגורה + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), // צל מעודן יותר + blurRadius: 8, + offset: const Offset(0, 4), ), - ) - : const SizedBox.shrink(), + ], + border: Border( + left: BorderSide(color: Colors.grey.shade300), + right: BorderSide(color: Colors.grey.shade300), + bottom: BorderSide(color: Colors.grey.shade300), + ), + ), + child: Padding( + padding: const EdgeInsets.only( + left: 48.0, right: 16.0, top: 8.0, bottom: 8.0), + child: Wrap( + spacing: 16.0, // רווח אופקי בין אלמנטים + runSpacing: 8.0, // רווח אנכי בין שורות (זה המפתח!) + children: _availableOptions + .map((option) => _buildCheckbox(option)) + .toList(), + ), + ), + ), + ), ); } } From adf6827ad162eba921e44258c5cac20b3ebdedf8 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 31 Jul 2025 20:24:04 +0300 Subject: [PATCH 056/197] =?UTF-8?q?=D7=94=D7=A2=D7=91=D7=A8=D7=AA=20'?= =?UTF-8?q?=D7=97=D7=99=D7=A4=D7=95=D7=A9=20=D7=9E=D7=AA=D7=A7=D7=93=D7=9D?= =?UTF-8?q?'=20=D7=9C=D7=9B=D7=A4=D7=AA=D7=95=D7=A8=20=D7=AA=D7=9C=D7=AA?= =?UTF-8?q?=20=D7=9E=D7=A6=D7=91=D7=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/bloc/search_bloc.dart | 23 ++++-- lib/search/bloc/search_event.dart | 5 +- lib/search/bloc/search_state.dart | 1 + .../search_configuration_example.dart | 4 +- lib/search/models/search_configuration.dart | 29 +++++--- lib/search/view/enhanced_search_field.dart | 40 ++++++----- .../view/full_text_settings_widgets.dart | 72 +++++++------------ lib/search/view/tantivy_full_text_search.dart | 40 ++++------- lib/tabs/models/searching_tab.dart | 5 -- 9 files changed, 103 insertions(+), 116 deletions(-) diff --git a/lib/search/bloc/search_bloc.dart b/lib/search/bloc/search_bloc.dart index 56ea7cc88..f83e34adc 100644 --- a/lib/search/bloc/search_bloc.dart +++ b/lib/search/bloc/search_bloc.dart @@ -1,6 +1,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:otzaria/search/bloc/search_event.dart'; import 'package:otzaria/search/bloc/search_state.dart'; +import 'package:otzaria/search/models/search_configuration.dart'; import 'package:otzaria/data/data_providers/tantivy_data_provider.dart'; import 'package:otzaria/data/repository/data_repository.dart'; import 'package:otzaria/search/search_repository.dart'; @@ -11,7 +12,7 @@ class SearchBloc extends Bloc { SearchBloc() : super(const SearchState()) { on(_onUpdateSearchQuery); on(_onUpdateDistance); - on(_onToggleFuzzy); + on(_onToggleSearchMode); on(_onUpdateBooksToSearch); on(_onAddFacet); on(_onRemoveFacet); @@ -137,11 +138,25 @@ class SearchBloc extends Bloc { add(UpdateSearchQuery(state.searchQuery)); } - void _onToggleFuzzy( - ToggleFuzzy event, + void _onToggleSearchMode( + ToggleSearchMode event, Emitter emit, ) { - final newConfig = state.configuration.copyWith(fuzzy: !state.fuzzy); + // מעבר בין שלושת המצבים: מתקדם -> מדוייק -> מקורב -> מתקדם + SearchMode newMode; + switch (state.configuration.searchMode) { + case SearchMode.advanced: + newMode = SearchMode.exact; + break; + case SearchMode.exact: + newMode = SearchMode.fuzzy; + break; + case SearchMode.fuzzy: + newMode = SearchMode.advanced; + break; + } + + final newConfig = state.configuration.copyWith(searchMode: newMode); emit(state.copyWith(configuration: newConfig)); add(UpdateSearchQuery(state.searchQuery)); } diff --git a/lib/search/bloc/search_event.dart b/lib/search/bloc/search_event.dart index ad9894243..463b32182 100644 --- a/lib/search/bloc/search_event.dart +++ b/lib/search/bloc/search_event.dart @@ -19,7 +19,8 @@ class UpdateSearchQuery extends SearchEvent { final Map? customSpacing; final Map>? alternativeWords; final Map>? searchOptions; - UpdateSearchQuery(this.query, {this.customSpacing, this.alternativeWords, this.searchOptions}); + UpdateSearchQuery(this.query, + {this.customSpacing, this.alternativeWords, this.searchOptions}); } class UpdateDistance extends SearchEvent { @@ -27,7 +28,7 @@ class UpdateDistance extends SearchEvent { UpdateDistance(this.distance); } -class ToggleFuzzy extends SearchEvent {} +class ToggleSearchMode extends SearchEvent {} class UpdateBooksToSearch extends SearchEvent { final Set books; diff --git a/lib/search/bloc/search_state.dart b/lib/search/bloc/search_state.dart index b6c8928d4..a4c4c587e 100644 --- a/lib/search/bloc/search_state.dart +++ b/lib/search/bloc/search_state.dart @@ -50,6 +50,7 @@ class SearchState { // Getters לנוחות גישה להגדרות (backward compatibility) int get distance => configuration.distance; bool get fuzzy => configuration.fuzzy; + bool get isAdvancedSearchEnabled => configuration.isAdvancedSearchEnabled; List get currentFacets => configuration.currentFacets; ResultsOrder get sortBy => configuration.sortBy; int get numResults => configuration.numResults; diff --git a/lib/search/examples/search_configuration_example.dart b/lib/search/examples/search_configuration_example.dart index 2830b98ba..13666eb84 100644 --- a/lib/search/examples/search_configuration_example.dart +++ b/lib/search/examples/search_configuration_example.dart @@ -143,7 +143,7 @@ class CustomSearchConfiguration { dotAll: true, unicode: true, distance: 1, - fuzzy: false, + searchMode: SearchMode.exact, numResults: 50, ); } @@ -152,7 +152,7 @@ class CustomSearchConfiguration { static SearchConfiguration fuzzySearch() { return const SearchConfiguration( regexEnabled: false, - fuzzy: true, + searchMode: SearchMode.fuzzy, distance: 3, numResults: 200, ); diff --git a/lib/search/models/search_configuration.dart b/lib/search/models/search_configuration.dart index a6f8a75d9..add55d89a 100644 --- a/lib/search/models/search_configuration.dart +++ b/lib/search/models/search_configuration.dart @@ -1,11 +1,18 @@ import 'package:search_engine/search_engine.dart'; +/// מצבי החיפוש השונים +enum SearchMode { + advanced, // חיפוש מתקדם + exact, // חיפוש מדוייק + fuzzy, // חיפוש מקורב +} + /// מחלקה שמרכזת את כל הגדרות החיפוש במקום אחד /// כוללת הגדרות קיימות והגדרות עתידיות לרגקס class SearchConfiguration { // הגדרות חיפוש קיימות final int distance; - final bool fuzzy; + final SearchMode searchMode; final ResultsOrder sortBy; final int numResults; final List currentFacets; @@ -20,7 +27,7 @@ class SearchConfiguration { const SearchConfiguration({ // ערכי ברירת מחדל קיימים this.distance = 2, - this.fuzzy = false, + this.searchMode = SearchMode.advanced, this.sortBy = ResultsOrder.catalogue, this.numResults = 100, this.currentFacets = const ["/"], @@ -36,7 +43,7 @@ class SearchConfiguration { /// יוצר עותק עם שינויים SearchConfiguration copyWith({ int? distance, - bool? fuzzy, + SearchMode? searchMode, ResultsOrder? sortBy, int? numResults, List? currentFacets, @@ -48,7 +55,7 @@ class SearchConfiguration { }) { return SearchConfiguration( distance: distance ?? this.distance, - fuzzy: fuzzy ?? this.fuzzy, + searchMode: searchMode ?? this.searchMode, sortBy: sortBy ?? this.sortBy, numResults: numResults ?? this.numResults, currentFacets: currentFacets ?? this.currentFacets, @@ -64,7 +71,7 @@ class SearchConfiguration { Map toMap() { return { 'distance': distance, - 'fuzzy': fuzzy, + 'searchMode': searchMode.index, 'sortBy': sortBy.index, 'numResults': numResults, 'currentFacets': currentFacets, @@ -80,7 +87,7 @@ class SearchConfiguration { factory SearchConfiguration.fromMap(Map map) { return SearchConfiguration( distance: map['distance'] ?? 2, - fuzzy: map['fuzzy'] ?? false, + searchMode: SearchMode.values[map['searchMode'] ?? 0], sortBy: ResultsOrder.values[map['sortBy'] ?? 0], numResults: map['numResults'] ?? 100, currentFacets: List.from(map['currentFacets'] ?? ["/"]), @@ -105,12 +112,16 @@ class SearchConfiguration { return flags; } + // Getters לתאימות לאחור + bool get fuzzy => searchMode == SearchMode.fuzzy; + bool get isAdvancedSearchEnabled => searchMode == SearchMode.advanced; + @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is SearchConfiguration && other.distance == distance && - other.fuzzy == fuzzy && + other.searchMode == searchMode && other.sortBy == sortBy && other.numResults == numResults && other.currentFacets.toString() == currentFacets.toString() && @@ -125,7 +136,7 @@ class SearchConfiguration { int get hashCode { return Object.hash( distance, - fuzzy, + searchMode, sortBy, numResults, currentFacets, @@ -141,7 +152,7 @@ class SearchConfiguration { String toString() { return 'SearchConfiguration(' 'distance: $distance, ' - 'fuzzy: $fuzzy, ' + 'searchMode: $searchMode, ' 'sortBy: $sortBy, ' 'numResults: $numResults, ' 'facets: $currentFacets, ' diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 3b340d107..670fcf205 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:otzaria/search/bloc/search_bloc.dart'; import 'package:otzaria/search/bloc/search_event.dart'; +import 'package:otzaria/search/bloc/search_state.dart'; import 'package:otzaria/search/models/search_terms_model.dart'; import 'package:otzaria/search/view/tantivy_full_text_search.dart'; import 'package:otzaria/navigation/bloc/navigation_bloc.dart'; @@ -1431,18 +1432,23 @@ class _EnhancedSearchFieldState extends State { suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ - if (widget.widget.tab.isAdvancedSearchEnabled) - IconButton( - onPressed: () => _toggleSearchOptions( - !_isSearchOptionsVisible), - icon: const Icon(Icons.keyboard_arrow_down), - focusNode: FocusNode( - // <-- התוספת המרכזית - canRequestFocus: - false, // מונע מהכפתור לבקש פוקוס - skipTraversal: true, // מדלג עליו בניווט מקלדת - ), - ), + BlocBuilder( + builder: (context, state) { + if (!state.isAdvancedSearchEnabled) + return const SizedBox.shrink(); + return IconButton( + onPressed: () => _toggleSearchOptions( + !_isSearchOptionsVisible), + icon: const Icon(Icons.keyboard_arrow_down), + focusNode: FocusNode( + // <-- התוספת המרכזית + canRequestFocus: + false, // מונע מהכפתור לבקש פוקוס + skipTraversal: true, // מדלג עליו בניווט מקלדת + ), + ); + }, + ), IconButton( icon: const Icon(Icons.clear), onPressed: () { @@ -1493,13 +1499,11 @@ class _EnhancedSearchFieldState extends State { ); }).toList(), // כפתורי ה+ (רק בחיפוש מתקדם) - if (widget.widget.tab.isAdvancedSearchEnabled) - ..._wordPositions.asMap().entries.map((entry) { - return _buildPlusButton(entry.key, entry.value); - }).toList(), + ..._wordPositions.asMap().entries.map((entry) { + return _buildPlusButton(entry.key, entry.value); + }).toList(), // כפתורי המרווח (רק בחיפוש מתקדם) - if (widget.widget.tab.isAdvancedSearchEnabled) - ..._buildSpacingButtons(), + ..._buildSpacingButtons(), ], ), ); diff --git a/lib/search/view/full_text_settings_widgets.dart b/lib/search/view/full_text_settings_widgets.dart index 268493c36..c8bf989d3 100644 --- a/lib/search/view/full_text_settings_widgets.dart +++ b/lib/search/view/full_text_settings_widgets.dart @@ -4,13 +4,14 @@ import 'package:flutter_spinbox/flutter_spinbox.dart'; import 'package:otzaria/search/bloc/search_bloc.dart'; import 'package:otzaria/search/bloc/search_event.dart'; import 'package:otzaria/search/bloc/search_state.dart'; +import 'package:otzaria/search/models/search_configuration.dart'; import 'package:otzaria/tabs/models/searching_tab.dart'; import 'package:otzaria/search/view/tantivy_search_results.dart'; import 'package:search_engine/search_engine.dart'; import 'package:toggle_switch/toggle_switch.dart'; -class FuzzyToggle extends StatelessWidget { - const FuzzyToggle({ +class SearchModeToggle extends StatelessWidget { + const SearchModeToggle({ super.key, required this.tab, }); @@ -21,19 +22,32 @@ class FuzzyToggle extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { + int currentIndex; + switch (state.configuration.searchMode) { + case SearchMode.advanced: + currentIndex = 0; + break; + case SearchMode.exact: + currentIndex = 1; + break; + case SearchMode.fuzzy: + currentIndex = 2; + break; + } + return Padding( padding: const EdgeInsets.all(8.0), child: ToggleSwitch( - minWidth: 150, + minWidth: 108, minHeight: 45, inactiveBgColor: Colors.grey, inactiveFgColor: Colors.white, - initialLabelIndex: state.fuzzy ? 1 : 0, - totalSwitches: 2, - labels: const ['חיפוש מדוייק', 'חיפוש מקורב'], + initialLabelIndex: currentIndex, + totalSwitches: 3, + labels: const ['חיפוש מתקדם', 'חיפוש מדוייק', 'חיפוש מקורב'], radiusStyle: true, onToggle: (index) { - context.read().add(ToggleFuzzy()); + context.read().add(ToggleSearchMode()); }, ), ); @@ -83,14 +97,14 @@ class _FuzzyDistanceState extends State { final isEnabled = !state.fuzzy && !hasCustomSpacing; return SizedBox( - width: 200, + width: 160, child: Padding( padding: const EdgeInsets.all(16.0), child: SpinBox( enabled: isEnabled, decoration: InputDecoration( labelText: hasCustomSpacing - ? 'מרווח בין מילים (מושבת - יש מרווחים מותאמים)' + ? 'מרווח בין מילים (מושבת)' : 'מרווח בין מילים', labelStyle: TextStyle( color: hasCustomSpacing ? Colors.grey : null, @@ -125,7 +139,7 @@ class NumOfResults extends StatelessWidget { return BlocBuilder( builder: (context, state) { return SizedBox( - width: 160, + width: 150, height: 52, // גובה קבוע child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), @@ -523,44 +537,6 @@ class _SearchTermsDisplayState extends State { } } -class AdvancedSearchToggle extends StatelessWidget { - const AdvancedSearchToggle({ - super.key, - required this.tab, - required this.onChanged, - }); - - final SearchingTab tab; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 100, // רוחב צר יותר - height: 52, // גובה קבוע כמו שאר הבקרות - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - child: InputDecorator( - decoration: const InputDecoration( - labelText: 'חיפוש מתקדם', - labelStyle: TextStyle(fontSize: 13), // גופן קטן יותר - border: OutlineInputBorder(), - contentPadding: - EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), - ), - child: Center( - child: Checkbox( - value: tab.isAdvancedSearchEnabled, - onChanged: (value) => onChanged(value ?? true), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - ), - ), - ); - } -} - class OrderOfResults extends StatelessWidget { const OrderOfResults({ super.key, diff --git a/lib/search/view/tantivy_full_text_search.dart b/lib/search/view/tantivy_full_text_search.dart index 1d852bb2b..da007b19d 100644 --- a/lib/search/view/tantivy_full_text_search.dart +++ b/lib/search/view/tantivy_full_text_search.dart @@ -88,18 +88,6 @@ class _TantivyFullTextSearchState extends State if (_showIndexWarning) _buildIndexWarning(), Row( children: [ - // כפתור חיפוש מתקדם למסכים קטנים - מימין - SizedBox( - width: 70, // רוחב קטן יותר למסכים קטנים - child: AdvancedSearchToggle( - tab: widget.tab, - onChanged: (value) { - setState(() { - widget.tab.isAdvancedSearchEnabled = value; - }); - }, - ), - ), _buildMenuButton(), Expanded(child: TantivySearchField(widget: widget)), ], @@ -138,7 +126,7 @@ class _TantivyFullTextSearchState extends State NumOfResults(tab: widget.tab), ], ), - FuzzyToggle(tab: widget.tab), + SearchModeToggle(tab: widget.tab), Expanded( child: SearchFacetFiltering( tab: widget.tab, @@ -162,20 +150,11 @@ class _TantivyFullTextSearchState extends State Row( mainAxisSize: MainAxisSize.min, children: [ - // כפתור חיפוש מתקדם - מימין - AdvancedSearchToggle( - tab: widget.tab, - onChanged: (value) { - setState(() { - widget.tab.isAdvancedSearchEnabled = value; - }); - }, - ), Expanded( child: TantivySearchField(widget: widget), ), FuzzyDistance(tab: widget.tab), - FuzzyToggle(tab: widget.tab) + SearchModeToggle(tab: widget.tab) ], ), Expanded( @@ -249,15 +228,20 @@ class _TantivyFullTextSearchState extends State padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), child: Row( children: [ - // מילות החיפוש - תמיד תופס מקום, אבל מוסתר כשלא בחיפוש מתקדם + // מילות החיפוש - תמיד תופס מקום, אבל מוצג רק בחיפוש מתקדם Expanded( - child: widget.tab.isAdvancedSearchEnabled - ? SearchTermsDisplay(tab: widget.tab) - : const SizedBox.shrink(), // מקום ריק שמחזיק את הפרופורציות + child: BlocBuilder( + builder: (context, searchState) { + return searchState.isAdvancedSearchEnabled + ? SearchTermsDisplay(tab: widget.tab) + : const SizedBox + .shrink(); // מקום ריק שמחזיק את הפרופורציות + }, + ), ), // ספירת התוצאות עם תווית SizedBox( - width: 180, // רוחב קבוע כמו שאר הבקרות + width: 161, // רוחב קבוע כמו שאר הבקרות height: 52, // אותו גובה כמו הבקרות האחרות child: Padding( padding: const EdgeInsets.symmetric( diff --git a/lib/tabs/models/searching_tab.dart b/lib/tabs/models/searching_tab.dart index 65645b76a..8ece12ac1 100644 --- a/lib/tabs/models/searching_tab.dart +++ b/lib/tabs/models/searching_tab.dart @@ -13,9 +13,6 @@ class SearchingTab extends OpenedTab { final ItemScrollController scrollController = ItemScrollController(); List allBooks = []; - // מצב חיפוש מתקדם - bool isAdvancedSearchEnabled = true; - // אפשרויות חיפוש לכל מילה (מילה_אינדקס -> אפשרויות) final Map> searchOptions = {}; @@ -60,7 +57,6 @@ class SearchingTab extends OpenedTab { @override factory SearchingTab.fromJson(Map json) { final tab = SearchingTab(json['title'], json['searchText']); - tab.isAdvancedSearchEnabled = json['isAdvancedSearchEnabled'] ?? true; return tab; } @@ -69,7 +65,6 @@ class SearchingTab extends OpenedTab { return { 'title': title, 'searchText': queryController.text, - 'isAdvancedSearchEnabled': isAdvancedSearchEnabled, 'type': 'SearchingTabWindow' }; } From 6c14b2d657749c077f157727fa5ea2f38e9384eb Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 1 Aug 2025 11:27:28 +0300 Subject: [PATCH 057/197] =?UTF-8?q?=D7=94=D7=AA=D7=90=D7=9E=D7=AA=20=D7=A8?= =?UTF-8?q?=D7=A9=D7=99=D7=9E=D7=AA=20=D7=94=D7=9E=D7=A7=D7=95=D7=A8=D7=95?= =?UTF-8?q?=D7=AA=20=D7=9C=D7=AA=D7=95=D7=A6=D7=90=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data_providers/tantivy_data_provider.dart | 428 +++++++++++++++++- lib/search/bloc/search_bloc.dart | 95 +++- lib/search/bloc/search_event.dart | 6 + lib/search/bloc/search_state.dart | 6 + .../view/full_text_facet_filtering.dart | 98 ++-- lib/search/view/tantivy_full_text_search.dart | 2 +- lib/tabs/models/searching_tab.dart | 47 +- 7 files changed, 623 insertions(+), 59 deletions(-) diff --git a/lib/data/data_providers/tantivy_data_provider.dart b/lib/data/data_providers/tantivy_data_provider.dart index 234c5f1b1..30c83bdda 100644 --- a/lib/data/data_providers/tantivy_data_provider.dart +++ b/lib/data/data_providers/tantivy_data_provider.dart @@ -17,6 +17,22 @@ class TantivyDataProvider { static final TantivyDataProvider _singleton = TantivyDataProvider(); static TantivyDataProvider instance = _singleton; + // Global cache for facet counts + static final Map _globalFacetCache = {}; + static String _lastCachedQuery = ''; + + // Track ongoing counts to prevent duplicates + static final Set _ongoingCounts = {}; + + /// Clear global cache when starting new search + static void clearGlobalCache() { + print( + '🧹 Clearing global facet cache (${_globalFacetCache.length} entries)'); + _globalFacetCache.clear(); + _ongoingCounts.clear(); + _lastCachedQuery = ''; + } + /// Indicates whether the indexing process is currently running ValueNotifier isIndexing = ValueNotifier(false); @@ -103,47 +119,95 @@ class TantivyDataProvider { } Future countTexts(String query, List books, List facets, - {bool fuzzy = false, int distance = 2}) async { - final index = await engine; + {bool fuzzy = false, + int distance = 2, + Map? customSpacing, + Map>? alternativeWords, + Map>? searchOptions}) async { + // Global cache check + final cacheKey = + '$query|${facets.join(',')}|$fuzzy|$distance|${customSpacing.toString()}|${alternativeWords.toString()}|${searchOptions.toString()}'; - // Debug: CountTexts for "$query" + if (_lastCachedQuery == query && _globalFacetCache.containsKey(cacheKey)) { + print('🎯 GLOBAL CACHE HIT for $facets: ${_globalFacetCache[cacheKey]}'); + return _globalFacetCache[cacheKey]!; + } - // המרת החיפוש הפשוט לפורמט החדש - ללא רגקס אמיתי! - List regexTerms; - if (!fuzzy) { - // חיפוש מדוייק - נפצל למילים אם יש יותר ממילה אחת - final words = query.trim().split(RegExp(r'\s+')); - if (words.length > 1) { - regexTerms = words; - } else { - regexTerms = [query]; + // Check if this count is already in progress + if (_ongoingCounts.contains(cacheKey)) { + print('⏳ Count already in progress for $facets, waiting...'); + // Wait for the ongoing count to complete + while (_ongoingCounts.contains(cacheKey)) { + await Future.delayed(const Duration(milliseconds: 50)); + if (_globalFacetCache.containsKey(cacheKey)) { + print( + '🎯 DELAYED CACHE HIT for $facets: ${_globalFacetCache[cacheKey]}'); + return _globalFacetCache[cacheKey]!; + } } - } else { - // חיפוש מקורב - נשתמש במילים בודדות - regexTerms = query.trim().split(RegExp(r'\s+')); } - // חישוב maxExpansions בהתבסס על סוג החיפוש - int maxExpansions; - if (fuzzy) { - maxExpansions = 50; // חיפוש מקורב - } else if (regexTerms.length > 1) { - maxExpansions = 100; // חיפוש של כמה מילים - צריך expansions גבוה יותר + // Mark this count as in progress + _ongoingCounts.add(cacheKey); + final index = await engine; + + // בדיקה אם יש מרווחים מותאמים אישית, מילים חילופיות או אפשרויות חיפוש + final hasCustomSpacing = customSpacing != null && customSpacing.isNotEmpty; + final hasAlternativeWords = + alternativeWords != null && alternativeWords.isNotEmpty; + final hasSearchOptions = searchOptions != null && searchOptions.isNotEmpty; + + // המרת החיפוש לפורמט המנוע החדש - בדיוק כמו ב-SearchRepository! + final words = query.trim().split(RegExp(r'\s+')); + final List regexTerms; + final int effectiveSlop; + + if (hasAlternativeWords || hasSearchOptions) { + // יש מילים חילופיות או אפשרויות חיפוש - נבנה queries מתקדמים + regexTerms = + _buildAdvancedQueryForCount(words, alternativeWords, searchOptions); + effectiveSlop = hasCustomSpacing + ? _getMaxCustomSpacingForCount(customSpacing, words.length) + : (fuzzy ? distance : 0); + } else if (fuzzy) { + // חיפוש מקורב - נשתמש במילים בודדות + regexTerms = words; + effectiveSlop = distance; + } else if (words.length == 1) { + // מילה אחת - חיפוש פשוט + regexTerms = [query]; + effectiveSlop = 0; + } else if (hasCustomSpacing) { + // מרווחים מותאמים אישית + regexTerms = words; + effectiveSlop = _getMaxCustomSpacingForCount(customSpacing, words.length); } else { - maxExpansions = 10; // מילה אחת - expansions נמוך + // חיפוש מדוייק של כמה מילים + regexTerms = words; + effectiveSlop = distance; } - // Debug: RegexTerms: $regexTerms, MaxExpansions: $maxExpansions + // חישוב maxExpansions בהתבסס על סוג החיפוש + final int maxExpansions = + _calculateMaxExpansionsForCount(fuzzy, regexTerms.length); try { final count = await index.count( regexTerms: regexTerms, facets: facets, - slop: distance, + slop: effectiveSlop, maxExpansions: maxExpansions); + // Save to global cache + _lastCachedQuery = query; + _globalFacetCache[cacheKey] = count; + _ongoingCounts.remove(cacheKey); // Mark as completed + print('💾 GLOBAL CACHE SAVE for $facets: $count'); + return count; } catch (e) { + // Remove from ongoing counts even on error + _ongoingCounts.remove(cacheKey); // Log error in production rethrow; } @@ -182,6 +246,322 @@ class TantivyDataProvider { order: ResultsOrder.relevance); } + /// מחשב את המרווח המקסימלי מהמרווחים המותאמים אישית + int _getMaxCustomSpacingForCount( + Map customSpacing, int wordCount) { + int maxSpacing = 0; + + for (int i = 0; i < wordCount - 1; i++) { + final spacingKey = '$i-${i + 1}'; + final customSpacingValue = customSpacing[spacingKey]; + + if (customSpacingValue != null && customSpacingValue.isNotEmpty) { + final spacingNum = int.tryParse(customSpacingValue) ?? 0; + maxSpacing = maxSpacing > spacingNum ? maxSpacing : spacingNum; + } + } + + return maxSpacing; + } + + /// בונה query מתקדם עם מילים חילופיות ואפשרויות חיפוש + List _buildAdvancedQueryForCount( + List words, + Map>? alternativeWords, + Map>? searchOptions) { + List regexTerms = []; + + for (int i = 0; i < words.length; i++) { + final word = words[i]; + final wordKey = '${word}_$i'; + + // קבלת אפשרויות החיפוש למילה הזו + final wordOptions = searchOptions?[wordKey] ?? {}; + final hasPrefix = wordOptions['קידומות'] == true; + final hasSuffix = wordOptions['סיומות'] == true; + final hasGrammaticalPrefixes = wordOptions['קידומות דקדוקיות'] == true; + final hasGrammaticalSuffixes = wordOptions['סיומות דקדוקיות'] == true; + final hasFullPartialSpelling = wordOptions['כתיב מלא/חסר'] == true; + + // קבלת מילים חילופיות + final alternatives = alternativeWords?[i]; + + // בניית רשימת כל האפשרויות (מילה מקורית + חלופות) + final allOptions = [word]; + if (alternatives != null && alternatives.isNotEmpty) { + allOptions.addAll(alternatives); + } + + // סינון אפשרויות ריקות + final validOptions = + allOptions.where((w) => w.trim().isNotEmpty).toList(); + + if (validOptions.isNotEmpty) { + // בניית רשימת כל האפשרויות לכל מילה + final allVariations = {}; + + for (final option in validOptions) { + List baseVariations = [option]; + + // אם יש כתיב מלא/חסר, נוצר את כל הווריאציות של כתיב + if (hasFullPartialSpelling) { + try { + // ייבוא דינמי של HebrewMorphology + baseVariations = _generateFullPartialSpellingVariations(option); + } catch (e) { + // אם יש בעיה, נוסיף לפחות את המילה המקורית + baseVariations = [option]; + } + } + + // עבור כל וריאציה של כתיב, מוסיפים את האפשרויות הדקדוקיות + for (final baseVariation in baseVariations) { + if (hasGrammaticalPrefixes && hasGrammaticalSuffixes) { + // שתי האפשרויות יחד - נוסיף את כל הווריאציות הדקדוקיות + allVariations + .addAll(_generateFullMorphologicalVariations(baseVariation)); + } else if (hasGrammaticalPrefixes) { + // רק קידומות דקדוקיות + allVariations.addAll(_generatePrefixVariations(baseVariation)); + } else if (hasGrammaticalSuffixes) { + // רק סיומות דקדוקיות + allVariations.addAll(_generateSuffixVariations(baseVariation)); + } else if (hasPrefix) { + // קידומות רגילות + allVariations.add('.*${RegExp.escape(baseVariation)}'); + } else if (hasSuffix) { + // סיומות רגילות + allVariations.add('${RegExp.escape(baseVariation)}.*'); + } else { + // ללא אפשרויות מיוחדות - מילה מדויקת + allVariations.add(RegExp.escape(baseVariation)); + } + } + } + + // במקום רגקס מורכב, נוסיף כל וריאציה בנפרד + final finalPattern = allVariations.length == 1 + ? allVariations.first + : '(${allVariations.join('|')})'; + + regexTerms.add(finalPattern); + } else { + // fallback למילה המקורית + regexTerms.add(word); + } + } + + return regexTerms; + } + + /// מחשב את maxExpansions בהתבסס על סוג החיפוש + int _calculateMaxExpansionsForCount(bool fuzzy, int termCount) { + if (fuzzy) { + return 50; // חיפוש מקורב + } else if (termCount > 1) { + return 100; // חיפוש של כמה מילים - צריך expansions גבוה יותר + } else { + return 10; // מילה אחת - expansions נמוך + } + } + + // פונקציות עזר ליצירת וריאציות כתיב מלא/חסר ודקדוקיות + List _generateFullPartialSpellingVariations(String word) { + if (word.isEmpty) return [word]; + + final variations = {word}; // המילה המקורית + + // מוצא את כל המיקומים של י, ו, וגרשיים + final chars = word.split(''); + final optionalIndices = []; + + // מוצא אינדקסים של תווים שיכולים להיות אופציונליים + for (int i = 0; i < chars.length; i++) { + if (chars[i] == 'י' || + chars[i] == 'ו' || + chars[i] == "'" || + chars[i] == '"') { + optionalIndices.add(i); + } + } + + // יוצר את כל הצירופים האפשריים (2^n אפשרויות) + final numCombinations = 1 << optionalIndices.length; // 2^n + + for (int combination = 0; combination < numCombinations; combination++) { + final variant = []; + + for (int i = 0; i < chars.length; i++) { + final optionalIndex = optionalIndices.indexOf(i); + + if (optionalIndex != -1) { + // זה תו אופציונלי - בודק אם לכלול אותו בצירוף הזה + final shouldInclude = (combination & (1 << optionalIndex)) != 0; + if (shouldInclude) { + variant.add(chars[i]); + } + } else { + // תו רגיל - תמיד כולל + variant.add(chars[i]); + } + } + + variations.add(variant.join('')); + } + + return variations.toList(); + } + + List _generateFullMorphologicalVariations(String word) { + // פונקציה פשוטה שמחזירה את המילה עם קידומות וסיומות בסיסיות + final variations = {word}; + + // קידומות בסיסיות + final prefixes = ['ב', 'ה', 'ו', 'כ', 'ל', 'מ', 'ש']; + // סיומות בסיסיות + final suffixes = ['ה', 'ים', 'ות', 'י', 'ך', 'נו', 'כם', 'הם']; + + // הוספת קידומות + for (final prefix in prefixes) { + variations.add('$prefix$word'); + } + + // הוספת סיומות + for (final suffix in suffixes) { + variations.add('$word$suffix'); + } + + // הוספת קידומות וסיומות יחד + for (final prefix in prefixes) { + for (final suffix in suffixes) { + variations.add('$prefix$word$suffix'); + } + } + + return variations.toList(); + } + + List _generatePrefixVariations(String word) { + final variations = {word}; + final prefixes = ['ב', 'ה', 'ו', 'כ', 'ל', 'מ', 'ש']; + + for (final prefix in prefixes) { + variations.add('$prefix$word'); + } + + return variations.toList(); + } + + List _generateSuffixVariations(String word) { + final variations = {word}; + final suffixes = ['ה', 'ים', 'ות', 'י', 'ך', 'נו', 'כם', 'הם']; + + for (final suffix in suffixes) { + variations.add('$word$suffix'); + } + + return variations.toList(); + } + + /// ספירה מקבצת של תוצאות עבור מספר facets בבת אחת - לשיפור ביצועים + Future> countTextsForMultipleFacets( + String query, List books, List facets, + {bool fuzzy = false, + int distance = 2, + Map? customSpacing, + Map>? alternativeWords, + Map>? searchOptions}) async { + print( + '🔍 TantivyDataProvider: Starting batch count for ${facets.length} facets'); + final stopwatch = Stopwatch()..start(); + + final index = await engine; + final results = {}; + + // בדיקה אם יש מרווחים מותאמים אישית, מילים חילופיות או אפשרויות חיפוש + final hasCustomSpacing = customSpacing != null && customSpacing.isNotEmpty; + final hasAlternativeWords = + alternativeWords != null && alternativeWords.isNotEmpty; + final hasSearchOptions = searchOptions != null && searchOptions.isNotEmpty; + + // המרת החיפוש לפורמט המנוע החדש - בדיוק כמו ב-countTexts + final words = query.trim().split(RegExp(r'\s+')); + final List regexTerms; + final int effectiveSlop; + + if (hasAlternativeWords || hasSearchOptions) { + regexTerms = + _buildAdvancedQueryForCount(words, alternativeWords, searchOptions); + effectiveSlop = hasCustomSpacing + ? _getMaxCustomSpacingForCount(customSpacing, words.length) + : (fuzzy ? distance : 0); + } else if (fuzzy) { + regexTerms = words; + effectiveSlop = distance; + } else if (words.length == 1) { + regexTerms = [query]; + effectiveSlop = 0; + } else if (hasCustomSpacing) { + regexTerms = words; + effectiveSlop = _getMaxCustomSpacingForCount(customSpacing, words.length); + } else { + regexTerms = words; + effectiveSlop = distance; + } + + final int maxExpansions = + _calculateMaxExpansionsForCount(fuzzy, regexTerms.length); + + // ביצוע ספירה עבור כל facet - בזה אחר זה (לא במקביל כי זה לא עובד) + int processedCount = 0; + int zeroResultsCount = 0; + + for (final facet in facets) { + try { + print( + '🔍 Counting facet: $facet (${processedCount + 1}/${facets.length})'); + final facetStopwatch = Stopwatch()..start(); + final count = await index.count( + regexTerms: regexTerms, + facets: [facet], + slop: effectiveSlop, + maxExpansions: maxExpansions); + facetStopwatch.stop(); + print( + '✅ Facet $facet: $count (${facetStopwatch.elapsedMilliseconds}ms)'); + results[facet] = count; + + processedCount++; + if (count == 0) { + zeroResultsCount++; + } + + // אם יש יותר מדי facets עם 0 תוצאות, נפסיק מוקדם + if (processedCount >= 10 && zeroResultsCount > processedCount * 0.8) { + print('⚠️ Too many zero results, stopping early'); + // נמלא את השאר עם 0 + for (int i = processedCount; i < facets.length; i++) { + results[facets[i]] = 0; + } + break; + } + } catch (e) { + print('❌ Error counting facet $facet: $e'); + results[facet] = 0; + processedCount++; + zeroResultsCount++; + } + } + + stopwatch.stop(); + print( + '✅ TantivyDataProvider: Batch count completed in ${stopwatch.elapsedMilliseconds}ms'); + print( + '📊 Results: ${results.entries.where((e) => e.value > 0).map((e) => '${e.key}: ${e.value}').join(', ')}'); + + return results; + } + /// Clears the index and resets the list of indexed books. Future clear() async { isIndexing.value = false; diff --git a/lib/search/bloc/search_bloc.dart b/lib/search/bloc/search_bloc.dart index f83e34adc..a646cffa2 100644 --- a/lib/search/bloc/search_bloc.dart +++ b/lib/search/bloc/search_bloc.dart @@ -29,6 +29,7 @@ class SearchBloc extends Bloc { on(_onToggleMultiline); on(_onToggleDotAll); on(_onToggleUnicode); + on(_onUpdateFacetCounts); } Future _onUpdateSearchQuery( UpdateSearchQuery event, @@ -43,9 +44,13 @@ class SearchBloc extends Bloc { return; } + // Clear global cache for new search + TantivyDataProvider.clearGlobalCache(); + emit(state.copyWith( searchQuery: event.query, isLoading: true, + facetCounts: {}, // Clear facet counts for new search )); final booksToSearch = state.booksToSearch.map((e) => e.title).toList(); @@ -57,6 +62,9 @@ class SearchBloc extends Bloc { state.currentFacets, fuzzy: state.fuzzy, distance: state.distance, + customSpacing: event.customSpacing, + alternativeWords: event.alternativeWords, + searchOptions: event.searchOptions, ); // If no results with current facets, try root facet @@ -81,7 +89,12 @@ class SearchBloc extends Bloc { results: results, totalResults: totalResults, isLoading: false, + facetCounts: {}, // Start with empty facet counts, will be filled by individual requests )); + + // Prefetch disabled - too slow and causes duplicates + // _prefetchCommonFacetCounts(event.query, event.customSpacing, + // event.alternativeWords, event.searchOptions); } catch (e) { emit(state.copyWith( results: [], @@ -231,19 +244,84 @@ class SearchBloc extends Bloc { emit(const SearchState()); } - Future countForFacet(String facet) async { + Future countForFacet( + String facet, { + Map? customSpacing, + Map>? alternativeWords, + Map>? searchOptions, + }) async { if (state.searchQuery.isEmpty) { return 0; } + + // קודם נבדוק אם יש לנו את הספירה ב-state + if (state.facetCounts.containsKey(facet)) { + return state.facetCounts[facet]!; + } + + // אם אין, נבצע ספירה ישירה (fallback) return TantivyDataProvider.instance.countTexts( state.searchQuery.replaceAll('"', '\\"'), state.booksToSearch.map((e) => e.title).toList(), [facet], fuzzy: state.fuzzy, distance: state.distance, + customSpacing: customSpacing, + alternativeWords: alternativeWords, + searchOptions: searchOptions, ); } + /// ספירה מקבצת של תוצאות עבור מספר facets בבת אחת - לשיפור ביצועים + Future> countForMultipleFacets( + List facets, { + Map? customSpacing, + Map>? alternativeWords, + Map>? searchOptions, + }) async { + if (state.searchQuery.isEmpty) { + return {for (final facet in facets) facet: 0}; + } + + // קודם נבדוק כמה facets יש לנו כבר ב-state + final results = {}; + final missingFacets = []; + + for (final facet in facets) { + if (state.facetCounts.containsKey(facet)) { + results[facet] = state.facetCounts[facet]!; + } else { + missingFacets.add(facet); + } + } + + // אם יש facets חסרים, נבצע ספירה רק עבורם + if (missingFacets.isNotEmpty) { + final missingResults = + await TantivyDataProvider.instance.countTextsForMultipleFacets( + state.searchQuery.replaceAll('"', '\\"'), + state.booksToSearch.map((e) => e.title).toList(), + missingFacets, + fuzzy: state.fuzzy, + distance: state.distance, + customSpacing: customSpacing, + alternativeWords: alternativeWords, + searchOptions: searchOptions, + ); + results.addAll(missingResults); + } + + return results; + } + + /// מחזיר ספירה סינכרונית מה-state (אם קיימת) + int getFacetCountFromState(String facet) { + final result = state.facetCounts[facet] ?? 0; + print( + '🔍 getFacetCountFromState($facet) = $result, cache has ${state.facetCounts.length} entries'); + return result; + } + // Handlers חדשים לרגקס void _onToggleRegex( ToggleRegex event, @@ -291,4 +369,19 @@ class SearchBloc extends Bloc { emit(state.copyWith(configuration: newConfig)); add(UpdateSearchQuery(state.searchQuery)); } + + void _onUpdateFacetCounts( + UpdateFacetCounts event, + Emitter emit, + ) { + print( + '📝 Updating facet counts: ${event.facetCounts.entries.where((e) => e.value > 0).map((e) => '${e.key}: ${e.value}').join(', ')}'); + final newFacetCounts = {...state.facetCounts, ...event.facetCounts}; + emit(state.copyWith( + facetCounts: newFacetCounts, + )); + print('📊 Total facets in state: ${newFacetCounts.length}'); + print( + '📋 All cached facets: ${newFacetCounts.keys.take(10).join(', ')}...'); + } } diff --git a/lib/search/bloc/search_event.dart b/lib/search/bloc/search_event.dart index 463b32182..b96e09569 100644 --- a/lib/search/bloc/search_event.dart +++ b/lib/search/bloc/search_event.dart @@ -72,3 +72,9 @@ class ToggleMultiline extends SearchEvent {} class ToggleDotAll extends SearchEvent {} class ToggleUnicode extends SearchEvent {} + +// Event פנימי לעדכון facet counts +class UpdateFacetCounts extends SearchEvent { + final Map facetCounts; + UpdateFacetCounts(this.facetCounts); +} diff --git a/lib/search/bloc/search_state.dart b/lib/search/bloc/search_state.dart index a4c4c587e..a39af432e 100644 --- a/lib/search/bloc/search_state.dart +++ b/lib/search/bloc/search_state.dart @@ -11,6 +11,9 @@ class SearchState { final String searchQuery; final int totalResults; + // מידע על ספירות לכל facet - מתעדכן עם כל חיפוש + final Map facetCounts; + // הגדרות החיפוש מרוכזות במחלקה נפרדת final SearchConfiguration configuration; @@ -22,6 +25,7 @@ class SearchState { this.totalResults = 0, this.filterQuery, this.filteredBooks, + this.facetCounts = const {}, this.configuration = const SearchConfiguration(), }); @@ -33,6 +37,7 @@ class SearchState { int? totalResults, String? filterQuery, List? filteredBooks, + Map? facetCounts, SearchConfiguration? configuration, }) { return SearchState( @@ -43,6 +48,7 @@ class SearchState { totalResults: totalResults ?? this.totalResults, filterQuery: filterQuery, filteredBooks: filteredBooks, + facetCounts: facetCounts ?? this.facetCounts, configuration: configuration ?? this.configuration, ); } diff --git a/lib/search/view/full_text_facet_filtering.dart b/lib/search/view/full_text_facet_filtering.dart index 086d16f82..40eb9987c 100644 --- a/lib/search/view/full_text_facet_filtering.dart +++ b/lib/search/view/full_text_facet_filtering.dart @@ -117,7 +117,7 @@ class _SearchFacetFilteringState extends State } Widget _buildBookTile(Book book, int count, int level) { - if (count <= 0) { + if (count == 0) { return const SizedBox.shrink(); } @@ -137,7 +137,20 @@ class _SearchFacetFilteringState extends State .surfaceTint .withValues(alpha: _kBackgroundOpacity) : null, - title: Text("${book.title} ($count)"), + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: + Text("${book.title} ${count == -1 ? '' : '($count)'}")), + if (count == -1) + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator(strokeWidth: 1.5), + ), + ], + ), onTap: () => HardwareKeyboard.instance.isControlPressed ? _handleFacetToggle(context, facet) : _setFacet(context, facet), @@ -151,29 +164,35 @@ class _SearchFacetFilteringState extends State Widget _buildBooksList(List books) { return BlocBuilder( builder: (context, state) { - return ListView.builder( - shrinkWrap: true, - itemCount: books.length, - itemBuilder: (context, index) { - final book = books[index]; - final facet = "/${book.topics.replaceAll(', ', '/')}/${book.title}"; - return Builder( - builder: (context) { - final countFuture = - context.read().countForFacet(facet); - return FutureBuilder( - key: ValueKey( - '${state.searchQuery}_$facet'), // מפתח שמשתנה עם החיפוש - future: countFuture, - builder: (context, snapshot) { - if (snapshot.hasData) { - return _buildBookTile(book, snapshot.data!, 0); - } - return const SizedBox.shrink(); - }, - ); - }, - ); + // יצירת רשימת כל ה-facets בבת אחת + final facets = books + .map( + (book) => "/${book.topics.replaceAll(', ', '/')}/${book.title}") + .toList(); + + // ספירה מקבצת של כל ה-facets + final countsFuture = widget.tab.countForMultipleFacets(facets); + + return FutureBuilder>( + key: ValueKey( + '${state.searchQuery}_books_batch'), // מפתח שמשתנה עם החיפוש + future: countsFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + final counts = snapshot.data!; + return ListView.builder( + shrinkWrap: true, + itemCount: books.length, + itemBuilder: (context, index) { + final book = books[index]; + final facet = + "/${book.topics.replaceAll(', ', '/')}/${book.title}"; + final count = counts[facet] ?? 0; + return _buildBookTile(book, count, 0); + }, + ); + } + return const Center(child: CircularProgressIndicator()); }, ); }, @@ -181,7 +200,7 @@ class _SearchFacetFilteringState extends State } Widget _buildCategoryTile(Category category, int count, int level) { - if (count <= 0) { + if (count == 0) { return const SizedBox.shrink(); } @@ -214,7 +233,20 @@ class _SearchFacetFilteringState extends State : _setFacet(context, category.path), onDoubleTap: () => _handleFacetToggle(context, category.path), onLongPress: () => _handleFacetToggle(context, category.path), - child: Text("${category.title} ($count)")), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Text( + "${category.title} ${count == -1 ? '' : '($count)'}")), + if (count == -1) + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator(strokeWidth: 1.5), + ), + ], + )), initiallyExpanded: level == 0, tilePadding: EdgeInsets.only( right: _kTreePadding + (level * _kTreeLevelIndent), @@ -232,7 +264,7 @@ class _SearchFacetFilteringState extends State return BlocBuilder( builder: (context, state) { final countFuture = - context.read().countForFacet(subCategory.path); + widget.tab.countForFacetCached(subCategory.path); return FutureBuilder( key: ValueKey( '${state.searchQuery}_${subCategory.path}'), // מפתח שמשתנה עם החיפוש @@ -242,7 +274,8 @@ class _SearchFacetFilteringState extends State return _buildCategoryTile( subCategory, snapshot.data!, level + 1); } - return const SizedBox.shrink(); + // במקום shrink, נציג placeholder עם ספינר קטן + return _buildCategoryTile(subCategory, -1, level + 1); }, ); }, @@ -252,7 +285,7 @@ class _SearchFacetFilteringState extends State return BlocBuilder( builder: (context, state) { final facet = "/${book.topics.replaceAll(', ', '/')}/${book.title}"; - final countFuture = context.read().countForFacet(facet); + final countFuture = widget.tab.countForFacetCached(facet); return FutureBuilder( key: ValueKey( '${state.searchQuery}_$facet'), // מפתח שמשתנה עם החיפוש @@ -261,7 +294,8 @@ class _SearchFacetFilteringState extends State if (snapshot.hasData) { return _buildBookTile(book, snapshot.data!, level + 1); } - return const SizedBox.shrink(); + // במקום shrink, נציג placeholder עם ספינר קטן + return _buildBookTile(book, -1, level + 1); }, ); }, @@ -294,7 +328,7 @@ class _SearchFacetFilteringState extends State builder: (context, searchState) { final rootCategory = libraryState.library!; final countFuture = - context.read().countForFacet(rootCategory.path); + widget.tab.countForFacetCached(rootCategory.path); return FutureBuilder( key: ValueKey( '${searchState.searchQuery}_${rootCategory.path}'), // מפתח שמשתנה עם החיפוש diff --git a/lib/search/view/tantivy_full_text_search.dart b/lib/search/view/tantivy_full_text_search.dart index da007b19d..019f4d1d4 100644 --- a/lib/search/view/tantivy_full_text_search.dart +++ b/lib/search/view/tantivy_full_text_search.dart @@ -256,7 +256,7 @@ class _TantivyFullTextSearchState extends State child: Center( child: Text( state.results.isEmpty && state.searchQuery.isEmpty - ? 'עדיין לא בוצע חיפוש' + ? 'עוד לא בוצע חיפוש' : '${state.results.length} מתוך ${state.totalResults}', style: const TextStyle(fontSize: 14), ), diff --git a/lib/tabs/models/searching_tab.dart b/lib/tabs/models/searching_tab.dart index 8ece12ac1..2aa90c622 100644 --- a/lib/tabs/models/searching_tab.dart +++ b/lib/tabs/models/searching_tab.dart @@ -42,7 +42,52 @@ class SearchingTab extends OpenedTab { } Future countForFacet(String facet) { - return searchBloc.countForFacet(facet); + return searchBloc.countForFacet( + facet, + customSpacing: spacingValues, + alternativeWords: alternativeWords, + searchOptions: searchOptions, + ); + } + + /// ספירה מקבצת של תוצאות עבור מספר facets בבת אחת - לשיפור ביצועים + Future> countForMultipleFacets(List facets) { + return searchBloc.countForMultipleFacets( + facets, + customSpacing: spacingValues, + alternativeWords: alternativeWords, + searchOptions: searchOptions, + ); + } + + /// ספירה חכמה - מחזירה תוצאות מהירות מה-state או מבצעת ספירה + Future countForFacetCached(String facet) async { + // קודם נבדוק אם יש ספירה ב-state של ה-bloc (כולל 0) + final stateCount = searchBloc.getFacetCountFromState(facet); + if (searchBloc.state.facetCounts.containsKey(facet)) { + print('💾 Cache hit for $facet: $stateCount'); + return stateCount; + } + + print('🔄 Cache miss for $facet, performing direct count...'); + print( + '📍 Stack trace: ${StackTrace.current.toString().split('\n').take(5).join('\n')}'); + final stopwatch = Stopwatch()..start(); + // אם אין ב-state, נבצע ספירה ישירה + final result = await countForFacet(facet); + stopwatch.stop(); + print( + '⏱️ Direct count for $facet took ${stopwatch.elapsedMilliseconds}ms: $result'); + + // Update SearchBloc state cache + searchBloc.add(UpdateFacetCounts({facet: result})); + + return result; + } + + /// מחזיר ספירה סינכרונית מה-state (אם קיימת) + int getFacetCountFromState(String facet) { + return searchBloc.getFacetCountFromState(facet); } @override From f283434fdefba85b062b6c18e57dfdbf4a380820 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 1 Aug 2025 16:56:44 +0300 Subject: [PATCH 058/197] =?UTF-8?q?=D7=A9=D7=99=D7=A4=D7=95=D7=A8=20=D7=9E?= =?UTF-8?q?=D7=9E=D7=A9=D7=A7=20=D7=90=D7=96=D7=95=D7=A8=20=D7=94=D7=AA?= =?UTF-8?q?=D7=95=D7=A6=D7=90=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../view/full_text_facet_filtering.dart | 144 ++++++++++++------ lib/search/view/tantivy_full_text_search.dart | 2 +- 2 files changed, 96 insertions(+), 50 deletions(-) diff --git a/lib/search/view/full_text_facet_filtering.dart b/lib/search/view/full_text_facet_filtering.dart index 40eb9987c..5b76ac70e 100644 --- a/lib/search/view/full_text_facet_filtering.dart +++ b/lib/search/view/full_text_facet_filtering.dart @@ -11,8 +11,8 @@ import 'package:otzaria/library/models/library.dart'; import 'package:otzaria/tabs/models/searching_tab.dart'; // Constants -const double _kTreePadding = 6.0; -const double _kTreeLevelIndent = 10.0; +const double _kTreePadding = 15.0; +const double _kTreeLevelIndent = 3.0; const double _kMinQueryLength = 2; const double _kBackgroundOpacity = 0.1; @@ -48,6 +48,7 @@ class _SearchFacetFilteringState extends State @override bool get wantKeepAlive => true; final TextEditingController _filterQuery = TextEditingController(); + final Map _expansionState = {}; @override void dispose() { @@ -200,59 +201,104 @@ class _SearchFacetFilteringState extends State } Widget _buildCategoryTile(Category category, int count, int level) { - if (count == 0) { - return const SizedBox.shrink(); - } + if (count == 0) return const SizedBox.shrink(); return BlocBuilder( builder: (context, state) { final isSelected = state.currentFacets.contains(category.path); - return Theme( - data: Theme.of(context).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - key: PageStorageKey(category.path), - backgroundColor: isSelected - ? Theme.of(context) - .colorScheme - .surfaceTint - .withValues(alpha: _kBackgroundOpacity) - : null, - collapsedBackgroundColor: isSelected - ? Theme.of(context) - .colorScheme - .surfaceTint - .withValues(alpha: _kBackgroundOpacity) - : null, - leading: const Icon(Icons.chevron_right_rounded), - trailing: const SizedBox.shrink(), - iconColor: Theme.of(context).colorScheme.primary, - collapsedIconColor: Theme.of(context).colorScheme.primary, - title: GestureDetector( - onTap: () => HardwareKeyboard.instance.isControlPressed - ? _handleFacetToggle(context, category.path) - : _setFacet(context, category.path), - onDoubleTap: () => _handleFacetToggle(context, category.path), - onLongPress: () => _handleFacetToggle(context, category.path), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: Text( - "${category.title} ${count == -1 ? '' : '($count)'}")), - if (count == -1) - const SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator(strokeWidth: 1.5), + final primaryColor = Theme.of(context).colorScheme.primary; + final isExpanded = _expansionState[category.path] ?? level == 0; + + void toggle() { + setState(() { + _expansionState[category.path] = !isExpanded; + }); + } + + return Column( + children: [ + // ─────────── שורת-הכותרת ─────────── + Container( + color: isSelected + ? Theme.of(context) + .colorScheme + .surfaceTint + .withOpacity(_kBackgroundOpacity) + : null, + child: Row( + textDirection: + TextDirection.rtl, // RTL: הטקסט מימין, המספר משמאל + children: [ + // אזור-החץ – רוחב ~1 ס"מ + SizedBox( + width: 40, + height: 48, + child: InkWell( + onTap: toggle, + child: Icon( + isExpanded + ? Icons.expand_more // חץ מטה כשהשורה פתוחה + : Icons.chevron_right_rounded, + color: primaryColor, ), - ], - )), - initiallyExpanded: level == 0, - tilePadding: EdgeInsets.only( - right: _kTreePadding + (level * _kTreeLevelIndent), + ), + ), + + // פס-הפרדה אפור דק + Container(width: 1, height: 32, color: Colors.grey.shade300), + + // השורה עצמה + Expanded( + child: InkWell( + onTap: () => HardwareKeyboard.instance.isControlPressed + ? _handleFacetToggle(context, category.path) + : _setFacet(context, category.path), + onDoubleTap: () => + _handleFacetToggle(context, category.path), + onLongPress: () => + _handleFacetToggle(context, category.path), + child: Padding( + padding: EdgeInsets.only( + right: _kTreePadding + (level * _kTreeLevelIndent), + top: 8, + bottom: 8, + ), + child: Row( + textDirection: TextDirection.rtl, + children: [ + // כותרת הקטגוריה + Expanded(child: Text(category.title)), + // המספר – בקצה השמאלי + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text(count == -1 ? '' : '($count)'), + ), + if (count == -1) + const SizedBox( + width: 12, + height: 12, + child: + CircularProgressIndicator(strokeWidth: 1.5), + ), + ], + ), + ), + ), + ), + ], + ), ), - children: _buildCategoryChildren(category, level), - ), + + // ─────────── ילדים ─────────── + if (isExpanded) + Padding( + padding: EdgeInsets.only( + right: _kTreePadding + + (level * _kTreeLevelIndent)), // הזחה פנימה + child: + Column(children: _buildCategoryChildren(category, level)), + ), + ], ); }, ); diff --git a/lib/search/view/tantivy_full_text_search.dart b/lib/search/view/tantivy_full_text_search.dart index 019f4d1d4..23a2217bc 100644 --- a/lib/search/view/tantivy_full_text_search.dart +++ b/lib/search/view/tantivy_full_text_search.dart @@ -169,7 +169,7 @@ class _TantivyFullTextSearchState extends State child: Row( children: [ SizedBox( - width: 350, + width: 235, child: SearchFacetFiltering(tab: widget.tab), ), Container( From aa9b90c64885d751fda55859d1aaf2de6181f86d Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 1 Aug 2025 18:26:29 +0300 Subject: [PATCH 059/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=91?= =?UTF-8?q?=D7=A2=D7=99=D7=95=D7=AA=20=D7=95=D7=A9=D7=99=D7=A4=D7=95=D7=A8?= =?UTF-8?q?=20=D7=91=D7=97=D7=99=D7=A4=D7=95=D7=A9=D7=99=D7=9D=20=D7=9E?= =?UTF-8?q?=D7=AA=D7=A7=D7=93=D7=9E=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data_providers/tantivy_data_provider.dart | 158 +++++++++++++++--- lib/search/search_repository.dart | 135 ++++++++++++--- 2 files changed, 251 insertions(+), 42 deletions(-) diff --git a/lib/data/data_providers/tantivy_data_provider.dart b/lib/data/data_providers/tantivy_data_provider.dart index 30c83bdda..6cffea020 100644 --- a/lib/data/data_providers/tantivy_data_provider.dart +++ b/lib/data/data_providers/tantivy_data_provider.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:search_engine/search_engine.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; @@ -188,8 +189,9 @@ class TantivyDataProvider { } // חישוב maxExpansions בהתבסס על סוג החיפוש - final int maxExpansions = - _calculateMaxExpansionsForCount(fuzzy, regexTerms.length); + final int maxExpansions = _calculateMaxExpansionsForCount( + fuzzy, regexTerms.length, + searchOptions: searchOptions, words: words); try { final count = await index.count( @@ -306,8 +308,16 @@ class TantivyDataProvider { // אם יש כתיב מלא/חסר, נוצר את כל הווריאציות של כתיב if (hasFullPartialSpelling) { try { - // ייבוא דינמי של HebrewMorphology - baseVariations = _generateFullPartialSpellingVariations(option); + // הגבלה למילים קצרות - כתיב מלא/חסר יכול ליצור הרבה וריאציות + if (option.length <= 3) { + // למילים קצרות, נגביל את מספר הוריאציות + final allSpellingVariations = + _generateFullPartialSpellingVariations(option); + // נקח רק את ה-5 הראשונות כדי למנוע יותר מדי expansions + baseVariations = allSpellingVariations.take(5).toList(); + } else { + baseVariations = _generateFullPartialSpellingVariations(option); + } } catch (e) { // אם יש בעיה, נוסיף לפחות את המילה המקורית baseVariations = [option]; @@ -317,21 +327,61 @@ class TantivyDataProvider { // עבור כל וריאציה של כתיב, מוסיפים את האפשרויות הדקדוקיות for (final baseVariation in baseVariations) { if (hasGrammaticalPrefixes && hasGrammaticalSuffixes) { - // שתי האפשרויות יחד - נוסיף את כל הווריאציות הדקדוקיות - allVariations - .addAll(_generateFullMorphologicalVariations(baseVariation)); + // שתי האפשרויות יחד - הגבלה למילים קצרות + if (baseVariation.length <= 2) { + // למילים קצרות, נשתמש ברגקס קומפקטי במקום רשימת וריאציות + allVariations + .add(_createFullMorphologicalRegexPattern(baseVariation)); + } else { + allVariations.addAll( + _generateFullMorphologicalVariations(baseVariation)); + } } else if (hasGrammaticalPrefixes) { - // רק קידומות דקדוקיות - allVariations.addAll(_generatePrefixVariations(baseVariation)); + // רק קידומות דקדוקיות - הגבלה למילים קצרות + if (baseVariation.length <= 2) { + // למילים קצרות, נשתמש ברגקס קומפקטי + allVariations.add(_createPrefixRegexPattern(baseVariation)); + } else { + allVariations.addAll(_generatePrefixVariations(baseVariation)); + } } else if (hasGrammaticalSuffixes) { - // רק סיומות דקדוקיות - allVariations.addAll(_generateSuffixVariations(baseVariation)); + // רק סיומות דקדוקיות - הגבלה למילים קצרות + if (baseVariation.length <= 2) { + // למילים קצרות, נשתמש ברגקס קומפקטי + allVariations.add(_createSuffixRegexPattern(baseVariation)); + } else { + allVariations.addAll(_generateSuffixVariations(baseVariation)); + } } else if (hasPrefix) { - // קידומות רגילות - allVariations.add('.*${RegExp.escape(baseVariation)}'); + // קידומות רגילות - הגבלה חכמה לפי אורך המילה + if (baseVariation.length <= 1) { + // מילה של תו אחד - הגבלה קיצונית (מקסימום 5 תווים קידומת) + allVariations.add('.{1,5}${RegExp.escape(baseVariation)}'); + } else if (baseVariation.length <= 2) { + // מילה של 2 תווים - הגבלה בינונית (מקסימום 4 תווים קידומת) + allVariations.add('.{1,4}${RegExp.escape(baseVariation)}'); + } else if (baseVariation.length <= 3) { + // מילה של 3 תווים - הגבלה קלה (מקסימום 3 תווים קידומת) + allVariations.add('.{1,3}${RegExp.escape(baseVariation)}'); + } else { + // מילה ארוכה - ללא הגבלה + allVariations.add('.*${RegExp.escape(baseVariation)}'); + } } else if (hasSuffix) { - // סיומות רגילות - allVariations.add('${RegExp.escape(baseVariation)}.*'); + // סיומות רגילות - הגבלה חכמה לפי אורך המילה + if (baseVariation.length <= 1) { + // מילה של תו אחד - הגבלה קיצונית (מקסימום 7 תווים סיומת) + allVariations.add('${RegExp.escape(baseVariation)}.{1,7}'); + } else if (baseVariation.length <= 2) { + // מילה של 2 תווים - הגבלה בינונית (מקסימום 6 תווים סיומת) + allVariations.add('${RegExp.escape(baseVariation)}.{1,6}'); + } else if (baseVariation.length <= 3) { + // מילה של 3 תווים - הגבלה קלה (מקסימום 5 תווים סיומת) + allVariations.add('${RegExp.escape(baseVariation)}.{1,5}'); + } else { + // מילה ארוכה - ללא הגבלה + allVariations.add('${RegExp.escape(baseVariation)}.*'); + } } else { // ללא אפשרויות מיוחדות - מילה מדויקת allVariations.add(RegExp.escape(baseVariation)); @@ -339,10 +389,15 @@ class TantivyDataProvider { } } + // הגבלה על מספר הוריאציות הכולל למילה אחת + final limitedVariations = allVariations.length > 20 + ? allVariations.take(20).toList() + : allVariations.toList(); + // במקום רגקס מורכב, נוסיף כל וריאציה בנפרד - final finalPattern = allVariations.length == 1 - ? allVariations.first - : '(${allVariations.join('|')})'; + final finalPattern = limitedVariations.length == 1 + ? limitedVariations.first + : '(${limitedVariations.join('|')})'; regexTerms.add(finalPattern); } else { @@ -355,9 +410,41 @@ class TantivyDataProvider { } /// מחשב את maxExpansions בהתבסס על סוג החיפוש - int _calculateMaxExpansionsForCount(bool fuzzy, int termCount) { + int _calculateMaxExpansionsForCount(bool fuzzy, int termCount, + {Map>? searchOptions, List? words}) { + // בדיקה אם יש חיפוש עם סיומות או קידומות ואיזה מילים + bool hasSuffixOrPrefix = false; + int shortestWordLength = 10; // ערך התחלתי גבוה + + if (searchOptions != null && words != null) { + for (int i = 0; i < words.length; i++) { + final word = words[i]; + final wordKey = '${word}_$i'; + final wordOptions = searchOptions[wordKey] ?? {}; + + if (wordOptions['סיומות'] == true || + wordOptions['קידומות'] == true || + wordOptions['קידומות דקדוקיות'] == true || + wordOptions['סיומות דקדוקיות'] == true) { + hasSuffixOrPrefix = true; + shortestWordLength = math.min(shortestWordLength, word.length); + } + } + } + if (fuzzy) { return 50; // חיפוש מקורב + } else if (hasSuffixOrPrefix) { + // התאמת המגבלה לפי אורך המילה הקצרה ביותר עם אפשרויות מתקדמות + if (shortestWordLength <= 1) { + return 2000; // מילה של תו אחד - הגבלה קיצונית + } else if (shortestWordLength <= 2) { + return 3000; // מילה של 2 תווים - הגבלה בינונית + } else if (shortestWordLength <= 3) { + return 4000; // מילה של 3 תווים - הגבלה קלה + } else { + return 5000; // מילה ארוכה - הגבלה מלאה + } } else if (termCount > 1) { return 100; // חיפוש של כמה מילים - צריך expansions גבוה יותר } else { @@ -509,8 +596,9 @@ class TantivyDataProvider { effectiveSlop = distance; } - final int maxExpansions = - _calculateMaxExpansionsForCount(fuzzy, regexTerms.length); + final int maxExpansions = _calculateMaxExpansionsForCount( + fuzzy, regexTerms.length, + searchOptions: searchOptions, words: words); // ביצוע ספירה עבור כל facet - בזה אחר זה (לא במקביל כי זה לא עובד) int processedCount = 0; @@ -572,4 +660,32 @@ class TantivyDataProvider { booksDone.clear(); saveBooksDoneToDisk(); } + + // פונקציות עזר ליצירת regex קומפקטי לחיפושים דקדוקיים + + /// יוצר דפוס רגקס קומפקטי לחיפוש מילה עם קידומות דקדוקיות + String _createPrefixRegexPattern(String word) { + if (word.isEmpty) return word; + // שימוש בתבנית קבועה ויעילה - מוגבלת לקידומות נפוצות + return r'(ו|מ|כ|ב|ש|ל|ה|ד)?(כ|ב|ש|ל|ה|ד)?(ה)?' + RegExp.escape(word); + } + + /// יוצר דפוס רגקס קומפקטי לחיפוש מילה עם סיומות דקדוקיות + String _createSuffixRegexPattern(String word) { + if (word.isEmpty) return word; + // שימוש בתבנית קבועה ויעילה - מוגבלת לסיומות נפוצות + const suffixPattern = + r'(ות|ים|יה|יו|יך|ינו|יכם|יכן|יהם|יהן|י|ך|ו|ה|נו|כם|כן|ם|ן)?'; + return RegExp.escape(word) + suffixPattern; + } + + /// יוצר דפוס רגקס קומפקטי לחיפוש מילה עם קידומות וסיומות דקדוקיות יחד + String _createFullMorphologicalRegexPattern(String word) { + if (word.isEmpty) return word; + // שילוב של קידומות וסיומות - מוגבל לנפוצות ביותר + const prefixPattern = r'(ו|מ|כ|ב|ש|ל|ה|ד)?(כ|ב|ש|ל|ה|ד)?(ה)?'; + const suffixPattern = + r'(ות|ים|יה|יו|יך|ינו|יכם|יכן|יהם|יהן|י|ך|ו|ה|נו|כם|כן|ם|ן)?'; + return prefixPattern + RegExp.escape(word) + suffixPattern; + } } diff --git a/lib/search/search_repository.dart b/lib/search/search_repository.dart index e7605d3b9..3e9a65b92 100644 --- a/lib/search/search_repository.dart +++ b/lib/search/search_repository.dart @@ -1,3 +1,4 @@ +import 'dart:math' as math; import 'package:otzaria/data/data_providers/tantivy_data_provider.dart'; import 'package:otzaria/search/utils/hebrew_morphology.dart'; import 'package:search_engine/search_engine.dart'; @@ -68,7 +69,8 @@ class SearchRepository { } // חישוב maxExpansions בהתבסס על סוג החיפוש - final int maxExpansions = _calculateMaxExpansions(fuzzy, regexTerms.length); + final int maxExpansions = _calculateMaxExpansions(fuzzy, regexTerms.length, + searchOptions: searchOptions, words: words); return await index.search( regexTerms: regexTerms, @@ -137,31 +139,85 @@ class SearchRepository { // אם יש כתיב מלא/חסר, נוצר את כל הווריאציות של כתיב if (hasFullPartialSpelling) { - baseVariations = - HebrewMorphology.generateFullPartialSpellingVariations(option); + // הגבלה למילים קצרות - כתיב מלא/חסר יכול ליצור הרבה וריאציות + if (option.length <= 3) { + // למילים קצרות, נגביל את מספר הוריאציות + final allSpellingVariations = + HebrewMorphology.generateFullPartialSpellingVariations( + option); + // נקח רק את ה-5 הראשונות כדי למנוע יותר מדי expansions + baseVariations = allSpellingVariations.take(5).toList(); + } else { + baseVariations = + HebrewMorphology.generateFullPartialSpellingVariations( + option); + } } // עבור כל וריאציה של כתיב, מוסיפים את האפשרויות הדקדוקיות for (final baseVariation in baseVariations) { if (hasGrammaticalPrefixes && hasGrammaticalSuffixes) { - // שתי האפשרויות יחד - allVariations.addAll( - HebrewMorphology.generateFullMorphologicalVariations( - baseVariation)); + // שתי האפשרויות יחד - הגבלה למילים קצרות + if (baseVariation.length <= 2) { + // למילים קצרות, נשתמש ברגקס קומפקטי במקום רשימת וריאציות + allVariations.add( + HebrewMorphology.createFullMorphologicalRegexPattern( + baseVariation)); + } else { + allVariations.addAll( + HebrewMorphology.generateFullMorphologicalVariations( + baseVariation)); + } } else if (hasGrammaticalPrefixes) { - // רק קידומות דקדוקיות - allVariations.addAll( - HebrewMorphology.generatePrefixVariations(baseVariation)); + // רק קידומות דקדוקיות - הגבלה למילים קצרות + if (baseVariation.length <= 2) { + // למילים קצרות, נשתמש ברגקס קומפקטי + allVariations.add( + HebrewMorphology.createPrefixRegexPattern(baseVariation)); + } else { + allVariations.addAll( + HebrewMorphology.generatePrefixVariations(baseVariation)); + } } else if (hasGrammaticalSuffixes) { - // רק סיומות דקדוקיות - allVariations.addAll( - HebrewMorphology.generateSuffixVariations(baseVariation)); + // רק סיומות דקדוקיות - הגבלה למילים קצרות + if (baseVariation.length <= 2) { + // למילים קצרות, נשתמש ברגקס קומפקטי + allVariations.add( + HebrewMorphology.createSuffixRegexPattern(baseVariation)); + } else { + allVariations.addAll( + HebrewMorphology.generateSuffixVariations(baseVariation)); + } } else if (hasPrefix) { - // קידומות רגילות - allVariations.add('.*' + RegExp.escape(baseVariation)); + // קידומות רגילות - הגבלה חכמה לפי אורך המילה + if (baseVariation.length <= 1) { + // מילה של תו אחד - הגבלה קיצונית (מקסימום 5 תווים קידומת) + allVariations.add('.{1,5}' + RegExp.escape(baseVariation)); + } else if (baseVariation.length <= 2) { + // מילה של 2 תווים - הגבלה בינונית (מקסימום 4 תווים קידומת) + allVariations.add('.{1,4}' + RegExp.escape(baseVariation)); + } else if (baseVariation.length <= 3) { + // מילה של 3 תווים - הגבלה קלה (מקסימום 3 תווים קידומת) + allVariations.add('.{1,3}' + RegExp.escape(baseVariation)); + } else { + // מילה ארוכה - ללא הגבלה + allVariations.add('.*' + RegExp.escape(baseVariation)); + } } else if (hasSuffix) { - // סיומות רגילות - allVariations.add(RegExp.escape(baseVariation) + '.*'); + // סיומות רגילות - הגבלה חכמה לפי אורך המילה + if (baseVariation.length <= 1) { + // מילה של תו אחד - הגבלה קיצונית (מקסימום 7 תווים סיומת) + allVariations.add(RegExp.escape(baseVariation) + '.{1,7}'); + } else if (baseVariation.length <= 2) { + // מילה של 2 תווים - הגבלה בינונית (מקסימום 6 תווים סיומת) + allVariations.add(RegExp.escape(baseVariation) + '.{1,6}'); + } else if (baseVariation.length <= 3) { + // מילה של 3 תווים - הגבלה קלה (מקסימום 5 תווים סיומת) + allVariations.add(RegExp.escape(baseVariation) + '.{1,5}'); + } else { + // מילה ארוכה - ללא הגבלה + allVariations.add(RegExp.escape(baseVariation) + '.*'); + } } else { // ללא אפשרויות מיוחדות - מילה מדויקת allVariations.add(RegExp.escape(baseVariation)); @@ -169,10 +225,15 @@ class SearchRepository { } } + // הגבלה על מספר הוריאציות הכולל למילה אחת + final limitedVariations = allVariations.length > 20 + ? allVariations.take(20).toList() + : allVariations.toList(); + // במקום רגקס מורכב, נוסיף כל וריאציה בנפרד - final finalPattern = allVariations.length == 1 - ? allVariations.first - : '(${allVariations.join('|')})'; + final finalPattern = limitedVariations.length == 1 + ? limitedVariations.first + : '(${limitedVariations.join('|')})'; regexTerms.add(finalPattern); print( @@ -187,9 +248,41 @@ class SearchRepository { } /// מחשב את maxExpansions בהתבסס על סוג החיפוש - int _calculateMaxExpansions(bool fuzzy, int termCount) { + int _calculateMaxExpansions(bool fuzzy, int termCount, + {Map>? searchOptions, List? words}) { + // בדיקה אם יש חיפוש עם סיומות או קידומות ואיזה מילים + bool hasSuffixOrPrefix = false; + int shortestWordLength = 10; // ערך התחלתי גבוה + + if (searchOptions != null && words != null) { + for (int i = 0; i < words.length; i++) { + final word = words[i]; + final wordKey = '${word}_$i'; + final wordOptions = searchOptions[wordKey] ?? {}; + + if (wordOptions['סיומות'] == true || + wordOptions['קידומות'] == true || + wordOptions['קידומות דקדוקיות'] == true || + wordOptions['סיומות דקדוקיות'] == true) { + hasSuffixOrPrefix = true; + shortestWordLength = math.min(shortestWordLength, word.length); + } + } + } + if (fuzzy) { return 50; // חיפוש מקורב + } else if (hasSuffixOrPrefix) { + // התאמת המגבלה לפי אורך המילה הקצרה ביותר עם אפשרויות מתקדמות + if (shortestWordLength <= 1) { + return 2000; // מילה של תו אחד - הגבלה קיצונית + } else if (shortestWordLength <= 2) { + return 3000; // מילה של 2 תווים - הגבלה בינונית + } else if (shortestWordLength <= 3) { + return 4000; // מילה של 3 תווים - הגבלה קלה + } else { + return 5000; // מילה ארוכה - הגבלה מלאה + } } else if (termCount > 1) { return 100; // חיפוש של כמה מילים - צריך expansions גבוה יותר } else { From d94559b35410e2cad48fa7ce4f2c5f4952b43e11 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 3 Aug 2025 20:43:32 +0300 Subject: [PATCH 060/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=A0=D7=99?= =?UTF-8?q?=D7=9D=20=D7=A7=D7=9C=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/bloc/search_bloc.dart | 21 ++- lib/search/view/enhanced_search_field.dart | 6 +- .../view/full_text_facet_filtering.dart | 163 ++++++++++++----- lib/search/view/tantivy_search_results.dart | 172 ++++++++++-------- 4 files changed, 231 insertions(+), 131 deletions(-) diff --git a/lib/search/bloc/search_bloc.dart b/lib/search/bloc/search_bloc.dart index a646cffa2..ab5efa70a 100644 --- a/lib/search/bloc/search_bloc.dart +++ b/lib/search/bloc/search_bloc.dart @@ -139,6 +139,7 @@ class SearchBloc extends Bloc { emit(state.copyWith( filterQuery: null, filteredBooks: null, + facetCounts: {}, // ניקוי ספירות הפאסטים כשמנקים את הסינון )); } @@ -260,7 +261,11 @@ class SearchBloc extends Bloc { } // אם אין, נבצע ספירה ישירה (fallback) - return TantivyDataProvider.instance.countTexts( + print('🔢 Counting texts for facet: $facet'); + print('🔢 Query: ${state.searchQuery}'); + print( + '🔢 Books to search: ${state.booksToSearch.map((e) => e.title).toList()}'); + final result = await TantivyDataProvider.instance.countTexts( state.searchQuery.replaceAll('"', '\\"'), state.booksToSearch.map((e) => e.title).toList(), [facet], @@ -270,6 +275,8 @@ class SearchBloc extends Bloc { alternativeWords: alternativeWords, searchOptions: searchOptions, ); + print('🔢 Count result for $facet: $result'); + return result; } /// ספירה מקבצת של תוצאות עבור מספר facets בבת אחת - לשיפור ביצועים @@ -376,12 +383,18 @@ class SearchBloc extends Bloc { ) { print( '📝 Updating facet counts: ${event.facetCounts.entries.where((e) => e.value > 0).map((e) => '${e.key}: ${e.value}').join(', ')}'); - final newFacetCounts = {...state.facetCounts, ...event.facetCounts}; + final newFacetCounts = event.facetCounts.isEmpty + ? {} // אם מעבירים מפה ריקה, מנקים הכל + : {...state.facetCounts, ...event.facetCounts}; emit(state.copyWith( facetCounts: newFacetCounts, )); print('📊 Total facets in state: ${newFacetCounts.length}'); - print( - '📋 All cached facets: ${newFacetCounts.keys.take(10).join(', ')}...'); + if (newFacetCounts.isNotEmpty) { + print( + '📋 All cached facets: ${newFacetCounts.keys.take(10).join(', ')}...'); + } else { + print('🧹 Facet counts cleared'); + } } } diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 670fcf205..193150629 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -1164,7 +1164,7 @@ class _EnhancedSearchFieldState extends State { color: Theme.of(context).scaffoldBackgroundColor, boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.25), + color: Colors.black.withValues(alpha: 0.25), blurRadius: 8, offset: const Offset(0, 4), ), @@ -1468,6 +1468,10 @@ class _EnhancedSearchFieldState extends State { context .read() .add(UpdateSearchQuery('')); + // ניקוי ספירות הפאסטים + context + .read() + .add(UpdateFacetCounts({})); }, ), ], diff --git a/lib/search/view/full_text_facet_filtering.dart b/lib/search/view/full_text_facet_filtering.dart index 5b76ac70e..d76849c0b 100644 --- a/lib/search/view/full_text_facet_filtering.dart +++ b/lib/search/view/full_text_facet_filtering.dart @@ -117,12 +117,15 @@ class _SearchFacetFilteringState extends State ); } - Widget _buildBookTile(Book book, int count, int level) { + Widget _buildBookTile(Book book, int count, int level, + {String? categoryPath}) { if (count == 0) { return const SizedBox.shrink(); } - final facet = "/${book.topics.replaceAll(', ', '/')}/${book.title}"; + // בניית facet נכון על בסיס נתיב הקטגוריה + final facet = + categoryPath != null ? "$categoryPath/${book.title}" : "/${book.title}"; return BlocBuilder( builder: (context, state) { final isSelected = state.currentFacets.contains(facet); @@ -166,10 +169,8 @@ class _SearchFacetFilteringState extends State return BlocBuilder( builder: (context, state) { // יצירת רשימת כל ה-facets בבת אחת - final facets = books - .map( - (book) => "/${book.topics.replaceAll(', ', '/')}/${book.title}") - .toList(); + // עבור רשימת ספרים מסוננת, נשתמש בשם הספר בלבד + final facets = books.map((book) => "/${book.title}").toList(); // ספירה מקבצת של כל ה-facets final countsFuture = widget.tab.countForMultipleFacets(facets); @@ -186,8 +187,7 @@ class _SearchFacetFilteringState extends State itemCount: books.length, itemBuilder: (context, index) { final book = books[index]; - final facet = - "/${book.topics.replaceAll(', ', '/')}/${book.title}"; + final facet = "/${book.title}"; final count = counts[facet] ?? 0; return _buildBookTile(book, count, 0); }, @@ -223,7 +223,7 @@ class _SearchFacetFilteringState extends State ? Theme.of(context) .colorScheme .surfaceTint - .withOpacity(_kBackgroundOpacity) + .withValues(alpha: _kBackgroundOpacity) : null, child: Row( textDirection: @@ -305,49 +305,112 @@ class _SearchFacetFilteringState extends State } List _buildCategoryChildren(Category category, int level) { - return [ - ...category.subCategories.map((subCategory) { - return BlocBuilder( - builder: (context, state) { - final countFuture = - widget.tab.countForFacetCached(subCategory.path); - return FutureBuilder( - key: ValueKey( - '${state.searchQuery}_${subCategory.path}'), // מפתח שמשתנה עם החיפוש - future: countFuture, - builder: (context, snapshot) { - if (snapshot.hasData) { - return _buildCategoryTile( - subCategory, snapshot.data!, level + 1); + final List children = []; + + // הוספת תת-קטגוריות + for (final subCategory in category.subCategories) { + children.add(BlocBuilder( + builder: (context, state) { + final countFuture = widget.tab.countForFacetCached(subCategory.path); + return FutureBuilder( + key: ValueKey('${state.searchQuery}_${subCategory.path}'), + future: countFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + final count = snapshot.data!; + // מציגים את הקטגוריה רק אם יש בה תוצאות או אם אנחנו בטעינה + if (count > 0 || count == -1) { + return _buildCategoryTile(subCategory, count, level + 1); } - // במקום shrink, נציג placeholder עם ספינר קטן - return _buildCategoryTile(subCategory, -1, level + 1); - }, - ); - }, - ); - }), - ...category.books.map((book) { - return BlocBuilder( - builder: (context, state) { - final facet = "/${book.topics.replaceAll(', ', '/')}/${book.title}"; - final countFuture = widget.tab.countForFacetCached(facet); - return FutureBuilder( - key: ValueKey( - '${state.searchQuery}_$facet'), // מפתח שמשתנה עם החיפוש - future: countFuture, - builder: (context, snapshot) { - if (snapshot.hasData) { - return _buildBookTile(book, snapshot.data!, level + 1); + return const SizedBox.shrink(); + } + return _buildCategoryTile(subCategory, -1, level + 1); + }, + ); + }, + )); + } + + // הוספת ספרים + for (final book in category.books) { + children.add(BlocBuilder( + builder: (context, state) { + // בניית facet נכון על בסיס נתיב הקטגוריה + final categoryPath = category.path; + final fullFacet = "$categoryPath/${book.title}"; + final topicsOnlyFacet = categoryPath; + final titleOnlyFacet = "/${book.title}"; + + print( + '🔍 Checking facets for book "${book.title}" in category "${category.path}":'); + print(' - Full: $fullFacet'); + print(' - Topics only: $topicsOnlyFacet'); + print(' - Title only: $titleOnlyFacet'); + + // ננסה קודם עם ה-facet המלא + final countFuture = widget.tab.countForFacetCached(fullFacet); + return FutureBuilder( + key: ValueKey('${state.searchQuery}_$fullFacet'), + future: countFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + final count = snapshot.data!; + print('📊 Count for "${book.title}" ($fullFacet): $count'); + + // אם יש תוצאות, נציג את הספר + if (count > 0 || count == -1) { + return _buildBookTile(book, count, level + 1, + categoryPath: category.path); } - // במקום shrink, נציג placeholder עם ספינר קטן - return _buildBookTile(book, -1, level + 1); - }, - ); - }, - ); - }), - ]; + + // אם אין תוצאות עם ה-facet המלא, ננסה עם topics בלבד + return FutureBuilder( + key: ValueKey('${state.searchQuery}_$topicsOnlyFacet'), + future: widget.tab.countForFacetCached(topicsOnlyFacet), + builder: (context, topicsSnapshot) { + if (topicsSnapshot.hasData) { + final topicsCount = topicsSnapshot.data!; + print( + '📊 Count for "${book.title}" ($topicsOnlyFacet): $topicsCount'); + + if (topicsCount > 0 || topicsCount == -1) { + // יש תוצאות בקטגוריה, אבל לא בספר הספציפי + // לא נציג את הספר כי זה יגרום להצגת ספרים ללא תוצאות + return const SizedBox.shrink(); + } + + // ננסה עם שם הספר בלבד + return FutureBuilder( + key: ValueKey('${state.searchQuery}_$titleOnlyFacet'), + future: widget.tab.countForFacetCached(titleOnlyFacet), + builder: (context, titleSnapshot) { + if (titleSnapshot.hasData) { + final titleCount = titleSnapshot.data!; + print( + '📊 Count for "${book.title}" ($titleOnlyFacet): $titleCount'); + + if (titleCount > 0 || titleCount == -1) { + return _buildBookTile(book, titleCount, level + 1, + categoryPath: category.path); + } + } + return const SizedBox.shrink(); + }, + ); + } + return _buildBookTile(book, -1, level + 1); + }, + ); + } + return _buildBookTile(book, -1, level + 1, + categoryPath: category.path); + }, + ); + }, + )); + } + + return children; } Widget _buildFacetTree() { diff --git a/lib/search/view/tantivy_search_results.dart b/lib/search/view/tantivy_search_results.dart index 22e1a3894..5bf2dd6ed 100644 --- a/lib/search/view/tantivy_search_results.dart +++ b/lib/search/view/tantivy_search_results.dart @@ -359,88 +359,108 @@ class _TantivySearchResultsState extends State { )); } - return ListView.builder( - shrinkWrap: true, - itemCount: state.results.length, - itemBuilder: (context, index) { - final result = state.results[index]; - return BlocBuilder( - builder: (context, settingsState) { - String titleText = '[תוצאה ${index + 1}] ${result.reference}'; - String rawHtml = result.text; - if (settingsState.replaceHolyNames) { - titleText = utils.replaceHolyNames(titleText); - rawHtml = utils.replaceHolyNames(rawHtml); - } - - // חישוב רוחב זמין לטקסט (מינוס אייקון ו-padding) - final availableWidth = constrains.maxWidth - - (result.isPdf ? 56.0 : 16.0) - // רוחב האייקון או padding - 32.0; // padding נוסף של ListTile - - // Create the snippet using the new robust function - final snippetSpans = createSnippetSpans( - rawHtml, - state.searchQuery, - TextStyle( - fontSize: settingsState.fontSize, - fontFamily: settingsState.fontFamily, - ), - TextStyle( - fontSize: settingsState.fontSize, - fontFamily: settingsState.fontFamily, - color: Colors.red, - fontWeight: FontWeight.bold, - ), - availableWidth, - ); - - return ListTile( - leading: result.isPdf ? const Icon(Icons.picture_as_pdf) : null, - onTap: () { - if (result.isPdf) { - context.read().add(AddTab( - PdfBookTab( - book: PdfBook( - title: result.title, path: result.filePath), - pageNumber: result.segment.toInt() + 1, - searchText: widget.tab.queryController.text, - openLeftPane: - (Settings.getValue('key-pin-sidebar') ?? - false) || - (Settings.getValue( - 'key-default-sidebar-open') ?? - false), - ), - )); - } else { - context.read().add(AddTab( - TextBookTab( - book: TextBook( - title: result.title, - ), - index: result.segment.toInt(), + // תמיד נשתמש ב-ListView גם לתוצאה אחת - כך היא תופיע למעלה + return Align( + alignment: Alignment.topCenter, + child: ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), // מונע גלילה כפולה + itemCount: state.results.length, + itemBuilder: (context, index) { + final result = state.results[index]; + return BlocBuilder( + builder: (context, settingsState) { + String titleText = '[תוצאה ${index + 1}] ${result.reference}'; + String rawHtml = result.text; + print( + '🎯 Search result: title="${result.title}", reference="${result.reference}"'); + // בואו נבדוק אם יש לתוצאה facet - נבדוק את כל השדות + try { + print('🎯 Result details:'); + print(' - title: ${result.title}'); + print(' - reference: ${result.reference}'); + print(' - segment: ${result.segment}'); + print(' - isPdf: ${result.isPdf}'); + print(' - filePath: ${result.filePath}'); + // אולי יש שדה topics או facet + print(' - toString: ${result.toString()}'); + } catch (e) { + print('🎯 Error getting result details: $e'); + } + if (settingsState.replaceHolyNames) { + titleText = utils.replaceHolyNames(titleText); + rawHtml = utils.replaceHolyNames(rawHtml); + } + + // חישוב רוחב זמין לטקסט (מינוס אייקון ו-padding) + final availableWidth = constrains.maxWidth - + (result.isPdf ? 56.0 : 16.0) - // רוחב האייקון או padding + 32.0; // padding נוסף של ListTile + + // Create the snippet using the new robust function + final snippetSpans = createSnippetSpans( + rawHtml, + state.searchQuery, + TextStyle( + fontSize: settingsState.fontSize, + fontFamily: settingsState.fontFamily, + ), + TextStyle( + fontSize: settingsState.fontSize, + fontFamily: settingsState.fontFamily, + color: Colors.red, + fontWeight: FontWeight.bold, + ), + availableWidth, + ); + + return ListTile( + leading: result.isPdf ? const Icon(Icons.picture_as_pdf) : null, + onTap: () { + if (result.isPdf) { + context.read().add(AddTab( + PdfBookTab( + book: PdfBook( + title: result.title, path: result.filePath), + pageNumber: result.segment.toInt() + 1, searchText: widget.tab.queryController.text, openLeftPane: (Settings.getValue('key-pin-sidebar') ?? false) || (Settings.getValue( 'key-default-sidebar-open') ?? - false)), - )); - } - }, - title: Text(titleText), - subtitle: Text.rich( - TextSpan(children: snippetSpans), - maxLines: null, // אין הגבלה על מספר השורות! - textAlign: TextAlign.justify, - textDirection: TextDirection.rtl, - ), - ); - }, - ); - }, + false), + ), + )); + } else { + context.read().add(AddTab( + TextBookTab( + book: TextBook( + title: result.title, + ), + index: result.segment.toInt(), + searchText: widget.tab.queryController.text, + openLeftPane: + (Settings.getValue('key-pin-sidebar') ?? + false) || + (Settings.getValue( + 'key-default-sidebar-open') ?? + false)), + )); + } + }, + title: Text(titleText), + subtitle: Text.rich( + TextSpan(children: snippetSpans), + maxLines: null, // אין הגבלה על מספר השורות! + textAlign: TextAlign.justify, + textDirection: TextDirection.rtl, + ), + ); + }, + ); + }, + ), ); } } From 53ac65660d632e6edf396055555a01a1a0b97e21 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 3 Aug 2025 20:52:29 +0300 Subject: [PATCH 061/197] =?UTF-8?q?=D7=97=D7=9C=D7=95=D7=A7=D7=AA=20=D7=94?= =?UTF-8?q?=D7=9E=D7=92=D7=99=D7=A8=D7=94=20=D7=9C3=20=D7=98=D7=95=D7=A8?= =?UTF-8?q?=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/enhanced_search_field.dart | 51 +++++++++++++++++++--- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 193150629..52eccf992 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -1622,27 +1622,64 @@ class _SearchOptionsContentState extends State<_SearchOptionsContent> { @override Widget build(BuildContext context) { - // 1. נקודת ההכרעה: מה הרוחב המינימלי שדרוש כדי להציג הכל בשורה אחת? - // אפשר לשחק עם הערך הזה, אבל 650 הוא נקודת התחלה טובה. - const double singleRowThreshold = 650.0; + // נקודות ההכרעה לתצוגות שונות + const double singleRowThreshold = 650.0; // רוחב מינימלי לשורה אחת + const double threeColumnsThreshold = 450.0; // רוחב מינימלי ל-3 טורים return LayoutBuilder( builder: (context, constraints) { - // constraints.maxWidth נותן לנו את הרוחב הזמין האמיתי למגירה final availableWidth = constraints.maxWidth; - // 2. אם המסך רחב מספיק - נשתמש ב-Wrap (שיראה כמו שורה אחת) + // 1. אם המסך רחב מספיק - נשתמש ב-Wrap (שיראה כמו שורה אחת) if (availableWidth >= singleRowThreshold) { return Wrap( spacing: 16.0, runSpacing: 8.0, - alignment: WrapAlignment.center, // מרכוז יפה של הפריטים + alignment: WrapAlignment.center, children: _availableOptions .map((option) => _buildCheckbox(option)) .toList(), ); } - // 3. אם המסך צר מדי - נעבור לתצוגת טורים מסודרת + // 2. אם יש מקום ל-3 טורים - נחלק ל-3 + else if (availableWidth >= threeColumnsThreshold) { + // מחלקים את רשימת האפשרויות לשלושה טורים + final int itemsPerColumn = (_availableOptions.length / 3).ceil(); + final List column1Options = + _availableOptions.take(itemsPerColumn).toList(); + final List column2Options = _availableOptions + .skip(itemsPerColumn) + .take(itemsPerColumn) + .toList(); + final List column3Options = + _availableOptions.skip(itemsPerColumn * 2).toList(); + + // פונקציית עזר לבניית עמודה + Widget buildColumn(List options) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: options + .map((option) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: _buildCheckbox(option), + )) + .toList(), + ); + } + + // מחזירים שורה שמכילה את שלושת הטורים + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + buildColumn(column1Options), + buildColumn(column2Options), + buildColumn(column3Options), + ], + ); + } + // 3. אם המסך צר מדי - נעבור לתצוגת 2 טורים else { // מחלקים את רשימת האפשרויות לשתי עמודות final int middle = (_availableOptions.length / 2).ceil(); From ed7f4099aaadfa5db0b6ab016c41de4adc9e46e9 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 3 Aug 2025 21:45:09 +0300 Subject: [PATCH 062/197] =?UTF-8?q?=D7=94=D7=AA=D7=90=D7=9E=D7=94=20=D7=90?= =?UTF-8?q?=D7=99=D7=A9=D7=99=D7=AA=20=D7=A9=D7=9C=20=D7=A8=D7=95=D7=97?= =?UTF-8?q?=D7=91=20=D7=90=D7=96=D7=95=D7=A8=20=D7=A1=D7=99=D7=A0=D7=95?= =?UTF-8?q?=D7=9F=20=D7=94=D7=AA=D7=95=D7=A6=D7=90=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../view/full_text_facet_filtering.dart | 11 -- lib/search/view/tantivy_full_text_search.dart | 10 +- lib/settings/settings_bloc.dart | 13 +- lib/settings/settings_event.dart | 11 +- lib/settings/settings_repository.dart | 17 ++- lib/settings/settings_state.dart | 10 +- lib/text_book/view/text_book_screen.dart | 122 +++++++++--------- lib/widgets/resizable_facet_filtering.dart | 110 ++++++++++++++++ 8 files changed, 218 insertions(+), 86 deletions(-) create mode 100644 lib/widgets/resizable_facet_filtering.dart diff --git a/lib/search/view/full_text_facet_filtering.dart b/lib/search/view/full_text_facet_filtering.dart index d76849c0b..19de2e44d 100644 --- a/lib/search/view/full_text_facet_filtering.dart +++ b/lib/search/view/full_text_facet_filtering.dart @@ -341,12 +341,6 @@ class _SearchFacetFilteringState extends State final topicsOnlyFacet = categoryPath; final titleOnlyFacet = "/${book.title}"; - print( - '🔍 Checking facets for book "${book.title}" in category "${category.path}":'); - print(' - Full: $fullFacet'); - print(' - Topics only: $topicsOnlyFacet'); - print(' - Title only: $titleOnlyFacet'); - // ננסה קודם עם ה-facet המלא final countFuture = widget.tab.countForFacetCached(fullFacet); return FutureBuilder( @@ -355,7 +349,6 @@ class _SearchFacetFilteringState extends State builder: (context, snapshot) { if (snapshot.hasData) { final count = snapshot.data!; - print('📊 Count for "${book.title}" ($fullFacet): $count'); // אם יש תוצאות, נציג את הספר if (count > 0 || count == -1) { @@ -370,8 +363,6 @@ class _SearchFacetFilteringState extends State builder: (context, topicsSnapshot) { if (topicsSnapshot.hasData) { final topicsCount = topicsSnapshot.data!; - print( - '📊 Count for "${book.title}" ($topicsOnlyFacet): $topicsCount'); if (topicsCount > 0 || topicsCount == -1) { // יש תוצאות בקטגוריה, אבל לא בספר הספציפי @@ -386,8 +377,6 @@ class _SearchFacetFilteringState extends State builder: (context, titleSnapshot) { if (titleSnapshot.hasData) { final titleCount = titleSnapshot.data!; - print( - '📊 Count for "${book.title}" ($titleOnlyFacet): $titleCount'); if (titleCount > 0 || titleCount == -1) { return _buildBookTile(book, titleCount, level + 1, diff --git a/lib/search/view/tantivy_full_text_search.dart b/lib/search/view/tantivy_full_text_search.dart index 23a2217bc..f7ba24681 100644 --- a/lib/search/view/tantivy_full_text_search.dart +++ b/lib/search/view/tantivy_full_text_search.dart @@ -12,6 +12,7 @@ import 'package:otzaria/search/view/full_text_settings_widgets.dart'; import 'package:otzaria/search/view/tantivy_search_field.dart'; import 'package:otzaria/search/view/tantivy_search_results.dart'; import 'package:otzaria/search/view/full_text_facet_filtering.dart'; +import 'package:otzaria/widgets/resizable_facet_filtering.dart'; class TantivyFullTextSearch extends StatefulWidget { final SearchingTab tab; @@ -168,14 +169,7 @@ class _TantivyFullTextSearchState extends State Expanded( child: Row( children: [ - SizedBox( - width: 235, - child: SearchFacetFiltering(tab: widget.tab), - ), - Container( - width: 1, - color: Colors.grey.shade300, - ), + ResizableFacetFiltering(tab: widget.tab), Expanded( child: Builder(builder: (context) { if (state.isLoading) { diff --git a/lib/settings/settings_bloc.dart b/lib/settings/settings_bloc.dart index 83f056436..9849f9fc9 100644 --- a/lib/settings/settings_bloc.dart +++ b/lib/settings/settings_bloc.dart @@ -27,6 +27,7 @@ class SettingsBloc extends Bloc { on(_onUpdateDefaultSidebarOpen); on(_onUpdatePinSidebar); on(_onUpdateSidebarWidth); + on(_onUpdateFacetFilteringWidth); } Future _onLoadSettings( @@ -51,7 +52,8 @@ class SettingsBloc extends Bloc { removeNikudFromTanach: settings['removeNikudFromTanach'], defaultSidebarOpen: settings['defaultSidebarOpen'], pinSidebar: settings['pinSidebar'], - sidebarWidth: settings['sidebarWidth'], + sidebarWidth: settings['sidebarWidth'], + facetFilteringWidth: settings['facetFilteringWidth'], )); } @@ -166,6 +168,7 @@ class SettingsBloc extends Bloc { await _repository.updateRemoveNikudFromTanach(event.removeNikudFromTanach); emit(state.copyWith(removeNikudFromTanach: event.removeNikudFromTanach)); } + Future _onUpdateDefaultSidebarOpen( UpdateDefaultSidebarOpen event, Emitter emit, @@ -189,4 +192,12 @@ class SettingsBloc extends Bloc { await _repository.updateSidebarWidth(event.sidebarWidth); emit(state.copyWith(sidebarWidth: event.sidebarWidth)); } + + Future _onUpdateFacetFilteringWidth( + UpdateFacetFilteringWidth event, + Emitter emit, + ) async { + await _repository.updateFacetFilteringWidth(event.facetFilteringWidth); + emit(state.copyWith(facetFilteringWidth: event.facetFilteringWidth)); + } } diff --git a/lib/settings/settings_event.dart b/lib/settings/settings_event.dart index a1e7b3f36..69d2f396f 100644 --- a/lib/settings/settings_event.dart +++ b/lib/settings/settings_event.dart @@ -161,4 +161,13 @@ class UpdateSidebarWidth extends SettingsEvent { @override List get props => [sidebarWidth]; -} \ No newline at end of file +} + +class UpdateFacetFilteringWidth extends SettingsEvent { + final double facetFilteringWidth; + + const UpdateFacetFilteringWidth(this.facetFilteringWidth); + + @override + List get props => [facetFilteringWidth]; +} diff --git a/lib/settings/settings_repository.dart b/lib/settings/settings_repository.dart index d7a7efef6..bb2605ad9 100644 --- a/lib/settings/settings_repository.dart +++ b/lib/settings/settings_repository.dart @@ -20,6 +20,7 @@ class SettingsRepository { static const String keyDefaultSidebarOpen = 'key-default-sidebar-open'; static const String keyPinSidebar = 'key-pin-sidebar'; static const String keySidebarWidth = 'key-sidebar-width'; + static const String keyFacetFilteringWidth = 'key-facet-filtering-width'; final SettingsWrapper _settings; @@ -29,7 +30,7 @@ class SettingsRepository { Future> loadSettings() async { // Initialize default settings to disk if needed await _initializeDefaultsIfNeeded(); - + return { 'isDarkMode': _settings.getValue(keyDarkMode, defaultValue: false), 'seedColor': ColorUtils.colorFromString( @@ -88,6 +89,8 @@ class SettingsRepository { ), 'sidebarWidth': _settings.getValue(keySidebarWidth, defaultValue: 300), + 'facetFilteringWidth': + _settings.getValue(keyFacetFilteringWidth, defaultValue: 235), }; } @@ -142,9 +145,11 @@ class SettingsRepository { Future updateDefaultRemoveNikud(bool value) async { await _settings.setValue(keyDefaultNikud, value); } + Future updateRemoveNikudFromTanach(bool value) async { await _settings.setValue(keyRemoveNikudFromTanach, value); } + Future updateDefaultSidebarOpen(bool value) async { await _settings.setValue(keyDefaultSidebarOpen, value); } @@ -157,6 +162,10 @@ class SettingsRepository { await _settings.setValue(keySidebarWidth, value); } + Future updateFacetFilteringWidth(double value) async { + await _settings.setValue(keyFacetFilteringWidth, value); + } + /// Initialize default settings to disk if this is the first app launch Future _initializeDefaultsIfNeeded() async { if (await _checkIfDefaultsNeeded()) { @@ -167,7 +176,8 @@ class SettingsRepository { /// Check if default settings need to be initialized Future _checkIfDefaultsNeeded() async { // Use a dedicated flag to track initialization - return !_settings.getValue('settings_initialized', defaultValue: false); + return !_settings.getValue('settings_initialized', + defaultValue: false); } /// Write all default settings to persistent storage @@ -189,7 +199,8 @@ class SettingsRepository { await _settings.setValue(keyDefaultSidebarOpen, false); await _settings.setValue(keyPinSidebar, false); await _settings.setValue(keySidebarWidth, 300.0); - + await _settings.setValue(keyFacetFilteringWidth, 235.0); + // Mark as initialized await _settings.setValue('settings_initialized', true); } diff --git a/lib/settings/settings_state.dart b/lib/settings/settings_state.dart index 26813af24..6479306d5 100644 --- a/lib/settings/settings_state.dart +++ b/lib/settings/settings_state.dart @@ -19,6 +19,7 @@ class SettingsState extends Equatable { final bool defaultSidebarOpen; final bool pinSidebar; final double sidebarWidth; + final double facetFilteringWidth; const SettingsState({ required this.isDarkMode, @@ -38,6 +39,7 @@ class SettingsState extends Equatable { required this.defaultSidebarOpen, required this.pinSidebar, required this.sidebarWidth, + required this.facetFilteringWidth, }); factory SettingsState.initial() { @@ -59,6 +61,7 @@ class SettingsState extends Equatable { defaultSidebarOpen: false, pinSidebar: false, sidebarWidth: 300, + facetFilteringWidth: 235, ); } @@ -80,6 +83,7 @@ class SettingsState extends Equatable { bool? defaultSidebarOpen, bool? pinSidebar, double? sidebarWidth, + double? facetFilteringWidth, }) { return SettingsState( isDarkMode: isDarkMode ?? this.isDarkMode, @@ -99,7 +103,8 @@ class SettingsState extends Equatable { removeNikudFromTanach ?? this.removeNikudFromTanach, defaultSidebarOpen: defaultSidebarOpen ?? this.defaultSidebarOpen, pinSidebar: pinSidebar ?? this.pinSidebar, - sidebarWidth: sidebarWidth ?? this.sidebarWidth, + sidebarWidth: sidebarWidth ?? this.sidebarWidth, + facetFilteringWidth: facetFilteringWidth ?? this.facetFilteringWidth, ); } @@ -121,6 +126,7 @@ class SettingsState extends Equatable { removeNikudFromTanach, defaultSidebarOpen, pinSidebar, - sidebarWidth, + sidebarWidth, + facetFilteringWidth, ]; } diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index 0a4418a86..655b38e28 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -591,7 +591,7 @@ class _TextBookViewerBlocState extends State Align( alignment: Alignment.centerRight, child: Text( - 'פירוט הטעות (אופציונלי):', + 'פירוט הטעות (חובה לפרט מהי הטעות, בלא פירוט לא נוכל לטפל):', style: Theme.of(context) .textTheme .bodyMedium @@ -1007,73 +1007,75 @@ class _TextBookViewerBlocState extends State child: SizedBox( width: state.showLeftPane ? width : 0, child: Padding( - padding: const EdgeInsets.fromLTRB(1, 0, 4, 0), - child: Column( - children: [ - Row( - children: [ - Expanded( - child: TabBar( - tabs: const [ - Tab(text: 'ניווט'), - Tab(text: 'חיפוש'), - Tab(text: 'פרשנות'), - Tab(text: 'קישורים'), - ], - controller: tabController, - onTap: (value) { - if (value == 1 && !Platform.isAndroid) { - textSearchFocusNode.requestFocus(); - } else if (value == 0 && !Platform.isAndroid) { - navigationSearchFocusNode.requestFocus(); - } - }, - ), - ), - if (MediaQuery.of(context).size.width >= 600) - IconButton( - onPressed: - (Settings.getValue('key-pin-sidebar') ?? false) - ? null - : () => context.read().add( - TogglePinLeftPane(!state.pinLeftPane), - ), - icon: const Icon(Icons.push_pin), - isSelected: state.pinLeftPane || - (Settings.getValue('key-pin-sidebar') ?? false), - ), - ], - ), - Expanded( - child: TabBarView( - controller: tabController, + padding: const EdgeInsets.fromLTRB(1, 0, 4, 0), + child: Column( + children: [ + Row( children: [ - _buildTocViewer(context, state), - CallbackShortcuts( - bindings: { - LogicalKeySet( - LogicalKeyboardKey.control, - LogicalKeyboardKey.keyF, - ): () { - context.read().add( - const ToggleLeftPane(true), - ); - tabController.index = 1; - textSearchFocusNode.requestFocus(); + Expanded( + child: TabBar( + tabs: const [ + Tab(text: 'ניווט'), + Tab(text: 'חיפוש'), + Tab(text: 'פרשנות'), + Tab(text: 'קישורים'), + ], + controller: tabController, + onTap: (value) { + if (value == 1 && !Platform.isAndroid) { + textSearchFocusNode.requestFocus(); + } else if (value == 0 && !Platform.isAndroid) { + navigationSearchFocusNode.requestFocus(); + } }, - }, - child: _buildSearchView(context, state), + ), ), - _buildCommentaryView(), - _buildLinkView(context, state), + if (MediaQuery.of(context).size.width >= 600) + IconButton( + onPressed: + (Settings.getValue('key-pin-sidebar') ?? + false) + ? null + : () => context.read().add( + TogglePinLeftPane(!state.pinLeftPane), + ), + icon: const Icon(Icons.push_pin), + isSelected: state.pinLeftPane || + (Settings.getValue('key-pin-sidebar') ?? + false), + ), ], ), - ), - ], + Expanded( + child: TabBarView( + controller: tabController, + children: [ + _buildTocViewer(context, state), + CallbackShortcuts( + bindings: { + LogicalKeySet( + LogicalKeyboardKey.control, + LogicalKeyboardKey.keyF, + ): () { + context.read().add( + const ToggleLeftPane(true), + ); + tabController.index = 1; + textSearchFocusNode.requestFocus(); + }, + }, + child: _buildSearchView(context, state), + ), + _buildCommentaryView(), + _buildLinkView(context, state), + ], + ), + ), + ], + ), ), ), ), - ), ); } diff --git a/lib/widgets/resizable_facet_filtering.dart b/lib/widgets/resizable_facet_filtering.dart new file mode 100644 index 000000000..a704ab997 --- /dev/null +++ b/lib/widgets/resizable_facet_filtering.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:otzaria/settings/settings_bloc.dart'; +import 'package:otzaria/settings/settings_event.dart'; +import 'package:otzaria/settings/settings_state.dart'; +import 'package:otzaria/search/view/full_text_facet_filtering.dart'; +import 'package:otzaria/tabs/models/searching_tab.dart'; + +/// Widget שמאפשר שינוי גודל של אזור סינון התוצאות +/// ושומר את הגודל בהגדרות המשתמש +class ResizableFacetFiltering extends StatefulWidget { + final SearchingTab tab; + final double minWidth; + final double maxWidth; + + const ResizableFacetFiltering({ + Key? key, + required this.tab, + this.minWidth = 150, + this.maxWidth = 500, + }) : super(key: key); + + @override + State createState() => + _ResizableFacetFilteringState(); +} + +class _ResizableFacetFilteringState extends State { + late double _currentWidth; + bool _isResizing = false; + + @override + void initState() { + super.initState(); + // טעינת הרוחב מההגדרות + final settingsState = context.read().state; + _currentWidth = settingsState.facetFilteringWidth; + } + + void _onPanUpdate(DragUpdateDetails details) { + setState(() { + // עדכון הרוחב בהתאם לתנועת העכבר + // details.delta.dx הוא השינוי ב-x (חיובי = ימינה, שלילי = שמאלה) + _currentWidth = (_currentWidth - details.delta.dx) + .clamp(widget.minWidth, widget.maxWidth); + }); + } + + void _onPanStart(DragStartDetails details) { + setState(() { + _isResizing = true; + }); + } + + void _onPanEnd(DragEndDetails details) { + setState(() { + _isResizing = false; + }); + // שמירת הרוחב החדש בהגדרות + context.read().add(UpdateFacetFilteringWidth(_currentWidth)); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + // עדכון הרוחב כאשר ההגדרות משתנות מבחוץ + if (state.facetFilteringWidth != _currentWidth) { + setState(() { + _currentWidth = state.facetFilteringWidth; + }); + } + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // אזור הסינון עצמו + SizedBox( + width: _currentWidth, + child: SearchFacetFiltering(tab: widget.tab), + ), + // הידית לשינוי גודל + GestureDetector( + onPanStart: _onPanStart, + onPanUpdate: _onPanUpdate, + onPanEnd: _onPanEnd, + child: MouseRegion( + cursor: SystemMouseCursors.resizeLeftRight, + child: Container( + width: 8, + color: _isResizing + ? Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.3) + : Colors.transparent, + child: Center( + child: Container( + width: 1, + color: Colors.grey.shade300, + ), + ), + ), + ), + ), + ], + ), + ); + } +} From 47aac8f55c72933b9d6faa75795f0471d54017f8 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 3 Aug 2025 22:09:29 +0300 Subject: [PATCH 063/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=A7?= =?UTF-8?q?=D7=9C=20=D7=9Ccommit=20=D7=94=D7=A7=D7=95=D7=93=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/enhanced_search_field.dart | 4 +-- .../mocks/mock_settings_repository.mocks.dart | 25 ++++++++++++++++++- test/unit/settings/settings_bloc_test.dart | 4 ++- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 52eccf992..3483e2012 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -1246,10 +1246,10 @@ class _EnhancedSearchFieldState extends State { if (wordInfo == null || wordInfo['word'] == null || wordInfo['word'].isEmpty) { - return Center( + return const Center( child: Text( 'הקלד או הצב את הסמן על מילה כלשהיא, כדי לבחור אפשרויות חיפוש', - style: const TextStyle(fontSize: 12, color: Colors.grey), + style: TextStyle(fontSize: 12, color: Colors.grey), textAlign: TextAlign.center, ), ); diff --git a/test/unit/mocks/mock_settings_repository.mocks.dart b/test/unit/mocks/mock_settings_repository.mocks.dart index b2c64f1b4..3cc92b2a9 100644 --- a/test/unit/mocks/mock_settings_repository.mocks.dart +++ b/test/unit/mocks/mock_settings_repository.mocks.dart @@ -158,6 +158,7 @@ class MockSettingsRepository extends _i1.Mock returnValue: _i3.Future.value(), returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); + @override _i3.Future updateDefaultRemoveNikud(bool? value) => (super.noSuchMethod( Invocation.method( @@ -169,7 +170,8 @@ class MockSettingsRepository extends _i1.Mock ) as _i3.Future); @override - _i3.Future updateRemoveNikudFromTanach(bool? value) => (super.noSuchMethod( + _i3.Future updateRemoveNikudFromTanach(bool? value) => + (super.noSuchMethod( Invocation.method( #updateRemoveNikudFromTanach, [value], @@ -197,4 +199,25 @@ class MockSettingsRepository extends _i1.Mock returnValue: _i3.Future.value(), returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); + + @override + _i3.Future updateSidebarWidth(double? value) => (super.noSuchMethod( + Invocation.method( + #updateSidebarWidth, + [value], + ), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); + + @override + _i3.Future updateFacetFilteringWidth(double? value) => + (super.noSuchMethod( + Invocation.method( + #updateFacetFilteringWidth, + [value], + ), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); } diff --git a/test/unit/settings/settings_bloc_test.dart b/test/unit/settings/settings_bloc_test.dart index 43e8d1122..c75bd4380 100644 --- a/test/unit/settings/settings_bloc_test.dart +++ b/test/unit/settings/settings_bloc_test.dart @@ -44,6 +44,7 @@ void main() { 'defaultSidebarOpen': true, 'pinSidebar': true, 'sidebarWidth': 300.0, + 'facetFilteringWidth': 235.0, }; blocTest( @@ -74,6 +75,7 @@ void main() { defaultSidebarOpen: mockSettings['defaultSidebarOpen'] as bool, pinSidebar: mockSettings['pinSidebar'] as bool, sidebarWidth: mockSettings['sidebarWidth'] as double, + facetFilteringWidth: mockSettings['facetFilteringWidth'] as double, ), ], verify: (_) { @@ -215,4 +217,4 @@ void main() { ); }); }); -} \ No newline at end of file +} From 6724bd9ce6001c3eec71756285e30559fa4dd5f6 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 3 Aug 2025 23:40:00 +0300 Subject: [PATCH 064/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=94?= =?UTF-8?q?commit=20=D7=A9=D7=99=D7=A4=D7=95=D7=A8=20=D7=9E=D7=94=D7=99?= =?UTF-8?q?=D7=A8=D7=95=D7=AA=20=D7=98=D7=A2=D7=99=D7=A0=D7=AA=20=D7=94?= =?UTF-8?q?=D7=A1=D7=A4=D7=A8=D7=99=D7=99=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file_system_data_provider.dart | 157 ++++++++++-------- 1 file changed, 89 insertions(+), 68 deletions(-) diff --git a/lib/data/data_providers/file_system_data_provider.dart b/lib/data/data_providers/file_system_data_provider.dart index 3e8d9966d..71472e28e 100644 --- a/lib/data/data_providers/file_system_data_provider.dart +++ b/lib/data/data_providers/file_system_data_provider.dart @@ -44,80 +44,86 @@ class FileSystemData { /// Reads the library from the configured path and combines it with metadata /// to create a full [Library] object containing all categories and books. Future getLibrary() async { - // --- הגדרת נתיבים --- - final cachePath = '$libraryPath${Platform.pathSeparator}library_cache.json'; - final cacheFile = File(cachePath); - final metadataPath = '$libraryPath${Platform.pathSeparator}metadata.json'; - final metadataFile = File(metadataPath); + // --- הגדרת נתיבים --- + final cachePath = '$libraryPath${Platform.pathSeparator}library_cache.json'; + final cacheFile = File(cachePath); + final metadataPath = '$libraryPath${Platform.pathSeparator}metadata.json'; + final metadataFile = File(metadataPath); + + // --- בדיקת תוקף המטמון --- + bool isCacheValid = await cacheFile.exists(); + + if (isCacheValid) { + try { + final cacheLastModified = await cacheFile.stat(); + + // בדיקה רקורסיבית של כל התיקיות והקבצים בספרייה + final libraryDir = + Directory('$libraryPath${Platform.pathSeparator}אוצריא'); + if (await libraryDir.exists()) { + await for (FileSystemEntity entity + in libraryDir.list(recursive: true)) { + final entityStat = await entity.stat(); + if (cacheLastModified.modified.isBefore(entityStat.modified)) { + isCacheValid = false; + break; + } + } + } + + // 3. בדוק אם המטמון ישן יותר מקובץ המטא-דאטה + if (isCacheValid && await metadataFile.exists()) { + final metadataLastModified = await metadataFile.stat(); + if (cacheLastModified.modified + .isBefore(metadataLastModified.modified)) { + isCacheValid = false; // אם כן, המטמון לא תקין + } + } + } catch (_) { + isCacheValid = false; // אם יש שגיאה בבדיקה, נניח שהמטמון לא תקין + } + } - // --- בדיקת תוקף המטמון --- - bool isCacheValid = await cacheFile.exists(); + // --- טעינה מהמטמון (רק אם הוא קיים ותקין) --- + if (isCacheValid) { + try { + final jsonString = await cacheFile.readAsString(); + final jsonMap = await Isolate.run(() => jsonDecode(jsonString)); - if (isCacheValid) { - try { - final cacheLastModified = await cacheFile.stat(); - final libraryDirLastModified = await Directory(libraryPath).stat(); + // טוען את הנתיבים מהמטמון + titleToPath = Future.value( + Map.from(jsonMap['titleToPath'] ?? {})); - // 2. בדוק אם המטמון ישן יותר משינוי במבנה תיקיית הספרייה (הוספה/מחיקה) - if (cacheLastModified.modified.isBefore(libraryDirLastModified.modified)) { - isCacheValid = false; // אם כן, המטמון לא תקין - } + // תמיד טוען את המטא-דאטה מחדש מהקובץ כדי להבטיח עדכניות + metadata = _getMetadata(); - // 3. בדוק אם המטמון ישן יותר מקובץ המטא-דאטה - if (isCacheValid && await metadataFile.exists()) { - final metadataLastModified = await metadataFile.stat(); - if (cacheLastModified.modified.isBefore(metadataLastModified.modified)) { - isCacheValid = false; // אם כן, המטמון לא תקין - } + return Library.fromJson(Map.from(jsonMap['library'])); + } catch (_) { + // אם יש שגיאה בקריאה מהמטמון, נסרוק מחדש } - } catch (_) { - isCacheValid = false; // אם יש שגיאה בבדיקה, נניח שהמטמון לא תקין } - } - // --- טעינה מהמטמון (רק אם הוא קיים ותקין) --- - if (isCacheValid) { - try { - final jsonString = await cacheFile.readAsString(); - final jsonMap = await Isolate.run(() => jsonDecode(jsonString)); - - // טוען גם את הנתיבים וגם את המטא-דאטה מהמטמון - titleToPath = Future.value(Map.from(jsonMap['titleToPath'] ?? {})); - - // המפתח 'metadata' עדיין לא קיים במטמונים ישנים, לכן צריך בדיקה - if (jsonMap.containsKey('metadata')) { - metadata = Future.value(Map>.from(jsonMap['metadata'])); - } else { - metadata = _getMetadata(); // טעינה רגילה אם המפתח חסר - } + // --- סריקה מלאה (אם המטמון לא קיים או לא תקין) --- + titleToPath = _getTitleToPath(); + metadata = _getMetadata(); + final lib = await _getLibraryFromDirectory( + '$libraryPath${Platform.pathSeparator}אוצריא', await metadata); - return Library.fromJson(Map.from(jsonMap['library'])); + // --- יצירת קובץ מטמון חדש --- + try { + final jsonMap = { + 'library': lib.toJson(), + 'titleToPath': await titleToPath, + // לא שומרים את המטא-דאטה במטמון כדי שתיטען תמיד מחדש + }; + await cacheFile.writeAsString(jsonEncode(jsonMap)); } catch (_) { - // אם יש שגיאה בקריאה מהמטמון, נסרוק מחדש + // מתעלם משגיאות כתיבה למטמון } - } - // --- סריקה מלאה (אם המטמון לא קיים או לא תקין) --- - titleToPath = _getTitleToPath(); - metadata = _getMetadata(); - final lib = await _getLibraryFromDirectory( - '$libraryPath${Platform.pathSeparator}אוצריא', await metadata); - - // --- יצירת קובץ מטמון חדש --- - try { - final jsonMap = { - 'library': lib.toJson(), - 'titleToPath': await titleToPath, - 'metadata': await metadata, - }; - await cacheFile.writeAsString(jsonEncode(jsonMap)); - } catch (_) { - // מתעלם משגיאות כתיבה למטמון + return lib; } - return lib; -} - /// Recursively builds the library structure from a directory. /// /// Creates a hierarchical structure of categories and books by traversing @@ -293,19 +299,19 @@ class FileSystemData { if (!RegExp(r'^\d+$').hasMatch(bookId)) continue; String? localPath; - if (hebrewBooksPath != null ) { + if (hebrewBooksPath != null) { localPath = '$hebrewBooksPath${Platform.pathSeparator}Hebrewbooks_org_$bookId.pdf'; - if (! File(localPath).existsSync()) { - localPath = '$hebrewBooksPath${Platform.pathSeparator}$bookId.pdf'; - if (! File(localPath).existsSync()) { + if (!File(localPath).existsSync()) { + localPath = + '$hebrewBooksPath${Platform.pathSeparator}$bookId.pdf'; + if (!File(localPath).existsSync()) { localPath = null; - } + } } - } - if (localPath != null ) { + if (localPath != null) { // If local file exists, add as PdfBook books.add(PdfBook( title: row[1].toString(), @@ -535,6 +541,21 @@ class FileSystemData { return titleToPath.keys.contains(title); } + /// Clears the library cache to force a full rescan on next load. + /// Useful for development and troubleshooting. + Future clearCache() async { + try { + final cachePath = + '$libraryPath${Platform.pathSeparator}library_cache.json'; + final cacheFile = File(cachePath); + if (await cacheFile.exists()) { + await cacheFile.delete(); + } + } catch (_) { + // מתעלם משגיאות מחיקה + } + } + /// Returns true if the book belongs to Tanach (Torah, Neviim or Ketuvim). /// /// The check is performed by examining the book path and verifying that it From 588857c7bd7a6bc83f0cebc82c04f93d4e438acf Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 4 Aug 2025 01:45:33 +0300 Subject: [PATCH 065/197] =?UTF-8?q?=D7=A1=D7=93=D7=A8=20=D7=94=D7=93=D7=95?= =?UTF-8?q?=D7=A8=D7=95=D7=AA=20=D7=9C=D7=A4=D7=99=20=D7=A7=D7=95=D7=91?= =?UTF-8?q?=D7=A5=20=D7=9E=D7=AA=D7=A2=D7=93=D7=9B=D7=9F!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/utils/text_manipulation.dart | 64 ++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/lib/utils/text_manipulation.dart b/lib/utils/text_manipulation.dart index 03f94888c..731af5bb0 100644 --- a/lib/utils/text_manipulation.dart +++ b/lib/utils/text_manipulation.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:otzaria/data/data_providers/file_system_data_provider.dart'; String stripHtmlIfNeeded(String text) { @@ -30,11 +31,56 @@ String getTitleFromPath(String path) { } Future hasTopic(String title, String topic) async { + // First try to load CSV data + try { + final libraryPath = Settings.getValue('key-library-path') ?? '.'; + final csvPath = + '$libraryPath${Platform.pathSeparator}אוצריא${Platform.pathSeparator}אודות התוכנה${Platform.pathSeparator}סדר הדורות.csv'; + final csvFile = File(csvPath); + + print('DEBUG: Checking CSV for title: $title, topic: $topic'); + print('DEBUG: CSV path: $csvPath'); + print('DEBUG: CSV exists: ${await csvFile.exists()}'); + + if (await csvFile.exists()) { + final csvString = await csvFile.readAsString(); + final lines = csvString.split('\n'); + print('DEBUG: CSV has ${lines.length} lines'); + + // Skip header and search for the book + for (int i = 1; i < lines.length; i++) { + final parts = lines[i].split(','); + if (parts.isNotEmpty && parts[0].trim() == title) { + print('DEBUG: Found book in CSV: $title'); + // Found the book, check if topic matches generation or category + if (parts.length >= 3) { + final generation = parts[1].trim(); + final category = parts[2].trim(); + print('DEBUG: Book generation: $generation, category: $category'); + final result = generation == topic || category == topic; + print('DEBUG: Topic match result: $result'); + return result; + } + } + } + + // Book not found in CSV, it's "פרשנים נוספים" + print('DEBUG: Book not found in CSV, checking if topic is פרשנים נוספים'); + return topic == 'פרשנים נוספים'; + } else { + print('DEBUG: CSV file does not exist, falling back to path-based check'); + } + } catch (e) { + print('DEBUG: Error reading CSV: $e'); + // If CSV fails, fall back to path-based check + } + + // Fallback to original path-based logic + print('DEBUG: Using fallback path-based logic'); final titleToPath = await FileSystemData.instance.titleToPath; return titleToPath[title]?.contains(topic) ?? false; } - // Matches the Tetragrammaton with any Hebrew diacritics or cantillation marks. final RegExp _holyNameRegex = RegExp( r"י([\p{Mn}]*)ה([\p{Mn}]*)ו([\p{Mn}]*)ה([\p{Mn}]*)", @@ -345,22 +391,34 @@ String replaceParaphrases(String s) { Future>> splitByEra( List titles, ) async { - // יוצרים מבנה נתונים ריק לכל שלוש הקטגוריות + // יוצרים מבנה נתונים ריק לכל הקטגוריות final Map> byEra = { + 'תורה שבכתב': [], + 'חזל': [], 'ראשונים': [], 'אחרונים': [], 'מחברי זמננו': [], + 'פרשנים נוספים': [], }; // ממיינים כל פרשן לקטגוריה הראשונה שמתאימה לו for (final t in titles) { - if (await hasTopic(t, 'ראשונים')) { + if (await hasTopic(t, 'תורה שבכתב')) { + byEra['תורה שבכתב']!.add(t); + } else if (await hasTopic(t, 'חזל')) { + byEra['חזל']!.add(t); + } else if (await hasTopic(t, 'ראשונים')) { byEra['ראשונים']!.add(t); } else if (await hasTopic(t, 'אחרונים')) { byEra['אחרונים']!.add(t); } else if (await hasTopic(t, 'מחברי זמננו')) { byEra['מחברי זמננו']!.add(t); + } else { + // כל ספר שלא נמצא בקטגוריות הקודמות יוכנס ל"פרשנים נוספים" + byEra['פרשנים נוספים']!.add(t); } } + + // מחזירים את כל הקטגוריות, גם אם הן ריקות return byEra; } From 27ff36c20413ad9ddc920b2ad8a02c039cd599c9 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 4 Aug 2025 02:13:54 +0300 Subject: [PATCH 066/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=91?= =?UTF-8?q?=D7=A2=D7=99=D7=99=D7=AA=20=D7=94=D7=92=D7=9C=D7=99=D7=9C=D7=94?= =?UTF-8?q?=20=D7=91=D7=AA=D7=95=D7=A6=D7=90=D7=95=D7=AA=20=D7=97=D7=99?= =?UTF-8?q?=D7=A4=D7=95=D7=A9,=20=D7=95=D7=A2=D7=95=D7=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/bloc/search_bloc.dart | 11 + lib/search/bloc/search_event.dart | 6 + .../view/full_text_settings_widgets.dart | 16 +- lib/search/view/tantivy_full_text_search.dart | 226 ++++++++++-------- lib/search/view/tantivy_search_results.dart | 97 ++++---- 5 files changed, 196 insertions(+), 160 deletions(-) diff --git a/lib/search/bloc/search_bloc.dart b/lib/search/bloc/search_bloc.dart index ab5efa70a..ae4a5845c 100644 --- a/lib/search/bloc/search_bloc.dart +++ b/lib/search/bloc/search_bloc.dart @@ -13,6 +13,7 @@ class SearchBloc extends Bloc { on(_onUpdateSearchQuery); on(_onUpdateDistance); on(_onToggleSearchMode); + on(_onSetSearchMode); on(_onUpdateBooksToSearch); on(_onAddFacet); on(_onRemoveFacet); @@ -175,6 +176,16 @@ class SearchBloc extends Bloc { add(UpdateSearchQuery(state.searchQuery)); } + void _onSetSearchMode( + SetSearchMode event, + Emitter emit, + ) { + final newConfig = + state.configuration.copyWith(searchMode: event.searchMode); + emit(state.copyWith(configuration: newConfig)); + add(UpdateSearchQuery(state.searchQuery)); + } + void _onUpdateBooksToSearch( UpdateBooksToSearch event, Emitter emit, diff --git a/lib/search/bloc/search_event.dart b/lib/search/bloc/search_event.dart index b96e09569..b2777c771 100644 --- a/lib/search/bloc/search_event.dart +++ b/lib/search/bloc/search_event.dart @@ -1,4 +1,5 @@ import 'package:otzaria/models/books.dart'; +import 'package:otzaria/search/models/search_configuration.dart'; import 'package:search_engine/search_engine.dart'; abstract class SearchEvent { @@ -30,6 +31,11 @@ class UpdateDistance extends SearchEvent { class ToggleSearchMode extends SearchEvent {} +class SetSearchMode extends SearchEvent { + final SearchMode searchMode; + SetSearchMode(this.searchMode); +} + class UpdateBooksToSearch extends SearchEvent { final Set books; UpdateBooksToSearch(this.books); diff --git a/lib/search/view/full_text_settings_widgets.dart b/lib/search/view/full_text_settings_widgets.dart index c8bf989d3..8747f8f1b 100644 --- a/lib/search/view/full_text_settings_widgets.dart +++ b/lib/search/view/full_text_settings_widgets.dart @@ -47,7 +47,21 @@ class SearchModeToggle extends StatelessWidget { labels: const ['חיפוש מתקדם', 'חיפוש מדוייק', 'חיפוש מקורב'], radiusStyle: true, onToggle: (index) { - context.read().add(ToggleSearchMode()); + SearchMode newMode; + switch (index) { + case 0: + newMode = SearchMode.advanced; + break; + case 1: + newMode = SearchMode.exact; + break; + case 2: + newMode = SearchMode.fuzzy; + break; + default: + newMode = SearchMode.advanced; + } + context.read().add(SetSearchMode(newMode)); }, ), ); diff --git a/lib/search/view/tantivy_full_text_search.dart b/lib/search/view/tantivy_full_text_search.dart index f7ba24681..c469b7822 100644 --- a/lib/search/view/tantivy_full_text_search.dart +++ b/lib/search/view/tantivy_full_text_search.dart @@ -85,119 +85,135 @@ class _TantivyFullTextSearchState extends State Widget _buildForSmallScreens() { return BlocBuilder( builder: (context, state) { - return Column(children: [ - if (_showIndexWarning) _buildIndexWarning(), - Row( - children: [ - _buildMenuButton(), - Expanded(child: TantivySearchField(widget: widget)), - ], - ), - // השורה התחתונה - מוצגת תמיד! - _buildBottomRow(state), - _buildDivider(), - Expanded( - child: Stack( + return Container( + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), + child: Column(children: [ + if (_showIndexWarning) _buildIndexWarning(), + Row( children: [ - if (state.isLoading) - const Center(child: CircularProgressIndicator()) - else if (state.searchQuery.isEmpty) - const Center(child: Text("לא בוצע חיפוש")) - else if (state.results.isEmpty) - const Center( - child: Padding( - padding: EdgeInsets.all(8.0), - child: Text('אין תוצאות'), - )) - else - TantivySearchResults(tab: widget.tab), - ValueListenableBuilder( - valueListenable: widget.tab.isLeftPaneOpen, - builder: (context, value, child) => AnimatedSize( - duration: const Duration(milliseconds: 300), - child: SizedBox( - width: value ? 500 : 0, - child: Container( - color: Theme.of(context).colorScheme.surface, - child: Column( - children: [ - Row( - children: [ - FuzzyDistance(tab: widget.tab), - NumOfResults(tab: widget.tab), - ], - ), - SearchModeToggle(tab: widget.tab), - Expanded( - child: SearchFacetFiltering( - tab: widget.tab, - ), - ), - ], - ), - ), - ))) + _buildMenuButton(), + Expanded(child: TantivySearchField(widget: widget)), ], ), - ) - ]); + // השורה התחתונה - מוצגת תמיד! + _buildBottomRow(state), + _buildDivider(), + Expanded( + child: Stack( + children: [ + if (state.isLoading) + const Center(child: CircularProgressIndicator()) + else if (state.searchQuery.isEmpty) + const Center(child: Text("לא בוצע חיפוש")) + else if (state.results.isEmpty) + const Center( + child: Padding( + padding: EdgeInsets.all(8.0), + child: Text('אין תוצאות'), + )) + else + Container( + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), + child: TantivySearchResults(tab: widget.tab), + ), + ValueListenableBuilder( + valueListenable: widget.tab.isLeftPaneOpen, + builder: (context, value, child) => AnimatedSize( + duration: const Duration(milliseconds: 300), + child: SizedBox( + width: value ? 500 : 0, + child: Container( + color: Theme.of(context).colorScheme.surface, + child: Column( + children: [ + Row( + children: [ + FuzzyDistance(tab: widget.tab), + NumOfResults(tab: widget.tab), + ], + ), + SearchModeToggle(tab: widget.tab), + Expanded( + child: SearchFacetFiltering( + tab: widget.tab, + ), + ), + ], + ), + ), + ))) + ], + ), + ) + ]), + ); }, ); } - Column _buildForWideScreens() { - return Column(children: [ - if (_showIndexWarning) _buildIndexWarning(), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: TantivySearchField(widget: widget), - ), - FuzzyDistance(tab: widget.tab), - SearchModeToggle(tab: widget.tab) - ], - ), - Expanded( - child: BlocBuilder( - builder: (context, state) { - return Column( - children: [ - // השורה התחתונה - מוצגת תמיד! - _buildBottomRow(state), - _buildDivider(), - Expanded( - child: Row( - children: [ - ResizableFacetFiltering(tab: widget.tab), - Expanded( - child: Builder(builder: (context) { - if (state.isLoading) { - return const Center( - child: CircularProgressIndicator()); - } - if (state.searchQuery.isEmpty) { - return const Center(child: Text("לא בוצע חיפוש")); - } - if (state.results.isEmpty) { - return const Center( - child: Padding( - padding: EdgeInsets.all(8.0), - child: Text('אין תוצאות'), - )); - } - return TantivySearchResults(tab: widget.tab); - }), - ) - ], - ), - ), - ], - ); - }, + Widget _buildForWideScreens() { + return Container( + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), + child: Column(children: [ + if (_showIndexWarning) _buildIndexWarning(), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: TantivySearchField(widget: widget), + ), + FuzzyDistance(tab: widget.tab), + SearchModeToggle(tab: widget.tab) + ], ), - ) - ]); + Expanded( + child: BlocBuilder( + builder: (context, state) { + return Column( + children: [ + // השורה התחתונה - מוצגת תמיד! + _buildBottomRow(state), + _buildDivider(), + Expanded( + child: Row( + children: [ + ResizableFacetFiltering(tab: widget.tab), + Expanded( + child: Builder(builder: (context) { + if (state.isLoading) { + return const Center( + child: CircularProgressIndicator()); + } + if (state.searchQuery.isEmpty) { + return const Center(child: Text("לא בוצע חיפוש")); + } + if (state.results.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(8.0), + child: Text('אין תוצאות'), + )); + } + return Container( + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), + child: TantivySearchResults(tab: widget.tab), + ); + }), + ) + ], + ), + ), + ], + ); + }, + ), + ) + ]), + ); } Widget _buildMenuButton() { diff --git a/lib/search/view/tantivy_search_results.dart b/lib/search/view/tantivy_search_results.dart index 5bf2dd6ed..f7d54624f 100644 --- a/lib/search/view/tantivy_search_results.dart +++ b/lib/search/view/tantivy_search_results.dart @@ -360,11 +360,10 @@ class _TantivySearchResultsState extends State { } // תמיד נשתמש ב-ListView גם לתוצאה אחת - כך היא תופיע למעלה - return Align( - alignment: Alignment.topCenter, + return Container( + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), child: ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), // מונע גלילה כפולה itemCount: state.results.length, itemBuilder: (context, index) { final result = state.results[index]; @@ -372,21 +371,7 @@ class _TantivySearchResultsState extends State { builder: (context, settingsState) { String titleText = '[תוצאה ${index + 1}] ${result.reference}'; String rawHtml = result.text; - print( - '🎯 Search result: title="${result.title}", reference="${result.reference}"'); - // בואו נבדוק אם יש לתוצאה facet - נבדוק את כל השדות - try { - print('🎯 Result details:'); - print(' - title: ${result.title}'); - print(' - reference: ${result.reference}'); - print(' - segment: ${result.segment}'); - print(' - isPdf: ${result.isPdf}'); - print(' - filePath: ${result.filePath}'); - // אולי יש שדה topics או facet - print(' - toString: ${result.toString()}'); - } catch (e) { - print('🎯 Error getting result details: $e'); - } + // Debug info removed for production if (settingsState.replaceHolyNames) { titleText = utils.replaceHolyNames(titleText); rawHtml = utils.replaceHolyNames(rawHtml); @@ -414,47 +399,51 @@ class _TantivySearchResultsState extends State { availableWidth, ); - return ListTile( - leading: result.isPdf ? const Icon(Icons.picture_as_pdf) : null, - onTap: () { - if (result.isPdf) { - context.read().add(AddTab( - PdfBookTab( - book: PdfBook( - title: result.title, path: result.filePath), - pageNumber: result.segment.toInt() + 1, - searchText: widget.tab.queryController.text, - openLeftPane: - (Settings.getValue('key-pin-sidebar') ?? - false) || - (Settings.getValue( - 'key-default-sidebar-open') ?? - false), - ), - )); - } else { - context.read().add(AddTab( - TextBookTab( - book: TextBook( - title: result.title, - ), - index: result.segment.toInt(), + return Material( + clipBehavior: Clip.hardEdge, + child: ListTile( + leading: + result.isPdf ? const Icon(Icons.picture_as_pdf) : null, + onTap: () { + if (result.isPdf) { + context.read().add(AddTab( + PdfBookTab( + book: PdfBook( + title: result.title, path: result.filePath), + pageNumber: result.segment.toInt() + 1, searchText: widget.tab.queryController.text, openLeftPane: (Settings.getValue('key-pin-sidebar') ?? false) || (Settings.getValue( 'key-default-sidebar-open') ?? - false)), - )); - } - }, - title: Text(titleText), - subtitle: Text.rich( - TextSpan(children: snippetSpans), - maxLines: null, // אין הגבלה על מספר השורות! - textAlign: TextAlign.justify, - textDirection: TextDirection.rtl, + false), + ), + )); + } else { + context.read().add(AddTab( + TextBookTab( + book: TextBook( + title: result.title, + ), + index: result.segment.toInt(), + searchText: widget.tab.queryController.text, + openLeftPane: (Settings.getValue( + 'key-pin-sidebar') ?? + false) || + (Settings.getValue( + 'key-default-sidebar-open') ?? + false)), + )); + } + }, + title: Text(titleText), + subtitle: Text.rich( + TextSpan(children: snippetSpans), + maxLines: null, // אין הגבלה על מספר השורות! + textAlign: TextAlign.justify, + textDirection: TextDirection.rtl, + ), ), ); }, From 9131f86e3e13ffa0f378815b1d5697b770262626 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 4 Aug 2025 03:45:42 +0300 Subject: [PATCH 067/197] =?UTF-8?q?=D7=94=D7=A1=D7=A8=D7=AA=20=D7=9C=D7=97?= =?UTF-8?q?=D7=A6=D7=9F=20=D7=9E=D7=95=D7=A2=D7=93=D7=A4=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bookmarks/bookmarks_dialog.dart | 41 +++++++ lib/library/view/library_browser.dart | 117 ++++++++++++-------- lib/navigation/bloc/navigation_state.dart | 2 +- lib/navigation/main_window_screen.dart | 41 +++++-- lib/navigation/more_screen.dart | 32 ++++++ lib/search/view/tantivy_search_results.dart | 2 +- lib/tabs/reading_screen.dart | 51 ++++++--- 7 files changed, 211 insertions(+), 75 deletions(-) create mode 100644 lib/bookmarks/bookmarks_dialog.dart create mode 100644 lib/navigation/more_screen.dart diff --git a/lib/bookmarks/bookmarks_dialog.dart b/lib/bookmarks/bookmarks_dialog.dart new file mode 100644 index 000000000..72e4d07f1 --- /dev/null +++ b/lib/bookmarks/bookmarks_dialog.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:otzaria/bookmarks/bookmark_screen.dart'; + +class BookmarksDialog extends StatelessWidget { + const BookmarksDialog({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Dialog( + insetPadding: const EdgeInsets.all(16), + child: Container( + width: MediaQuery.of(context).size.width * 0.8, + height: MediaQuery.of(context).size.height * 0.8, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'סימניות', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const SizedBox(height: 16), + const Expanded(child: BookmarkView()), + ], + ), + ), + ); + } +} diff --git a/lib/library/view/library_browser.dart b/lib/library/view/library_browser.dart index a912db74a..f15591b75 100644 --- a/lib/library/view/library_browser.dart +++ b/lib/library/view/library_browser.dart @@ -27,6 +27,10 @@ import 'package:otzaria/widgets/filter_list/src/theme/filter_list_theme.dart'; import 'package:otzaria/library/view/grid_items.dart'; import 'package:otzaria/library/view/otzar_book_dialog.dart'; import 'package:otzaria/workspaces/view/workspace_switcher_dialog.dart'; +import 'package:otzaria/history/history_dialog.dart'; +import 'package:otzaria/history/bloc/history_bloc.dart'; +import 'package:otzaria/history/bloc/history_event.dart'; +import 'package:otzaria/bookmarks/bookmarks_dialog.dart'; class LibraryBrowser extends StatefulWidget { const LibraryBrowser({Key? key}) : super(key: key); @@ -118,6 +122,16 @@ class _LibraryBrowserState extends State onPressed: () => _showSwitchWorkspaceDialog(context), ), + IconButton( + icon: const Icon(Icons.history), + tooltip: 'הצג היסטוריה', + onPressed: () => _showHistoryDialog(context), + ), + IconButton( + icon: const Icon(Icons.bookmark), + tooltip: 'הצג מועדפים', + onPressed: () => _showBookmarksDialog(context), + ), ], ), ), @@ -184,9 +198,8 @@ class _LibraryBrowserState extends State Expanded( child: TextField( controller: focusRepository.librarySearchController, - focusNode: context - .read() - .librarySearchFocusNode, + focusNode: + context.read().librarySearchFocusNode, autofocus: true, decoration: InputDecoration( constraints: const BoxConstraints(maxWidth: 400), @@ -253,9 +266,8 @@ class _LibraryBrowserState extends State final allTopics = _getAllTopics(state.searchResults!); - final relevantTopics = categoryTopics - .where((element) => allTopics.contains(element)) - .toList(); + final relevantTopics = + categoryTopics.where((element) => allTopics.contains(element)).toList(); return FilterListWidget( hideSearchField: true, @@ -279,13 +291,11 @@ class _LibraryBrowserState extends State padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2), child: Chip( label: Text(item), - backgroundColor: isSelected! - ? Theme.of(context).colorScheme.secondary - : null, + backgroundColor: + isSelected! ? Theme.of(context).colorScheme.secondary : null, labelStyle: TextStyle( - color: isSelected - ? Theme.of(context).colorScheme.onSecondary - : null, + color: + isSelected ? Theme.of(context).colorScheme.onSecondary : null, fontSize: 11, ), labelPadding: const EdgeInsets.all(0), @@ -421,28 +431,30 @@ class _LibraryBrowserState extends State void _openBook(Book book) { if (book is PdfBook) { context.read().add( - AddTab( - PdfBookTab( - book: book, - pageNumber: 1, - openLeftPane: - (Settings.getValue('key-pin-sidebar') ?? false) || - (Settings.getValue('key-default-sidebar-open') ?? false), - ), - ), - ); + AddTab( + PdfBookTab( + book: book, + pageNumber: 1, + openLeftPane: + (Settings.getValue('key-pin-sidebar') ?? false) || + (Settings.getValue('key-default-sidebar-open') ?? + false), + ), + ), + ); } else if (book is TextBook) { context.read().add( - AddTab( - TextBookTab( - book: book, - index: 0, - openLeftPane: - (Settings.getValue('key-pin-sidebar') ?? false) || - (Settings.getValue('key-default-sidebar-open') ?? false), - ), - ), - ); + AddTab( + TextBookTab( + book: book, + index: 0, + openLeftPane: + (Settings.getValue('key-pin-sidebar') ?? false) || + (Settings.getValue('key-default-sidebar-open') ?? + false), + ), + ), + ); } context.read().add(const NavigateToScreen(Screen.reading)); } @@ -485,8 +497,8 @@ class _LibraryBrowserState extends State onChanged: (bool? value) { setState(() { context.read().add( - UpdateShowOtzarHachochma(value!), - ); + UpdateShowOtzarHachochma(value!), + ); _update(context, state, settingsState); }); }, @@ -497,8 +509,8 @@ class _LibraryBrowserState extends State onChanged: (bool? value) { setState(() { context.read().add( - UpdateShowHebrewBooks(value!), - ); + UpdateShowHebrewBooks(value!), + ); _update(context, state, settingsState); }); }, @@ -525,16 +537,16 @@ class _LibraryBrowserState extends State SettingsState settingsState, ) { context.read().add( - UpdateSearchQuery( - context.read().librarySearchController.text, - ), - ); + UpdateSearchQuery( + context.read().librarySearchController.text, + ), + ); context.read().add( - SearchBooks( - showHebrewBooks: settingsState.showHebrewBooks, - showOtzarHachochma: settingsState.showOtzarHachochma, - ), - ); + SearchBooks( + showHebrewBooks: settingsState.showHebrewBooks, + showOtzarHachochma: settingsState.showOtzarHachochma, + ), + ); setState(() {}); _refocusSearchBar(); } @@ -543,4 +555,19 @@ class _LibraryBrowserState extends State final focusRepository = context.read(); focusRepository.requestLibrarySearchFocus(selectAll: selectAll); } + + void _showHistoryDialog(BuildContext context) { + context.read().add(FlushHistory()); + showDialog( + context: context, + builder: (context) => const HistoryDialog(), + ); + } + + void _showBookmarksDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => const BookmarksDialog(), + ); + } } diff --git a/lib/navigation/bloc/navigation_state.dart b/lib/navigation/bloc/navigation_state.dart index 6961b295c..6b0e56522 100644 --- a/lib/navigation/bloc/navigation_state.dart +++ b/lib/navigation/bloc/navigation_state.dart @@ -1,6 +1,6 @@ import 'package:equatable/equatable.dart'; -enum Screen { library, find, reading, search, favorites, settings } +enum Screen { library, find, reading, search, more, settings } class NavigationState extends Equatable { final Screen currentScreen; diff --git a/lib/navigation/main_window_screen.dart b/lib/navigation/main_window_screen.dart index b20721951..823834969 100644 --- a/lib/navigation/main_window_screen.dart +++ b/lib/navigation/main_window_screen.dart @@ -7,7 +7,7 @@ import 'package:otzaria/indexing/bloc/indexing_event.dart'; import 'package:otzaria/navigation/bloc/navigation_bloc.dart'; import 'package:otzaria/navigation/bloc/navigation_event.dart'; import 'package:otzaria/navigation/bloc/navigation_state.dart'; -import 'package:otzaria/navigation/favoriets_screen.dart'; + import 'package:otzaria/settings/settings_bloc.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:otzaria/tabs/bloc/tabs_bloc.dart'; @@ -18,6 +18,7 @@ import 'package:otzaria/find_ref/find_ref_screen.dart'; import 'package:otzaria/library/view/library_browser.dart'; import 'package:otzaria/tabs/reading_screen.dart'; import 'package:otzaria/settings/settings_screen.dart'; +import 'package:otzaria/navigation/more_screen.dart'; import 'package:otzaria/widgets/keyboard_shortcuts.dart'; import 'package:otzaria/update/my_updat_widget.dart'; @@ -38,7 +39,7 @@ class MainWindowScreenState extends State KeepAlivePage(child: FindRefScreen()), KeepAlivePage(child: ReadingScreen()), KeepAlivePage(child: SizedBox.shrink()), - KeepAlivePage(child: FavouritesScreen()), + KeepAlivePage(child: MoreScreen()), KeepAlivePage(child: MySettingsScreen()), ]; @@ -132,8 +133,8 @@ class MainWindowScreenState extends State label: 'חיפוש', ), const NavigationDestination( - icon: Icon(Icons.star), - label: 'מועדפים', + icon: Icon(Icons.more_horiz), + label: 'עוד', ), const NavigationDestination( icon: Icon(Icons.settings), @@ -157,10 +158,14 @@ class MainWindowScreenState extends State ); } if (state.currentScreen == Screen.library) { - context.read().requestLibrarySearchFocus(selectAll: true); + context + .read() + .requestLibrarySearchFocus(selectAll: true); } else if (state.currentScreen == Screen.find) { - context.read().requestFindRefSearchFocus(selectAll: true); - } + context + .read() + .requestFindRefSearchFocus(selectAll: true); + } } } @@ -234,10 +239,16 @@ class MainWindowScreenState extends State Screen.values[index])); } if (index == Screen.library.index) { - context.read().requestLibrarySearchFocus(selectAll: true); + context + .read() + .requestLibrarySearchFocus( + selectAll: true); } if (index == Screen.find.index) { - context.read().requestFindRefSearchFocus(selectAll: true); + context + .read() + .requestFindRefSearchFocus( + selectAll: true); } }, ), @@ -261,10 +272,16 @@ class MainWindowScreenState extends State NavigateToScreen(Screen.values[index])); } if (index == Screen.library.index) { - context.read().requestLibrarySearchFocus(selectAll: true); + context + .read() + .requestLibrarySearchFocus( + selectAll: true); } if (index == Screen.find.index) { - context.read().requestFindRefSearchFocus(selectAll: true); + context + .read() + .requestFindRefSearchFocus( + selectAll: true); } }, ), @@ -323,4 +340,4 @@ class _KeepAlivePageState extends State super.build(context); return widget.child; } -} \ No newline at end of file +} diff --git a/lib/navigation/more_screen.dart b/lib/navigation/more_screen.dart new file mode 100644 index 000000000..bfba8ce37 --- /dev/null +++ b/lib/navigation/more_screen.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +class MoreScreen extends StatelessWidget { + const MoreScreen({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.more_horiz, + size: 64, + color: Colors.grey, + ), + SizedBox(height: 16), + Text( + 'בקרוב...', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/search/view/tantivy_search_results.dart b/lib/search/view/tantivy_search_results.dart index f7d54624f..5080d36d1 100644 --- a/lib/search/view/tantivy_search_results.dart +++ b/lib/search/view/tantivy_search_results.dart @@ -393,7 +393,7 @@ class _TantivySearchResultsState extends State { TextStyle( fontSize: settingsState.fontSize, fontFamily: settingsState.fontFamily, - color: Colors.red, + color: const Color(0xFFD32F2F), // צבע אדום חזק יותר fontWeight: FontWeight.bold, ), availableWidth, diff --git a/lib/tabs/reading_screen.dart b/lib/tabs/reading_screen.dart index 3de3e689b..278407f5a 100644 --- a/lib/tabs/reading_screen.dart +++ b/lib/tabs/reading_screen.dart @@ -19,9 +19,9 @@ import 'package:otzaria/text_book/view/text_book_screen.dart'; import 'package:otzaria/utils/text_manipulation.dart'; import 'package:otzaria/workspaces/view/workspace_switcher_dialog.dart'; import 'package:otzaria/history/history_dialog.dart'; +import 'package:otzaria/bookmarks/bookmarks_dialog.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; - class ReadingScreen extends StatefulWidget { const ReadingScreen({Key? key}) : super(key: key); @@ -58,11 +58,14 @@ class _ReadingScreenState extends State Widget build(BuildContext context) { return BlocListener( listener: (context, state) { - if(state.hasOpenTabs) { - context.read().add(CaptureStateForHistory(state.currentTab!)); + if (state.hasOpenTabs) { + context + .read() + .add(CaptureStateForHistory(state.currentTab!)); } }, - listenWhen: (previous, current) => previous.currentTabIndex != current.currentTabIndex, + listenWhen: (previous, current) => + previous.currentTabIndex != current.currentTabIndex, child: BlocBuilder( builder: (context, state) { if (!state.hasOpenTabs) { @@ -94,6 +97,15 @@ class _ReadingScreenState extends State child: const Text('הצג היסטוריה'), ), ), + Padding( + padding: const EdgeInsets.all(8.0), + child: TextButton( + onPressed: () { + _showBookmarksDialog(context); + }, + child: const Text('הצג מועדפים'), + ), + ), Padding( padding: const EdgeInsets.all(8.0), child: TextButton( @@ -107,7 +119,7 @@ class _ReadingScreenState extends State ), ); } - + return Builder( builder: (context) { final controller = TabController( @@ -115,19 +127,18 @@ class _ReadingScreenState extends State vsync: this, initialIndex: state.currentTabIndex, ); - + controller.addListener(() { if (controller.indexIsChanging && state.currentTabIndex < state.tabs.length) { - context - .read() - .add(CaptureStateForHistory(state.tabs[state.currentTabIndex])); + context.read().add(CaptureStateForHistory( + state.tabs[state.currentTabIndex])); } if (controller.index != state.currentTabIndex) { context.read().add(SetCurrentTab(controller.index)); } }); - + return Scaffold( appBar: AppBar( title: Container( @@ -188,8 +199,8 @@ class _ReadingScreenState extends State } return const SizedBox.shrink(); } - - Widget _buildTab(BuildContext context, OpenedTab tab, TabsState state) { + + Widget _buildTab(BuildContext context, OpenedTab tab, TabsState state) { return Listener( onPointerDown: (PointerDownEvent event) { if (event.buttons == 4) { @@ -283,9 +294,10 @@ class _ReadingScreenState extends State child: Text(truncate(tab.title, 12))), Tooltip( preferBelow: false, - message: (Settings.getValue('key-shortcut-close-tab') ?? - 'ctrl+w') - .toUpperCase(), + message: + (Settings.getValue('key-shortcut-close-tab') ?? + 'ctrl+w') + .toUpperCase(), child: IconButton( onPressed: () => closeTab(tab, context), icon: const Icon(Icons.close, size: 10), @@ -352,4 +364,11 @@ class _ReadingScreenState extends State builder: (context) => const HistoryDialog(), ); } -} \ No newline at end of file + + void _showBookmarksDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => const BookmarksDialog(), + ); + } +} From 3f8a2b29fba259f5e6f0d8c1bf339a24c234003b Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 4 Aug 2025 10:39:26 +0300 Subject: [PATCH 068/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=9B?= =?UTF-8?q?=D7=A4=D7=AA=D7=95=D7=A8=20"=D7=A2=D7=95=D7=93",=20=D7=95=D7=94?= =?UTF-8?q?=D7=AA=D7=97=D7=9C=D7=AA=20=D7=A4=D7=95=D7=A0=D7=A7=D7=A6=D7=99?= =?UTF-8?q?=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/navigation/more_screen.dart | 105 +++++++++++++++++++++++++++----- 1 file changed, 90 insertions(+), 15 deletions(-) diff --git a/lib/navigation/more_screen.dart b/lib/navigation/more_screen.dart index bfba8ce37..2cdfbd84b 100644 --- a/lib/navigation/more_screen.dart +++ b/lib/navigation/more_screen.dart @@ -5,28 +5,103 @@ class MoreScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return const Scaffold( - body: Center( + return Scaffold( + appBar: AppBar( + title: const Text('עוד'), + centerTitle: true, + ), + body: Padding( + padding: const EdgeInsets.all(16), child: Column( - mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, // מיושר לימין children: [ - Icon( - Icons.more_horiz, - size: 64, - color: Colors.grey, + _buildToolItem( + context, + icon: Icons.calendar_today, + title: 'לוח שנה', + subtitle: 'לוח שנה עברי ולועזי', + onTap: () => _showComingSoon(context, 'לוח שנה'), ), - SizedBox(height: 16), - Text( - 'בקרוב...', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.grey, - ), + const SizedBox(height: 16), + _buildToolItem( + context, + icon: Icons.straighten, + title: 'ממיר מידות', + subtitle: 'המרת מידות ומשקולות', + onTap: () => _showComingSoon(context, 'ממיר מידות ומשקולות'), + ), + const SizedBox(height: 16), + _buildToolItem( + context, + icon: Icons.calculate, + title: 'גימטריות', + subtitle: 'חישובי גימטריה', + onTap: () => _showComingSoon(context, 'גימטריות'), ), ], ), ), ); } + + /// כרטיס קטן מיושר לימין + Widget _buildToolItem( + BuildContext context, { + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap, + }) { + return Align( + alignment: Alignment.centerRight, // מצמיד לימין + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + width: 110, // רוחב צר - כמו הסרגל הצדדי + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade300), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 32, color: Theme.of(context).primaryColor), + const SizedBox(height: 8), + Text( + title, + style: + const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); + } + + void _showComingSoon(BuildContext context, String feature) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text(feature), + content: const Text('בקרוב...'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('אישור'), + ), + ], + ), + ); + } } From 73982e0d896e497820ddb612fb91afa7cd3c7019 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 4 Aug 2025 14:38:32 +0300 Subject: [PATCH 069/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=94?= =?UTF-8?q?=D7=99=D7=A1=D7=98=D7=95=D7=A8=D7=99=D7=94=20=D7=95=D7=9E=D7=95?= =?UTF-8?q?=D7=A2=D7=93=D7=A4=D7=99=D7=9D=20=D7=9C=D7=9E=D7=A1=D7=9A=20?= =?UTF-8?q?=D7=A2=D7=99=D7=95=D7=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/tabs/reading_screen.dart | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/lib/tabs/reading_screen.dart b/lib/tabs/reading_screen.dart index 278407f5a..42c29ee53 100644 --- a/lib/tabs/reading_screen.dart +++ b/lib/tabs/reading_screen.dart @@ -152,10 +152,26 @@ class _ReadingScreenState extends State .toList(), ), ), - leading: IconButton( - icon: const Icon(Icons.add_to_queue), - tooltip: 'החלף שולחן עבודה', - onPressed: () => _showSaveWorkspaceDialog(context), + leadingWidth: 150, + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.add_to_queue), + tooltip: 'החלף שולחן עבודה', + onPressed: () => _showSaveWorkspaceDialog(context), + ), + IconButton( + icon: const Icon(Icons.history), + tooltip: 'הצג היסטוריה', + onPressed: () => _showHistoryDialog(context), + ), + IconButton( + icon: const Icon(Icons.bookmark), + tooltip: 'הצג מועדפים', + onPressed: () => _showBookmarksDialog(context), + ), + ], ), ), body: SizedBox.fromSize( From 2986c35425c6b8f8d1abe001d508ac1b03d12bcf Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 4 Aug 2025 15:50:38 +0300 Subject: [PATCH 070/197] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20=D7=9C?= =?UTF-8?q?=D7=95=D7=97=20=D7=94=D7=A9=D7=A0=D7=94,=20=D7=95=D7=A2=D7=93?= =?UTF-8?q?=D7=9B=D7=95=D7=9F=20=D7=9B=D7=9E=D7=94=20=D7=97=D7=91=D7=99?= =?UTF-8?q?=D7=9C=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/navigation/calendar_cubit.dart | 287 +++++++++ lib/navigation/calendar_widget.dart | 546 ++++++++++++++++++ lib/navigation/more_screen.dart | 139 ++++- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 32 +- pubspec.yaml | 14 +- 6 files changed, 986 insertions(+), 34 deletions(-) create mode 100644 lib/navigation/calendar_cubit.dart create mode 100644 lib/navigation/calendar_widget.dart diff --git a/lib/navigation/calendar_cubit.dart b/lib/navigation/calendar_cubit.dart new file mode 100644 index 000000000..a9d360c7a --- /dev/null +++ b/lib/navigation/calendar_cubit.dart @@ -0,0 +1,287 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:kosher_dart/kosher_dart.dart'; +import 'package:flutter/material.dart'; + +enum CalendarType { hebrew, gregorian, combined } + +// Calendar State +class CalendarState extends Equatable { + final JewishDate selectedJewishDate; + final DateTime selectedGregorianDate; + final String selectedCity; + final Map dailyTimes; + final JewishDate currentJewishDate; + final DateTime currentGregorianDate; + final CalendarType calendarType; + + const CalendarState({ + required this.selectedJewishDate, + required this.selectedGregorianDate, + required this.selectedCity, + required this.dailyTimes, + required this.currentJewishDate, + required this.currentGregorianDate, + required this.calendarType, + }); + + factory CalendarState.initial() { + final now = DateTime.now(); + final jewishNow = JewishDate(); + + return CalendarState( + selectedJewishDate: jewishNow, + selectedGregorianDate: now, + selectedCity: 'ירושלים', + dailyTimes: const {}, + currentJewishDate: jewishNow, + currentGregorianDate: now, + calendarType: CalendarType.combined, + ); + } + + CalendarState copyWith({ + JewishDate? selectedJewishDate, + DateTime? selectedGregorianDate, + String? selectedCity, + Map? dailyTimes, + JewishDate? currentJewishDate, + DateTime? currentGregorianDate, + CalendarType? calendarType, + }) { + return CalendarState( + selectedJewishDate: selectedJewishDate ?? this.selectedJewishDate, + selectedGregorianDate: selectedGregorianDate ?? this.selectedGregorianDate, + selectedCity: selectedCity ?? this.selectedCity, + dailyTimes: dailyTimes ?? this.dailyTimes, + currentJewishDate: currentJewishDate ?? this.currentJewishDate, + currentGregorianDate: currentGregorianDate ?? this.currentGregorianDate, + calendarType: calendarType ?? this.calendarType, + ); + } + + + @override + List get props => [ + selectedJewishDate.getJewishYear(), + selectedJewishDate.getJewishMonth(), + selectedJewishDate.getJewishDayOfMonth(), + + selectedGregorianDate, + selectedCity, + dailyTimes, + + // "פירקנו" גם את התאריך של תצוגת החודש + currentJewishDate.getJewishYear(), + currentJewishDate.getJewishMonth(), + currentJewishDate.getJewishDayOfMonth(), + + currentGregorianDate, + calendarType + ]; +} + +// Calendar Cubit +class CalendarCubit extends Cubit { + CalendarCubit() : super(CalendarState.initial()) { + _updateTimesForDate(state.selectedGregorianDate, state.selectedCity); + } + + void _updateTimesForDate(DateTime date, String city) { + final newTimes = _calculateDailyTimes(date, city); + emit(state.copyWith(dailyTimes: newTimes)); + } + + void selectDate(JewishDate jewishDate, DateTime gregorianDate) { + final newTimes = _calculateDailyTimes(gregorianDate, state.selectedCity); + emit(state.copyWith( + selectedJewishDate: jewishDate, + selectedGregorianDate: gregorianDate, + dailyTimes: newTimes, + )); + } + + void changeCity(String newCity) { + final newTimes = _calculateDailyTimes(state.selectedGregorianDate, newCity); + emit(state.copyWith( + selectedCity: newCity, + dailyTimes: newTimes, + )); + } + + void previousMonth() { + if (state.calendarType == CalendarType.gregorian) { + final current = state.currentGregorianDate; + final newDate = current.month == 1 + ? DateTime(current.year - 1, 12, 1) + : DateTime(current.year, current.month - 1, 1); + emit(state.copyWith(currentGregorianDate: newDate)); + } else { + final current = state.currentJewishDate; + final newJewishDate = JewishDate(); + if (current.getJewishMonth() == 1) { + newJewishDate.setJewishDate( + current.getJewishYear() - 1, + 12, + 1, + ); + } else { + newJewishDate.setJewishDate( + current.getJewishYear(), + current.getJewishMonth() - 1, + 1, + ); + } + emit(state.copyWith(currentJewishDate: newJewishDate)); + } + } + + void nextMonth() { + if (state.calendarType == CalendarType.gregorian) { + final current = state.currentGregorianDate; + final newDate = current.month == 12 + ? DateTime(current.year + 1, 1, 1) + : DateTime(current.year, current.month + 1, 1); + emit(state.copyWith(currentGregorianDate: newDate)); + } else { + final current = state.currentJewishDate; + final newJewishDate = JewishDate(); + if (current.getJewishMonth() == 12) { + newJewishDate.setJewishDate( + current.getJewishYear() + 1, + 1, + 1, + ); + } else { + newJewishDate.setJewishDate( + current.getJewishYear(), + current.getJewishMonth() + 1, + 1, + ); + } + emit(state.copyWith(currentJewishDate: newJewishDate)); + } + } + + void changeCalendarType(CalendarType type) { + emit(state.copyWith(calendarType: type)); + } +} + +// City coordinates map +const Map> cityCoordinates = { + 'ירושלים': {'lat': 31.7683, 'lng': 35.2137, 'elevation': 800.0}, + 'תל אביב': {'lat': 32.0853, 'lng': 34.7818, 'elevation': 5.0}, + 'חיפה': {'lat': 32.7940, 'lng': 34.9896, 'elevation': 30.0}, + 'באר שבע': {'lat': 31.2518, 'lng': 34.7915, 'elevation': 280.0}, + 'נתניה': {'lat': 32.3215, 'lng': 34.8532, 'elevation': 30.0}, + 'אשדוד': {'lat': 31.8044, 'lng': 34.6553, 'elevation': 50.0}, + 'פתח תקווה': {'lat': 32.0870, 'lng': 34.8873, 'elevation': 80.0}, + 'בני ברק': {'lat': 32.0809, 'lng': 34.8338, 'elevation': 50.0}, + 'מודיעין עילית': {'lat': 31.9254, 'lng': 35.0364, 'elevation': 400.0}, + 'צפת': {'lat': 32.9650, 'lng': 35.4951, 'elevation': 900.0}, + 'טבריה': {'lat': 32.7940, 'lng': 35.5308, 'elevation': -200.0}, + 'אילת': {'lat': 29.5581, 'lng': 34.9482, 'elevation': 12.0}, + 'רחובות': {'lat': 31.8947, 'lng': 34.8096, 'elevation': 89.0}, + 'הרצליה': {'lat': 32.1624, 'lng': 34.8443, 'elevation': 40.0}, + 'רמת גן': {'lat': 32.0719, 'lng': 34.8244, 'elevation': 80.0}, + 'חולון': {'lat': 32.0117, 'lng': 34.7689, 'elevation': 54.0}, + 'בת ים': {'lat': 32.0167, 'lng': 34.7500, 'elevation': 5.0}, + 'רמלה': {'lat': 31.9297, 'lng': 34.8667, 'elevation': 108.0}, + 'לוד': {'lat': 31.9516, 'lng': 34.8958, 'elevation': 50.0}, + 'אשקלון': {'lat': 31.6688, 'lng': 34.5742, 'elevation': 50.0}, +}; + +// Calculate daily times function +Map _calculateDailyTimes(DateTime date, String city) { + final targetDate = date; + final isSummer = targetDate.month >= 4 && targetDate.month <= 9; + + print('Calculating times for date: ${targetDate.day}/${targetDate.month}/${targetDate.year}, city: $city'); + + final dayOfYear = targetDate.difference(DateTime(targetDate.year, 1, 1)).inDays; + final seasonalAdjustment = _getSeasonalAdjustment(dayOfYear); + + Map baseTimes; + final cityData = cityCoordinates[city]!; + final isJerusalem = city == 'ירושלים'; + + if (isJerusalem) { + baseTimes = isSummer + ? { + 'alos': _adjustTime('04:20', seasonalAdjustment), + 'sunrise': _adjustTime('05:45', seasonalAdjustment), + 'sofZmanShma': _adjustTime('09:00', seasonalAdjustment), + 'sofZmanTfila': _adjustTime('10:15', seasonalAdjustment), + 'chatzos': _adjustTime('12:45', seasonalAdjustment), + 'minchaGedola': _adjustTime('13:30', seasonalAdjustment), + 'minchaKetana': _adjustTime('17:15', seasonalAdjustment), + 'plagHamincha': _adjustTime('18:30', seasonalAdjustment), + 'sunset': _adjustTime('19:45', seasonalAdjustment), + 'tzais': _adjustTime('20:30', seasonalAdjustment), + } + : { + 'alos': _adjustTime('05:45', seasonalAdjustment), + 'sunrise': _adjustTime('06:30', seasonalAdjustment), + 'sofZmanShma': _adjustTime('09:15', seasonalAdjustment), + 'sofZmanTfila': _adjustTime('10:00', seasonalAdjustment), + 'chatzos': _adjustTime('12:00', seasonalAdjustment), + 'minchaGedola': _adjustTime('12:30', seasonalAdjustment), + 'minchaKetana': _adjustTime('15:00', seasonalAdjustment), + 'plagHamincha': _adjustTime('16:15', seasonalAdjustment), + 'sunset': _adjustTime('17:30', seasonalAdjustment), + 'tzais': _adjustTime('18:15', seasonalAdjustment), + }; + } else { + final latAdjustment = ((cityData['lat']! - 31.7683) * 2).round(); + baseTimes = isSummer + ? { + 'alos': _adjustTime('04:30', seasonalAdjustment + latAdjustment), + 'sunrise': _adjustTime('05:50', seasonalAdjustment + latAdjustment), + 'sofZmanShma': _adjustTime('09:10', seasonalAdjustment + latAdjustment), + 'sofZmanTfila': _adjustTime('10:20', seasonalAdjustment + latAdjustment), + 'chatzos': _adjustTime('12:50', seasonalAdjustment + latAdjustment), + 'minchaGedola': _adjustTime('13:35', seasonalAdjustment + latAdjustment), + 'minchaKetana': _adjustTime('17:20', seasonalAdjustment + latAdjustment), + 'plagHamincha': _adjustTime('18:35', seasonalAdjustment + latAdjustment), + 'sunset': _adjustTime('19:50', seasonalAdjustment + latAdjustment), + 'tzais': _adjustTime('20:35', seasonalAdjustment + latAdjustment), + } + : { + 'alos': _adjustTime('05:50', seasonalAdjustment + latAdjustment), + 'sunrise': _adjustTime('06:35', seasonalAdjustment + latAdjustment), + 'sofZmanShma': _adjustTime('09:20', seasonalAdjustment + latAdjustment), + 'sofZmanTfila': _adjustTime('10:05', seasonalAdjustment + latAdjustment), + 'chatzos': _adjustTime('12:05', seasonalAdjustment + latAdjustment), + 'minchaGedola': _adjustTime('12:35', seasonalAdjustment + latAdjustment), + 'minchaKetana': _adjustTime('15:05', seasonalAdjustment + latAdjustment), + 'plagHamincha': _adjustTime('16:20', seasonalAdjustment + latAdjustment), + 'sunset': _adjustTime('17:35', seasonalAdjustment + latAdjustment), + 'tzais': _adjustTime('18:20', seasonalAdjustment + latAdjustment), + }; + } + + return baseTimes; +} + +int _getSeasonalAdjustment(int dayOfYear) { + if (dayOfYear < 80 || dayOfYear > 300) { + return -15; // Winter - earlier times + } else if (dayOfYear > 120 && dayOfYear < 260) { + return 15; // Summer - later times + } else { + return 0; // Spring/Fall + } +} + +String _adjustTime(String timeStr, int adjustmentMinutes) { + final parts = timeStr.split(':'); + final hour = int.parse(parts[0]); + final minute = int.parse(parts[1]); + + final totalMinutes = hour * 60 + minute + adjustmentMinutes; + final adjustedHour = (totalMinutes ~/ 60) % 24; + final adjustedMinute = totalMinutes % 60; + + return '${adjustedHour.toString().padLeft(2, '0')}:${adjustedMinute.toString().padLeft(2, '0')}'; +} \ No newline at end of file diff --git a/lib/navigation/calendar_widget.dart b/lib/navigation/calendar_widget.dart new file mode 100644 index 000000000..c404690fe --- /dev/null +++ b/lib/navigation/calendar_widget.dart @@ -0,0 +1,546 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:kosher_dart/kosher_dart.dart'; +import 'calendar_cubit.dart'; // ודא שהנתיב נכון + +// הפכנו את הווידג'ט ל-Stateless כי הוא כבר לא מנהל מצב בעצמו. +class CalendarWidget extends StatelessWidget { + const CalendarWidget({super.key}); + + // העברנו את רשימות הקבועים לכאן כדי שיהיו זמינים + final List hebrewMonths = const [ + 'ניסן', 'אייר', 'סיון', 'תמוז', 'אב', 'אלול', + 'תשרי', 'חשון', 'כסלו', 'טבת', 'שבט', 'אדר' + ]; + + final List hebrewDays = const [ + 'ראשון', 'שני', 'שלישי', 'רביעי', 'חמישי', 'שישי', 'שבת' + ]; + + @override + Widget build(BuildContext context) { + // BlocBuilder מאזין לשינויים ב-Cubit ובונה מחדש את הממשק בכל פעם שהמצב משתנה + return BlocBuilder( + builder: (context, state) { + return Scaffold( + // אין צורך ב-AppBar כאן אם הוא מגיע ממסך האב + body: LayoutBuilder( + builder: (context, constraints) { + final isWideScreen = constraints.maxWidth > 800; + if (isWideScreen) { + return _buildWideScreenLayout(context, state); + } else { + return _buildNarrowScreenLayout(context, state); + } + }, + ), + ); + }, + ); + } + + // כל הפונקציות מקבלות כעת את context ואת state + Widget _buildWideScreenLayout(BuildContext context, CalendarState state) { + return Row( + children: [ + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + _buildCalendar(context, state), + const SizedBox(height: 16), + Expanded(child: _buildEventsCard(context, state)), + ], + ), + ), + ), + Expanded( + flex: 1, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: _buildDayDetailsWithoutEvents(context, state), + ), + ), + ], + ); + } + + Widget _buildNarrowScreenLayout(BuildContext context, CalendarState state) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + _buildCalendar(context, state), + const SizedBox(height: 16), + _buildEventsCard(context, state), + const SizedBox(height: 16), + _buildDayDetailsWithoutEvents(context, state), + ], + ), + ); + } + + Widget _buildCalendar(BuildContext context, CalendarState state) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + _buildMonthYearSelector(context, state), + const SizedBox(height: 16), + _buildCalendarGrid(context, state), + ], + ), + ), + ); + } + + Widget _buildMonthYearSelector(BuildContext context, CalendarState state) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + onPressed: () => context.read().previousMonth(), + icon: const Icon(Icons.chevron_left), + ), + Text( + _getCurrentMonthYearText(state), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + IconButton( + onPressed: () => context.read().nextMonth(), + icon: const Icon(Icons.chevron_right), + ), + ], + ); + } + + Widget _buildCalendarGrid(BuildContext context, CalendarState state) { + return Column( + children: [ + Row( + children: hebrewDays + .map((day) => Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + day, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + )) + .toList(), + ), + const SizedBox(height: 8), + _buildCalendarDays(context, state), + ], + ); + } + + Widget _buildCalendarDays(BuildContext context, CalendarState state) { + if (state.calendarType == CalendarType.gregorian) { + return _buildGregorianCalendarDays(context, state); + } else { + return _buildHebrewCalendarDays(context, state); + } + } + + Widget _buildHebrewCalendarDays(BuildContext context, CalendarState state) { + final currentJewishDate = state.currentJewishDate; + final daysInMonth = currentJewishDate.getDaysInJewishMonth(); + final firstDayOfMonth = JewishDate(); + firstDayOfMonth.setJewishDate( + currentJewishDate.getJewishYear(), + currentJewishDate.getJewishMonth(), + 1, + ); + final startingWeekday = firstDayOfMonth.getGregorianCalendar().weekday % 7; + + List dayWidgets = List.generate(startingWeekday, (_) => const SizedBox()); + + for (int day = 1; day <= daysInMonth; day++) { + dayWidgets.add(_buildHebrewDayCell(context, state, day)); + } + + List rows = []; + for (int i = 0; i < dayWidgets.length; i += 7) { + final rowWidgets = dayWidgets.sublist(i, i + 7 > dayWidgets.length ? dayWidgets.length : i + 7); + while(rowWidgets.length < 7) { + rowWidgets.add(const SizedBox()); + } + rows.add(Row(children: rowWidgets.map((w) => Expanded(child: w)).toList())); + } + + return Column(children: rows); + } + + Widget _buildGregorianCalendarDays(BuildContext context, CalendarState state) { + final currentGregorianDate = state.currentGregorianDate; + final firstDayOfMonth = DateTime(currentGregorianDate.year, currentGregorianDate.month, 1); + final lastDayOfMonth = DateTime(currentGregorianDate.year, currentGregorianDate.month + 1, 0); + final daysInMonth = lastDayOfMonth.day; + final startingWeekday = firstDayOfMonth.weekday % 7; + + List dayWidgets = List.generate(startingWeekday, (_) => const SizedBox()); + + for (int day = 1; day <= daysInMonth; day++) { + dayWidgets.add(_buildGregorianDayCell(context, state, day)); + } + + List rows = []; + for (int i = 0; i < dayWidgets.length; i += 7) { + final rowWidgets = dayWidgets.sublist(i, i + 7 > dayWidgets.length ? dayWidgets.length : i + 7); + while(rowWidgets.length < 7) { + rowWidgets.add(const SizedBox()); + } + rows.add(Row(children: rowWidgets.map((w) => Expanded(child: w)).toList())); + } + + return Column(children: rows); + } + + Widget _buildHebrewDayCell(BuildContext context, CalendarState state, int day) { + final jewishDate = JewishDate(); + jewishDate.setJewishDate( + state.currentJewishDate.getJewishYear(), + state.currentJewishDate.getJewishMonth(), + day, + ); + final gregorianDate = jewishDate.getGregorianCalendar(); + + final isSelected = state.selectedJewishDate.getJewishDayOfMonth() == day && + state.selectedJewishDate.getJewishMonth() == jewishDate.getJewishMonth() && + state.selectedJewishDate.getJewishYear() == jewishDate.getJewishYear(); + + return GestureDetector( + onTap: () => context.read().selectDate(jewishDate, gregorianDate), + child: Container( + margin: const EdgeInsets.all(2), + height: 50, + decoration: BoxDecoration( + color: isSelected ? Theme.of(context).primaryColor : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300, width: 1), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _formatHebrewDay(day), + style: TextStyle( + color: isSelected ? Colors.white : Colors.black, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + fontSize: state.calendarType == CalendarType.combined ? 14 : 16, + ), + ), + if (state.calendarType == CalendarType.combined) + Text( + '${gregorianDate.day}', + style: TextStyle( + color: isSelected ? Colors.white70 : Colors.grey[600], + fontSize: 10, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildGregorianDayCell(BuildContext context, CalendarState state, int day) { + final gregorianDate = DateTime(state.currentGregorianDate.year, state.currentGregorianDate.month, day); + final jewishDate = JewishDate.fromDateTime(gregorianDate); + + final isSelected = state.selectedGregorianDate.day == day && + state.selectedGregorianDate.month == gregorianDate.month && + state.selectedGregorianDate.year == gregorianDate.year; + + return GestureDetector( + onTap: () => context.read().selectDate(jewishDate, gregorianDate), + child: Container( + margin: const EdgeInsets.all(2), + height: 50, + decoration: BoxDecoration( + color: isSelected ? Theme.of(context).primaryColor : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300, width: 1), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '$day', + style: TextStyle( + color: isSelected ? Colors.white : Colors.black, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + fontSize: state.calendarType == CalendarType.combined ? 14 : 16, + ), + ), + if (state.calendarType == CalendarType.combined) + Text( + _formatHebrewDay(jewishDate.getJewishDayOfMonth()), + style: TextStyle( + color: isSelected ? Colors.white70 : Colors.grey[600], + fontSize: 10, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildDayDetailsWithoutEvents(BuildContext context, CalendarState state) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDateHeader(context, state), + const SizedBox(height: 16), + _buildTimesCard(context, state), + const SizedBox(height: 50), + ], + ), + ); + } + + Widget _buildDateHeader(BuildContext context, CalendarState state) { + final dayOfWeek = hebrewDays[state.selectedGregorianDate.weekday % 7]; + final jewishDateStr = + '${_formatHebrewDay(state.selectedJewishDate.getJewishDayOfMonth())} ${hebrewMonths[state.selectedJewishDate.getJewishMonth() - 1]}'; + final gregorianDateStr = + '${state.selectedGregorianDate.day} ${_getGregorianMonthName(state.selectedGregorianDate.month)} ${state.selectedGregorianDate.year}'; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16.0), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withAlpha(25), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + '$dayOfWeek $jewishDateStr', + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + gregorianDateStr, + style: TextStyle(fontSize: 16, color: Colors.grey[600]), + ), + ], + ), + ); + } + + Widget _buildTimesCard(BuildContext context, CalendarState state) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.schedule), + const SizedBox(width: 8), + const Text( + 'זמני היום', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const Spacer(), + DropdownButton( + value: state.selectedCity, + items: cityCoordinates.keys.map((city) { + return DropdownMenuItem( + value: city, + child: Text(city), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + context.read().changeCity(value); + } + }, + ), + ], + ), + const SizedBox(height: 16), + _buildTimesGrid(context, state), + ], + ), + ), + ); + } + + Widget _buildTimesGrid(BuildContext context, CalendarState state) { + final dailyTimes = state.dailyTimes; + final timesList = [ + {'name': 'עלות השחר', 'time': dailyTimes['alos']}, + {'name': 'זריחה', 'time': dailyTimes['sunrise']}, + {'name': 'סוף זמן קריאת שמע', 'time': dailyTimes['sofZmanShma']}, + {'name': 'סוף זמן תפילה', 'time': dailyTimes['sofZmanTfila']}, + {'name': 'חצות היום', 'time': dailyTimes['chatzos']}, + {'name': 'מנחה גדולה', 'time': dailyTimes['minchaGedola']}, + {'name': 'מנחה קטנה', 'time': dailyTimes['minchaKetana']}, + {'name': 'פלג המנחה', 'time': dailyTimes['plagHamincha']}, + {'name': 'שקיעה', 'time': dailyTimes['sunset']}, + {'name': 'צאת הכוכבים', 'time': dailyTimes['tzais']}, + ]; + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 3, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: timesList.length, + itemBuilder: (context, index) { + final timeData = timesList[index]; + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + timeData['name']!, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + timeData['time'] ?? '--:--', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + ], + ), + ); + }, + ); + } + + // פונקציות העזר שלא תלויות במצב נשארות כאן + String _getCurrentMonthYearText(CalendarState state) { + if (state.calendarType == CalendarType.gregorian) { + return '${_getGregorianMonthName(state.currentGregorianDate.month)} ${state.currentGregorianDate.year}'; + } else { + return '${hebrewMonths[state.currentJewishDate.getJewishMonth() - 1]} ${_formatHebrewYear(state.currentJewishDate.getJewishYear())}'; + } + } + + String _formatHebrewYear(int year) { + final thousands = year ~/ 1000; + final remainder = year % 1000; + if (thousands == 5) { + final hebrewNumber = _numberToHebrewWithoutQuotes(remainder); + return 'ה\'$hebrewNumber'; + } else { + return _numberToHebrewWithoutQuotes(year); + } + } + + String _formatHebrewDay(int day) { + return _numberToHebrewWithoutQuotes(day); + } + + String _numberToHebrewWithoutQuotes(int number) { + if (number <= 0) return ''; + String result = ''; + int num = number; + if (num >= 100) { + int hundreds = (num ~/ 100) * 100; + if (hundreds == 900) result += 'תתק'; + else if (hundreds == 800) result += 'תת'; + else if (hundreds == 700) result += 'תש'; + else if (hundreds == 600) result += 'תר'; + else if (hundreds == 500) result += 'תק'; + else if (hundreds == 400) result += 'ת'; + else if (hundreds == 300) result += 'ש'; + else if (hundreds == 200) result += 'ר'; + else if (hundreds == 100) result += 'ק'; + num %= 100; + } + const ones = ['', 'א', 'ב', 'ג', 'ד', 'ה', 'ו', 'ז', 'ח', 'ט']; + const tens = ['', 'י', 'כ', 'ל', 'מ', 'נ', 'ס', 'ע', 'פ', 'צ']; + if (num == 15) { + result += 'טו'; + } else if (num == 16) { + result += 'טז'; + } else { + if (num >= 10) { + result += tens[num ~/ 10]; + num %= 10; + } + if (num > 0) { + result += ones[num]; + } + } + return result; + } + + String _getGregorianMonthName(int month) { + const months = [ + 'ינואר', 'פברואר', 'מרץ', 'אפריל', 'מאי', 'יוני', + 'יולי', 'אוגוסט', 'ספטמבר', 'אוקטובר', 'נובמבר', 'דצמבר' + ]; + return months[month - 1]; + } + + // החלק של האירועים עדיין לא עבר ריפקטורינג, הוא יישאר לא פעיל בינתיים + Widget _buildEventsCard(BuildContext context, CalendarState state) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.event), + const SizedBox(width: 8), + const Text( + 'אירועים', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const Spacer(), + ElevatedButton.icon( + onPressed: () { /* TODO: Implement create event with cubit */ }, + icon: const Icon(Icons.add, size: 16), + label: const Text('צור אירוע'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + textStyle: const TextStyle(fontSize: 12), + ), + ), + ], + ), + const SizedBox(height: 16), + const Text('אין אירועים ליום זה'), + ], + ), + ), + ); + } +} diff --git a/lib/navigation/more_screen.dart b/lib/navigation/more_screen.dart index 2cdfbd84b..6e85546c8 100644 --- a/lib/navigation/more_screen.dart +++ b/lib/navigation/more_screen.dart @@ -1,10 +1,26 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'calendar_widget.dart'; +import 'calendar_cubit.dart'; -class MoreScreen extends StatelessWidget { +class MoreScreen extends StatefulWidget { const MoreScreen({Key? key}) : super(key: key); + @override + State createState() => _MoreScreenState(); +} + +class _MoreScreenState extends State { + Widget? currentWidget; + @override Widget build(BuildContext context) { + // אם currentWidget אינו null, הצג אותו. אחרת, הצג את התפריט. + if (currentWidget != null) { + return currentWidget!; + } + + // זהו מסך התפריט הראשי return Scaffold( appBar: AppBar( title: const Text('עוד'), @@ -13,14 +29,14 @@ class MoreScreen extends StatelessWidget { body: Padding( padding: const EdgeInsets.all(16), child: Column( - crossAxisAlignment: CrossAxisAlignment.end, // מיושר לימין + crossAxisAlignment: CrossAxisAlignment.end, children: [ _buildToolItem( context, icon: Icons.calendar_today, title: 'לוח שנה', subtitle: 'לוח שנה עברי ולועזי', - onTap: () => _showComingSoon(context, 'לוח שנה'), + onTap: () => _showCalendar(), ), const SizedBox(height: 16), _buildToolItem( @@ -44,7 +60,115 @@ class MoreScreen extends StatelessWidget { ); } - /// כרטיס קטן מיושר לימין + // פונקציה זו בונה את מסך לוח השנה + void _showCalendar() { + setState(() { + currentWidget = BlocProvider( + create: (context) => CalendarCubit(), + child: Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + setState(() { + currentWidget = null; // חזור למסך התפריט + }); + }, + ), + title: BlocBuilder( + builder: (context, state) { + switch (state.calendarType) { + case CalendarType.hebrew: + return const Text('לוח שנה עברי'); + case CalendarType.gregorian: + return const Text('לוח שנה לועזי'); + case CalendarType.combined: + return const Text('לוח שנה משולב'); + } + }, + ), + centerTitle: true, + actions: [ + Builder( + builder: (context) { + return IconButton( + icon: const Icon(Icons.settings), + onPressed: () => _showSettingsDialog(context), + ); + } + ), + ], + ), + body: const CalendarWidget(), + ), + ); + }); + } + + // פונקציה זו מציגה את דיאלוג ההגדרות + void _showSettingsDialog(BuildContext context) { + final calendarCubit = context.read(); + + showDialog( + context: context, + builder: (dialogContext) { + return BlocBuilder( + bloc: calendarCubit, + builder: (context, state) { + return AlertDialog( + title: const Text('הגדרות לוח שנה'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RadioListTile( + title: const Text('לוח עברי'), + value: CalendarType.hebrew, + groupValue: state.calendarType, + onChanged: (value) { + if (value != null) { + calendarCubit.changeCalendarType(value); + } + Navigator.of(dialogContext).pop(); + }, + ), + RadioListTile( + title: const Text('לוח לועזי'), + value: CalendarType.gregorian, + groupValue: state.calendarType, + onChanged: (value) { + if (value != null) { + calendarCubit.changeCalendarType(value); + } + Navigator.of(dialogContext).pop(); + }, + ), + RadioListTile( + title: const Text('לוח משולב'), + value: CalendarType.combined, + groupValue: state.calendarType, + onChanged: (value) { + if (value != null) { + calendarCubit.changeCalendarType(value); + } + Navigator.of(dialogContext).pop(); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('סגור'), + ), + ], + ); + }, + ); + }, + ); + } + + // שאר הפונקציות נשארות כפי שהיו Widget _buildToolItem( BuildContext context, { required IconData icon, @@ -53,12 +177,12 @@ class MoreScreen extends StatelessWidget { required VoidCallback onTap, }) { return Align( - alignment: Alignment.centerRight, // מצמיד לימין + alignment: Alignment.centerRight, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(16), child: Container( - width: 110, // רוחב צר - כמו הסרגל הצדדי + width: 110, padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, @@ -72,8 +196,7 @@ class MoreScreen extends StatelessWidget { const SizedBox(height: 8), Text( title, - style: - const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), const SizedBox(height: 2), diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 14da8388b..d8d167e42 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import file_picker import flutter_archive import isar_flutter_libs import package_info_plus @@ -16,6 +17,7 @@ import url_launcher_macos import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FlutterArchivePlugin.register(with: registry.registrar(forPlugin: "FlutterArchivePlugin")) IsarFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "IsarFlutterLibsPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) diff --git a/pubspec.lock b/pubspec.lock index f5dcdcc84..f67465ec3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -346,10 +346,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: c904b4ab56d53385563c7c39d8e9fa9af086f91495dfc48717ad84a42c3cf204 + sha256: "13ba4e627ef24503a465d1d61b32596ce10eb6b8903678d362a528f9939b4aa8" url: "https://pub.dev" source: hosted - version: "8.1.7" + version: "10.2.1" filter_list: dependency: "direct main" description: @@ -425,10 +425,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "5.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -552,10 +552,10 @@ packages: dependency: "direct main" description: name: http - sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" http_multi_server: dependency: transitive description: @@ -632,10 +632,10 @@ packages: dependency: "direct main" description: name: kosher_dart - sha256: f37c00da3109fedefc933296cdb01694d097474603f9f4d8a025b17fb9a2c5fe + sha256: e32225eab8439fce90af7ceee4929bf2b8dea3dac57c7638df69f9451ef78ef5 url: "https://pub.dev" source: hosted - version: "2.0.17" + version: "2.0.18" leak_tracker: dependency: transitive description: @@ -664,10 +664,10 @@ packages: dependency: transitive description: name: lints - sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "5.1.1" list_counter: dependency: transitive description: @@ -892,14 +892,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" - pedantic: - dependency: transitive - description: - name: pedantic - sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602" - url: "https://pub.dev" - source: hosted - version: "1.11.1" permission_handler: dependency: "direct main" description: @@ -992,10 +984,10 @@ packages: dependency: "direct main" description: name: provider - sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" url: "https://pub.dev" source: hosted - version: "6.1.2" + version: "6.1.5" pub_semver: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 653e9901c..e3e86c481 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,8 +6,10 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev msix_config: display_name: אוצריא + display_name_short: אוצריא publisher_display_name: sivan22 identity_name: sivan22.Otzaria + description: "ספריית ספרים יהודיים עם אפשרויות חיפוש חכם" msix_version: 0.2.7.2 logo_path: assets/icon/icon.png publisher: CN=sivan22, O=sivan22, C=IL @@ -58,7 +60,7 @@ dependencies: isar_flutter_libs: ^4.0.0-dev.14 isar: ^4.0.0-dev.14 msix: ^3.16.9 - path_provider: ^2.0.15 + path_provider: ^2.1.5 html: ^0.15.1 pdfrx: ^1.3.2 url_launcher: ^6.3.1 @@ -66,10 +68,10 @@ dependencies: scrollable_positioned_list: ^0.3.8 search_highlight_text: ^1.0.0+2 fuzzywuzzy: ^1.1.6 - file_picker: ^8.0.6 + file_picker: ^10.2.1 permission_handler: ^11.3.0 flutter_launcher_icons: "^0.13.1" - provider: ^6.1.2 + provider: ^6.1.5 docx_to_text: ^1.0.1 expandable: ^5.0.1 multi_split_view: ^2.4.0 @@ -80,7 +82,7 @@ dependencies: printing: pdf: ^3.10.8 - kosher_dart: ^2.0.16 + kosher_dart: ^2.0.18 gematria: ^1.0.0 csv: ^6.0.0 archive: ^3.6.1 @@ -88,7 +90,7 @@ dependencies: package_info_plus: ^8.0.2 crypto: ^3.0.5 path: ^1.9.0 - http: ^1.2.2 + http: ^1.4.0 flutter_document_picker: git: url: https://github.com/sidlatau/flutter_document_picker @@ -127,7 +129,7 @@ dev_dependencies: # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # rules and activating additional ones. - flutter_lints: ^2.0.0 + flutter_lints: ^5.0.0 test: ^1.25.2 build_runner: ^2.4.11 bloc_test: ^10.0.0 From ec5a0d6fd63de6970a4b26e97642636b62bda189 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 4 Aug 2025 20:04:10 +0300 Subject: [PATCH 071/197] =?UTF-8?q?=D7=A9=D7=99=D7=A4=D7=95=D7=A8=20=D7=91?= =?UTF-8?q?=D7=95=D7=A2=D7=95=D7=AA=20=D7=9E=D7=99=D7=9C=D7=94=20=D7=97?= =?UTF-8?q?=D7=99=D7=9C=D7=95=D7=A4=D7=99=D7=AA=20=D7=95=D7=9E=D7=A8=D7=95?= =?UTF-8?q?=D7=95=D7=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/enhanced_search_field.dart | 319 +++++++++++++-------- 1 file changed, 204 insertions(+), 115 deletions(-) diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 3483e2012..ddcc0eb56 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -151,17 +151,19 @@ class _SpacingFieldState extends State<_SpacingField> { } }); _focus.addListener(_onFocusChanged); - // הוספת listener לשינויי טקסט כדי לעדכן את ה-opacity widget.controller.addListener(_onTextChanged); } void _onTextChanged() { - setState(() {}); // עדכון המצב לשינוי opacity + if (mounted) { + setState(() {}); + } } void _onFocusChanged() { - setState(() {}); - + if (mounted) { + setState(() {}); + } if (!_focus.hasFocus && widget.controller.text.trim().isEmpty) { widget.onFocusLost?.call(); } @@ -177,70 +179,113 @@ class _SpacingFieldState extends State<_SpacingField> { @override Widget build(BuildContext context) { + final theme = Theme.of(context); final bool hasText = widget.controller.text.trim().isNotEmpty; - final bool isInactive = !_focus.hasFocus && hasText; + final bool hasFocus = _focus.hasFocus; + final bool isFloating = hasFocus || hasText; // התנאי להצפת התווית + final bool isInactive = !hasFocus && hasText; + + final floatingLabelStyle = TextStyle( + color: hasFocus ? theme.primaryColor : theme.hintColor, + fontSize: 12, + backgroundColor: theme.scaffoldBackgroundColor, + ); + final placeholderStyle = TextStyle( + color: theme.hintColor.withOpacity(0.8), + fontSize: 12, + ); return AnimatedOpacity( - opacity: isInactive ? 0.5 : 1.0, // חצי שקופה כשלא בפוקוס ויש טקסט + opacity: isInactive ? 0.5 : 1.0, duration: const Duration(milliseconds: 200), - child: Container( - width: 45, // הצרה משמעותית מ-65 ל-45 (מתאים ל-2 ספרות) - height: 40, - decoration: BoxDecoration( - color: Theme.of(context).scaffoldBackgroundColor, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: _focus.hasFocus - ? Theme.of(context).primaryColor - : Theme.of(context).dividerColor, - width: _focus.hasFocus ? 1.5 : 1.0, - ), - boxShadow: [ - BoxShadow( - color: - Colors.black.withValues(alpha: _focus.hasFocus ? 0.15 : 0.08), - blurRadius: _focus.hasFocus ? 6 : 3, - offset: const Offset(0, 2), + child: Stack( + clipBehavior: Clip.none, // מאפשר לתווית לצאת מגבולות ה-Stack + children: [ + // 1. קופסת הקלט עצמה (השכבה התחתונה) + Container( + width: 45, // רוחב צר למספר 1-2 ספרות + height: 40, + decoration: BoxDecoration( + color: theme.scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: hasFocus ? theme.primaryColor : theme.dividerColor, + width: hasFocus ? 1.5 : 1.0, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(hasFocus ? 0.15 : 0.08), + blurRadius: hasFocus ? 6 : 3, + offset: const Offset(0, 2), + ), + ], ), - ], - ), - clipBehavior: Clip.antiAlias, - child: Material( - type: MaterialType.transparency, - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.close, size: 14), - onPressed: widget.onRemove, - splashRadius: 16, - padding: const EdgeInsets.only(left: 4, right: 2), - constraints: const BoxConstraints(), + child: Material( + type: MaterialType.transparency, + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.close, size: 14), + onPressed: widget.onRemove, + splashRadius: 16, + padding: const EdgeInsets.only(left: 4, right: 2), + constraints: const BoxConstraints(), + ), + Expanded( + child: TextField( + controller: widget.controller, + focusNode: _focus, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(2), + ], + decoration: const InputDecoration( + // הסרנו את labelText מכאן + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.only(right: 4, bottom: 4), + ), + style: const TextStyle( + fontSize: 12, + color: Colors.black87, + fontWeight: FontWeight.w200, // גופן צר לטקסט שנכתב + ), + textAlign: TextAlign.right, + onSubmitted: (v) { + if (v.trim().isEmpty) widget.onRemove(); + }, + ), + ), + ], ), - Expanded( - child: TextField( - controller: widget.controller, - focusNode: _focus, - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(2), - ], - decoration: const InputDecoration( - hintText: 'מרווח', - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.only(right: 4, bottom: 4), + ), + ), + + // 2. התווית הצפה (השכבה העליונה) + AnimatedPositioned( + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOut, + // מיקום דינמי: למעלה או באמצע + top: isFloating ? -10 : 10, + right: isFloating ? 8 : 12, + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 150), + style: isFloating ? floatingLabelStyle : placeholderStyle, + child: Container( + // קונטיינר זה יוצר את אפקט ה"חיתוך" של הגבול + padding: const EdgeInsets.symmetric(horizontal: 4), + child: const Text( + 'מרווח', + style: TextStyle( + fontWeight: FontWeight.w100, // גופן צר במיוחד + fontSize: 11, ), - style: const TextStyle(fontSize: 12, color: Colors.black87), - textAlign: TextAlign.right, - onSubmitted: (v) { - if (v.trim().isEmpty) widget.onRemove(); - }, ), ), - ], + ), ), - ), + ], ), ); } @@ -273,17 +318,19 @@ class _AlternativeFieldState extends State<_AlternativeField> { } }); _focus.addListener(_onFocusChanged); - // הוספת listener לשינויי טקסט כדי לעדכן את ה-opacity widget.controller.addListener(_onTextChanged); } void _onTextChanged() { - setState(() {}); // עדכון המצב לשינוי opacity + if (mounted) { + setState(() {}); + } } void _onFocusChanged() { - setState(() {}); - + if (mounted) { + setState(() {}); + } if (!_focus.hasFocus && widget.controller.text.trim().isEmpty) { widget.onFocusLost?.call(); } @@ -299,69 +346,111 @@ class _AlternativeFieldState extends State<_AlternativeField> { @override Widget build(BuildContext context) { + final theme = Theme.of(context); final bool hasText = widget.controller.text.trim().isNotEmpty; - final bool isInactive = !_focus.hasFocus && hasText; + final bool hasFocus = _focus.hasFocus; + final bool isFloating = hasFocus || hasText; // התנאי להצפת התווית + final bool isInactive = !hasFocus && hasText; + + final floatingLabelStyle = TextStyle( + color: hasFocus ? theme.primaryColor : theme.hintColor, + fontSize: 12, + backgroundColor: theme.scaffoldBackgroundColor, + ); + final placeholderStyle = TextStyle( + color: theme.hintColor.withOpacity(0.8), + fontSize: 12, + ); return AnimatedOpacity( - opacity: isInactive ? 0.5 : 1.0, // חצי שקופה כשלא בפוקוס ויש טקסט + opacity: isInactive ? 0.5 : 1.0, duration: const Duration(milliseconds: 200), - child: Container( - width: 70, // הצרה עוד יותר - מ-120 ל-100 - height: 40, - decoration: BoxDecoration( - color: Theme.of(context).scaffoldBackgroundColor, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: _focus.hasFocus - ? Theme.of(context).primaryColor - : Theme.of(context).dividerColor, - width: _focus.hasFocus ? 1.5 : 1.0, - ), - boxShadow: [ - BoxShadow( - color: - Colors.black.withValues(alpha: _focus.hasFocus ? 0.15 : 0.08), - blurRadius: _focus.hasFocus ? 6 : 3, - offset: const Offset(0, 2), + child: Stack( + clipBehavior: Clip.none, // מאפשר לתווית לצאת מגבולות ה-Stack + children: [ + // 1. קופסת הקלט עצמה (השכבה התחתונה) + Container( + width: 60, // רוחב צר למילה של כ-4 תווים + height: 40, + decoration: BoxDecoration( + color: theme.scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: hasFocus ? theme.primaryColor : theme.dividerColor, + width: hasFocus ? 1.5 : 1.0, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(hasFocus ? 0.15 : 0.08), + blurRadius: hasFocus ? 6 : 3, + offset: const Offset(0, 2), + ), + ], ), - ], - ), - clipBehavior: Clip.antiAlias, - child: Material( - type: MaterialType.transparency, - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.close, size: 14), - onPressed: widget.onRemove, - splashRadius: 16, - padding: const EdgeInsets.only(left: 4, right: 2), - constraints: const BoxConstraints(), + child: Material( + type: MaterialType.transparency, + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.close, size: 14), + onPressed: widget.onRemove, + splashRadius: 16, + padding: const EdgeInsets.only(left: 4, right: 2), + constraints: const BoxConstraints(), + ), + Expanded( + child: TextField( + controller: widget.controller, + focusNode: _focus, + inputFormatters: [ + FilteringTextInputFormatter.deny(RegExp(r'\s')), + ], + decoration: const InputDecoration( + // הסרנו את labelText מכאן + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.only(right: 4, bottom: 4), + ), + style: const TextStyle( + fontSize: 12, + color: Colors.black87, + fontWeight: FontWeight.w200, // גופן צר לטקסט שנכתב + ), + textAlign: TextAlign.right, + onSubmitted: (v) { + if (v.trim().isEmpty) widget.onRemove(); + }, + ), + ), + ], ), - Expanded( - child: TextField( - controller: widget.controller, - focusNode: _focus, - inputFormatters: [ - // הגבלה למילה אחת - מניעת רווחים - FilteringTextInputFormatter.deny(RegExp(r'\s')), - ], - decoration: const InputDecoration( - hintText: 'מילה חילופית', - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.only(right: 4, bottom: 4), + ), + ), + + // 2. התווית הצפה (השכבה העליונה) + AnimatedPositioned( + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOut, + // מיקום דינמי + top: isFloating ? -10 : 10, + right: isFloating ? 8 : 15, + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 150), + style: isFloating ? floatingLabelStyle : placeholderStyle, + child: Container( + // קונטיינר זה יוצר את אפקט ה"חיתוך" של הגבול + padding: const EdgeInsets.symmetric(horizontal: 4), + child: const Text( + 'מילה חילופית', + style: TextStyle( + fontWeight: FontWeight.w100, // גופן צר במיוחד + fontSize: 11, ), - style: const TextStyle(fontSize: 12, color: Colors.black87), - textAlign: TextAlign.right, - onSubmitted: (v) { - if (v.trim().isEmpty) widget.onRemove(); - }, ), ), - ], + ), ), - ), + ], ), ); } From b7927eb9ae0e949826d5b0eac7bdbb6a9dcca0a8 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 4 Aug 2025 23:01:50 +0300 Subject: [PATCH 072/197] =?UTF-8?q?=D7=A4=D7=AA=D7=99=D7=97=D7=94=20=D7=90?= =?UTF-8?q?=D7=95=D7=98=D7=95=D7=9E=D7=98=D7=99=D7=AA=20=D7=A9=D7=9C=20?= =?UTF-8?q?=D7=9B=D7=95=D7=AA=D7=A8=D7=95=D7=AA=20=D7=91=D7=A8=D7=9E=D7=94?= =?UTF-8?q?=203=20=D7=95=D7=9E=D7=A2=D7=9C=D7=94=20-=20=D7=92=D7=9D=20?= =?UTF-8?q?=D7=91PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pdf_book/pdf_outlines_screen.dart | 127 +++++++++++++++++++++----- 1 file changed, 106 insertions(+), 21 deletions(-) diff --git a/lib/pdf_book/pdf_outlines_screen.dart b/lib/pdf_book/pdf_outlines_screen.dart index 7aa9b68d8..6f047a724 100644 --- a/lib/pdf_book/pdf_outlines_screen.dart +++ b/lib/pdf_book/pdf_outlines_screen.dart @@ -20,12 +20,14 @@ class OutlineView extends StatefulWidget { class _OutlineViewState extends State with AutomaticKeepAliveClientMixin { - final TextEditingController searchController = TextEditingController(); + final TextEditingController searchController = TextEditingController(); final ScrollController _tocScrollController = ScrollController(); final Map _tocItemKeys = {}; bool _isManuallyScrolling = false; int? _lastScrolledPage; + final Map _expanded = {}; + final Map _controllers = {}; @override bool get wantKeepAlive => true; @@ -58,6 +60,57 @@ class _OutlineViewState extends State _scrollToActiveItem(); } } + + void _ensureParentsOpen( + List nodes, PdfOutlineNode targetNode) { + final path = _findPath(nodes, targetNode); + if (path.isEmpty) return; + + // מוצא את הרמה של הצומת היעד + int targetLevel = _getNodeLevel(nodes, targetNode); + + // אם הצומת ברמה 2 ומעלה (שזה רמה 3 ומעלה בספירה רגילה), פתח את כל ההורים + if (targetLevel >= 2) { + for (final node in path) { + if (node.children.isNotEmpty && _expanded[node] != true) { + _expanded[node] = true; + _controllers[node]?.expand(); + } + } + } + } + + int _getNodeLevel(List nodes, PdfOutlineNode targetNode, + [int currentLevel = 0]) { + for (final node in nodes) { + if (node == targetNode) { + return currentLevel; + } + + final childLevel = + _getNodeLevel(node.children, targetNode, currentLevel + 1); + if (childLevel != -1) { + return childLevel; + } + } + return -1; + } + + List _findPath( + List nodes, PdfOutlineNode targetNode) { + for (final node in nodes) { + if (node == targetNode) { + return [node]; + } + + final subPath = _findPath(node.children, targetNode); + if (subPath.isNotEmpty) { + return [node, ...subPath]; + } + } + return []; + } + void _scrollToActiveItem() { if (_isManuallyScrolling || !widget.controller.isReady) return; @@ -86,6 +139,10 @@ class _OutlineViewState extends State activeNode = findClosestNode(widget.outline!, currentPage); } + if (activeNode != null && widget.outline != null) { + _ensureParentsOpen(widget.outline!, activeNode); + } + // קריאה ל-setState כדי לוודא שהפריט הנכון מודגש לפני הגלילה if (mounted) { setState(() {}); @@ -103,28 +160,34 @@ class _OutlineViewState extends State final key = _tocItemKeys[activeNode]; final itemContext = key?.currentContext; if (itemContext == null) return; - + final itemRenderObject = itemContext.findRenderObject(); if (itemRenderObject is! RenderBox) return; // --- התחלה: החישוב הנכון והבדוק --- // זהו החישוב מההצעה של ה-AI השני, מותאם לקוד שלנו. - - final scrollableBox = _tocScrollController.position.context.storageContext.findRenderObject() as RenderBox; - + + final scrollableBox = _tocScrollController.position.context.storageContext + .findRenderObject() as RenderBox; + // המיקום של הפריט ביחס ל-viewport של הגלילה - final itemOffset = itemRenderObject.localToGlobal(Offset.zero, ancestor: scrollableBox).dy; - + final itemOffset = itemRenderObject + .localToGlobal(Offset.zero, ancestor: scrollableBox) + .dy; + // גובה ה-viewport (האזור הנראה) final viewportHeight = scrollableBox.size.height; - + // גובה הפריט עצמו final itemHeight = itemRenderObject.size.height; // מיקום היעד המדויק למירוכז - final target = _tocScrollController.offset + itemOffset - (viewportHeight / 2) + (itemHeight / 2); + final target = _tocScrollController.offset + + itemOffset - + (viewportHeight / 2) + + (itemHeight / 2); // --- סיום: החישוב הנכון והבדוק --- - + _tocScrollController.animateTo( target.clamp( 0.0, @@ -178,7 +241,8 @@ class _OutlineViewState extends State Expanded( child: NotificationListener( onNotification: (notification) { - if (notification is ScrollStartNotification && notification.dragDetails != null) { + if (notification is ScrollStartNotification && + notification.dragDetails != null) { setState(() { _isManuallyScrolling = true; }); @@ -253,6 +317,20 @@ class _OutlineViewState extends State } } + if (node.children.isNotEmpty) { + final controller = + _controllers.putIfAbsent(node, () => ExpansionTileController()); + final bool isExpanded = _expanded[node] ?? (level == 0); + + if (controller.isExpanded != isExpanded) { + if (isExpanded) { + controller.expand(); + } else { + controller.collapse(); + } + } + } + return Padding( key: itemKey, padding: EdgeInsets.fromLTRB(0, 0, 10 * level.toDouble(), 0), @@ -266,10 +344,12 @@ class _OutlineViewState extends State child: ListTile( title: Text(node.title), selected: widget.controller.isReady && - node.dest?.pageNumber == - widget.controller.pageNumber, - selectedColor: Theme.of(context).colorScheme.onSecondaryContainer, - selectedTileColor: Theme.of(context).colorScheme.secondaryContainer, onTap: navigateToEntry, + node.dest?.pageNumber == widget.controller.pageNumber, + selectedColor: + Theme.of(context).colorScheme.onSecondaryContainer, + selectedTileColor: + Theme.of(context).colorScheme.secondaryContainer, + onTap: navigateToEntry, hoverColor: Theme.of(context).hoverColor, mouseCursor: SystemMouseCursors.click, ), @@ -278,19 +358,24 @@ class _OutlineViewState extends State color: Colors.transparent, child: ExpansionTile( key: PageStorageKey(node), - initiallyExpanded: level == 0, + controller: _controllers.putIfAbsent( + node, () => ExpansionTileController()), + initiallyExpanded: _expanded[node] ?? (level == 0), + onExpansionChanged: (val) { + setState(() { + _expanded[node] = val; + }); + }, // גם לכותרת של הצומת המורחב נוסיף ListTile title: ListTile( title: Text(node.title), selected: widget.controller.isReady && - node.dest?.pageNumber == - widget.controller.pageNumber, - selectedColor: - Theme.of(context).colorScheme.onSecondary, + node.dest?.pageNumber == widget.controller.pageNumber, + selectedColor: Theme.of(context).colorScheme.onSecondary, selectedTileColor: Theme.of(context) .colorScheme .secondary - .withOpacity(0.2), + .withValues(alpha: 0.2), onTap: navigateToEntry, hoverColor: Theme.of(context).hoverColor, mouseCursor: SystemMouseCursors.click, From 3e23e9ce9c708294d0f44742e671417cb63ca350 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 4 Aug 2025 23:27:47 +0300 Subject: [PATCH 073/197] =?UTF-8?q?=D7=A9=D7=99=D7=A4=D7=95=D7=A8=20=D7=94?= =?UTF-8?q?=D7=A6=D7=92=D7=AA=20=D7=9B=D7=A8=D7=98=D7=99=D7=A1=D7=99=D7=95?= =?UTF-8?q?=D7=AA=20=D7=91=D7=A2=D7=AA=20=D7=9B=D7=99=D7=95=D7=95=D7=A5=20?= =?UTF-8?q?=D7=A1=D7=A8=D7=92=D7=9C=20=D7=94=D7=A6=D7=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pdf_book/pdf_book_screen.dart | 11 ++++++++--- lib/text_book/view/text_book_screen.dart | 25 ++++++++++++++++++++---- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/lib/pdf_book/pdf_book_screen.dart b/lib/pdf_book/pdf_book_screen.dart index d0d4afea1..227f12f4b 100644 --- a/lib/pdf_book/pdf_book_screen.dart +++ b/lib/pdf_book/pdf_book_screen.dart @@ -512,10 +512,15 @@ class _PdfBookScreenState extends State child: TabBar( controller: _leftPaneTabController, tabs: const [ - Tab(text: 'ניווט'), - Tab(text: 'חיפוש'), - Tab(text: 'דפים'), + Tab(child: Center(child: Text('ניווט', textAlign: TextAlign.center))), + Tab(child: Center(child: Text('חיפוש', textAlign: TextAlign.center))), + Tab(child: Center(child: Text('דפים', textAlign: TextAlign.center))), ], + isScrollable: false, + tabAlignment: TabAlignment.fill, + padding: EdgeInsets.zero, + indicatorPadding: EdgeInsets.zero, + labelPadding: const EdgeInsets.symmetric(horizontal: 2), ), ), ), diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index 655b38e28..b0f491ff2 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -1015,12 +1015,29 @@ class _TextBookViewerBlocState extends State Expanded( child: TabBar( tabs: const [ - Tab(text: 'ניווט'), - Tab(text: 'חיפוש'), - Tab(text: 'פרשנות'), - Tab(text: 'קישורים'), + Tab( + child: Center( + child: Text('ניווט', + textAlign: TextAlign.center))), + Tab( + child: Center( + child: Text('חיפוש', + textAlign: TextAlign.center))), + Tab( + child: Center( + child: Text('פרשנות', + textAlign: TextAlign.center))), + Tab( + child: Center( + child: Text('קישורים', + textAlign: TextAlign.center))), ], controller: tabController, + isScrollable: false, + tabAlignment: TabAlignment.fill, + padding: EdgeInsets.zero, + indicatorPadding: EdgeInsets.zero, + labelPadding: const EdgeInsets.symmetric(horizontal: 2), onTap: (value) { if (value == 1 && !Platform.isAndroid) { textSearchFocusNode.requestFocus(); From b511c0bbeb3d8839b13a166c2c0e355c48cbd69b Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 5 Aug 2025 00:08:31 +0300 Subject: [PATCH 074/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=A7?= =?UTF-8?q?=D7=98=D7=92=D7=95=D7=A8=D7=99=D7=95=D7=AA=20=D7=97=D7=96"?= =?UTF-8?q?=D7=9C,=20=D7=AA=D7=95=D7=A8=D7=94=20=D7=A9=D7=91=D7=9B=D7=AA?= =?UTF-8?q?=D7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/text_book/bloc/text_book_bloc.dart | 20 +-- lib/text_book/bloc/text_book_state.dart | 23 ++- .../combined_view/combined_book_screen.dart | 22 ++- .../view/commentators_list_screen.dart | 139 ++++++++++++++---- .../view/splited_view/simple_book_view.dart | 2 +- lib/text_book/view/text_book_screen.dart | 2 +- lib/utils/text_manipulation.dart | 87 ++++++++--- 7 files changed, 227 insertions(+), 68 deletions(-) diff --git a/lib/text_book/bloc/text_book_bloc.dart b/lib/text_book/bloc/text_book_bloc.dart index 33a8eaaa4..bd59206a2 100644 --- a/lib/text_book/bloc/text_book_bloc.dart +++ b/lib/text_book/bloc/text_book_bloc.dart @@ -60,8 +60,7 @@ class TextBookBloc extends Bloc { Settings.getValue('key-default-nikud') ?? false; final removeNikudFromTanach = Settings.getValue('key-remove-nikud-tanach') ?? false; - final isTanach = - await FileSystemData.instance.isTanachBook(book.title); + final isTanach = await FileSystemData.instance.isTanachBook(book.title); final removeNikud = defaultRemoveNikud && (removeNikudFromTanach || !isTanach); @@ -74,9 +73,8 @@ class TextBookBloc extends Bloc { // Set up position listener positionsListener.itemPositions.addListener(() { - final visibleInecies = positionsListener.itemPositions.value - .map((e) => e.index) - .toList(); + final visibleInecies = + positionsListener.itemPositions.value.map((e) => e.index).toList(); if (visibleInecies.isNotEmpty) { add(UpdateVisibleIndecies(visibleInecies)); } @@ -93,21 +91,22 @@ class TextBookBloc extends Bloc { showLeftPane: initial.showLeftPane || initial.searchText.isNotEmpty, showSplitView: event.showSplitView, activeCommentators: initial.commentators, // שימוש במשתנה המקומי + torahShebichtav: eras['תורה שבכתב']!, + chazal: eras['חזל']!, rishonim: eras['ראשונים']!, acharonim: eras['אחרונים']!, modernCommentators: eras['מחברי זמננו']!, removeNikud: removeNikud, visibleIndices: [initial.index], // שימוש במשתנה המקומי - pinLeftPane: - Settings.getValue('key-pin-sidebar') ?? false, + pinLeftPane: Settings.getValue('key-pin-sidebar') ?? false, searchText: searchText, scrollController: scrollController, scrollOffsetController: scrollOffsetController, positionsListener: positionsListener, )); } catch (e) { - emit(TextBookError(e.toString(), book, initial.index, initial.showLeftPane, - initial.commentators)); + emit(TextBookError(e.toString(), book, initial.index, + initial.showLeftPane, initial.commentators)); } } @@ -191,7 +190,8 @@ class TextBookBloc extends Bloc { int? index = currentState.selectedIndex; if (!event.visibleIndecies.contains(index)) { - index = null; } + index = null; + } emit(currentState.copyWith( visibleIndices: event.visibleIndecies, diff --git a/lib/text_book/bloc/text_book_state.dart b/lib/text_book/bloc/text_book_state.dart index d6f872f7c..99c179d2c 100644 --- a/lib/text_book/bloc/text_book_state.dart +++ b/lib/text_book/bloc/text_book_state.dart @@ -20,10 +20,7 @@ class TextBookInitial extends TextBookState { final String searchText; const TextBookInitial( - super.book, - super.index, - super.showLeftPane, - super.commentators, + super.book, super.index, super.showLeftPane, super.commentators, [this.searchText = '']); @override @@ -53,6 +50,8 @@ class TextBookLoaded extends TextBookState { final double fontSize; final bool showSplitView; final List activeCommentators; + final List torahShebichtav; + final List chazal; final List rishonim; final List acharonim; final List modernCommentators; @@ -78,7 +77,9 @@ class TextBookLoaded extends TextBookState { required this.fontSize, required this.showSplitView, required this.activeCommentators, - required this.rishonim, + required this.torahShebichtav, + required this.chazal, + required this.rishonim, required this.acharonim, required this.modernCommentators, required this.availableCommentators, @@ -109,6 +110,8 @@ class TextBookLoaded extends TextBookState { showLeftPane: showLeftPane, showSplitView: splitView, activeCommentators: commentators ?? const [], + torahShebichtav: const [], + chazal: const [], rishonim: const [], acharonim: const [], modernCommentators: const [], @@ -132,6 +135,8 @@ class TextBookLoaded extends TextBookState { bool? showLeftPane, bool? showSplitView, List? activeCommentators, + List? torahShebichtav, + List? chazal, List? rishonim, List? acharonim, List? modernCommentators, @@ -155,6 +160,8 @@ class TextBookLoaded extends TextBookState { showLeftPane: showLeftPane ?? this.showLeftPane, showSplitView: showSplitView ?? this.showSplitView, activeCommentators: activeCommentators ?? this.activeCommentators, + torahShebichtav: torahShebichtav ?? this.torahShebichtav, + chazal: chazal ?? this.chazal, rishonim: rishonim ?? this.rishonim, acharonim: acharonim ?? this.acharonim, modernCommentators: modernCommentators ?? this.modernCommentators, @@ -183,7 +190,11 @@ class TextBookLoaded extends TextBookState { showLeftPane, showSplitView, activeCommentators.length, - rishonim, acharonim, modernCommentators, + torahShebichtav, + chazal, + rishonim, + acharonim, + modernCommentators, availableCommentators.length, links.length, tableOfContents.length, diff --git a/lib/text_book/view/combined_view/combined_book_screen.dart b/lib/text_book/view/combined_view/combined_book_screen.dart index 518b642db..685484497 100644 --- a/lib/text_book/view/combined_view/combined_book_screen.dart +++ b/lib/text_book/view/combined_view/combined_book_screen.dart @@ -95,6 +95,8 @@ class _CombinedViewState extends State { // 2. זיהוי פרשנים שכבר שויכו לקבוצה final Set alreadyListed = { + ...state.torahShebichtav, + ...state.chazal, ...state.rishonim, ...state.acharonim, ...state.modernCommentators, @@ -112,7 +114,7 @@ class _CombinedViewState extends State { ctx.MenuItem( label: 'חיפוש', onSelected: () => widget.openLeftPaneTab(1)), ctx.MenuItem.submenu( - label: 'פרשנות', + label: 'מפרשים', items: [ ctx.MenuItem( label: 'הצג את כל המפרשים', @@ -135,6 +137,20 @@ class _CombinedViewState extends State { }, ), const ctx.MenuDivider(), + // תורה שבכתב + ..._buildGroup('תורה שבכתב', state.torahShebichtav, state), + + // מוסיפים קו הפרדה רק אם יש גם תורה שבכתב וגם חזל + if (state.torahShebichtav.isNotEmpty && state.chazal.isNotEmpty) + const ctx.MenuDivider(), + + // חזל + ..._buildGroup('חז"ל', state.chazal, state), + + // מוסיפים קו הפרדה רק אם יש גם חזל וגם ראשונים + if (state.chazal.isNotEmpty && state.rishonim.isNotEmpty) + const ctx.MenuDivider(), + // ראשונים ..._buildGroup('ראשונים', state.rishonim, state), @@ -154,7 +170,9 @@ class _CombinedViewState extends State { ..._buildGroup('מחברי זמננו', state.modernCommentators, state), // הוסף קו הפרדה רק אם יש קבוצות אחרות וגם פרשנים לא-משויכים - if ((state.rishonim.isNotEmpty || + if ((state.torahShebichtav.isNotEmpty || + state.chazal.isNotEmpty || + state.rishonim.isNotEmpty || state.acharonim.isNotEmpty || state.modernCommentators.isNotEmpty) && ungrouped.isNotEmpty) diff --git a/lib/text_book/view/commentators_list_screen.dart b/lib/text_book/view/commentators_list_screen.dart index 7d7760f1b..23f4c81e7 100644 --- a/lib/text_book/view/commentators_list_screen.dart +++ b/lib/text_book/view/commentators_list_screen.dart @@ -20,24 +20,29 @@ class CommentatorsListViewState extends State { TextEditingController searchController = TextEditingController(); List selectedTopics = []; List commentatorsList = []; + List _torahShebichtav = []; + List _chazal = []; List _rishonim = []; List _acharonim = []; List _modern = []; List _ungrouped = []; + static const String _torahShebichtavTitle = '__TITLE_TORAH_SHEBICHTAV__'; + static const String _chazalTitle = '__TITLE_CHAZAL__'; static const String _rishonimTitle = '__TITLE_RISHONIM__'; static const String _acharonimTitle = '__TITLE_ACHARONim__'; static const String _modernTitle = '__TITLE_MODERN__'; static const String _ungroupedTitle = '__TITLE_UNGROUPED__'; + static const String _torahShebichtavButton = '__BUTTON_TORAH_SHEBICHTAV__'; + static const String _chazalButton = '__BUTTON_CHAZAL__'; static const String _rishonimButton = '__BUTTON_RISHONIM__'; static const String _acharonimButton = '__BUTTON_ACHARONIM__'; static const String _modernButton = '__BUTTON_MODERN__'; static const String _ungroupedButton = '__BUTTON_UNGROUPED__'; - Future> filterGroup(List group) async { final filteredByQuery = group.where((title) => title.contains(searchController.text)); - + if (selectedTopics.isEmpty) { return filteredByQuery.toList(); } @@ -57,11 +62,15 @@ class CommentatorsListViewState extends State { Future _update(BuildContext context, TextBookLoaded state) async { // סינון הקבוצות הידועות + final torahShebichtav = await filterGroup(state.torahShebichtav); + final chazal = await filterGroup(state.chazal); final rishonim = await filterGroup(state.rishonim); final acharonim = await filterGroup(state.acharonim); final modern = await filterGroup(state.modernCommentators); - + final Set alreadyListed = { + ...torahShebichtav, + ...chazal, ...rishonim, ...acharonim, ...modern, @@ -71,14 +80,26 @@ class CommentatorsListViewState extends State { .toList(); final ungrouped = await filterGroup(ungroupedRaw); + _torahShebichtav = torahShebichtav; + _chazal = chazal; _rishonim = rishonim; _acharonim = acharonim; _modern = modern; _ungrouped = ungrouped; - + // בניית הרשימה עם כותרות לפני כל קבוצה קיימת final List merged = []; - + + if (torahShebichtav.isNotEmpty) { + merged.add(_torahShebichtavTitle); // הוסף כותרת תורה שבכתב + merged.add(_torahShebichtavButton); + merged.addAll(torahShebichtav); + } + if (chazal.isNotEmpty) { + merged.add(_chazalTitle); // הוסף כותרת חזל + merged.add(_chazalButton); + merged.addAll(chazal); + } if (rishonim.isNotEmpty) { merged.add(_rishonimTitle); // הוסף כותרת ראשונים merged.add(_rishonimButton); @@ -99,12 +120,11 @@ class CommentatorsListViewState extends State { merged.add(_ungroupedButton); merged.addAll(ungrouped); } - if (mounted) { - setState(() => commentatorsList = merged); + if (mounted) { + setState(() => commentatorsList = merged); } } - @override Widget build(BuildContext context) { return BlocBuilder(builder: (context, state) { @@ -128,6 +148,8 @@ class CommentatorsListViewState extends State { list != null && list.contains(item), onItemSearch: (item, query) => item == query, listData: [ + 'תורה שבכתב', + 'חז"ל', 'ראשונים', 'אחרונים', 'מחברי זמננו', @@ -177,17 +199,22 @@ class CommentatorsListViewState extends State { ), onChanged: (_) => _update(context, state), ), - + // --- כפתור הכל --- if (commentatorsList.isNotEmpty) CheckboxListTile( - title: const Text('הצג את כל הפרשנים'), // שמרתי את השינוי שלך + title: + const Text('הצג את כל הפרשנים'), // שמרתי את השינוי שלך value: commentatorsList - .where((e) => !e.startsWith('__TITLE_') && !e.startsWith('__BUTTON_')) + .where((e) => + !e.startsWith('__TITLE_') && + !e.startsWith('__BUTTON_')) .every(state.activeCommentators.contains), onChanged: (checked) { final items = commentatorsList - .where((e) => !e.startsWith('__TITLE_') && !e.startsWith('__BUTTON_')) + .where((e) => + !e.startsWith('__TITLE_') && + !e.startsWith('__BUTTON_')) .toList(); if (checked ?? false) { context.read().add(UpdateCommentators( @@ -200,15 +227,59 @@ class CommentatorsListViewState extends State { } }, ), - + // --- רשימת הפרשנים --- Expanded( child: ListView.builder( itemCount: commentatorsList.length, itemBuilder: (context, index) { final item = commentatorsList[index]; - + // בדוק אם הפריט הוא כפתור הצגת קבוצה + if (item == _torahShebichtavButton) { + final allActive = _torahShebichtav + .every(state.activeCommentators.contains); + return CheckboxListTile( + title: const Text('הצג את כל התורה שבכתב'), + value: allActive, + onChanged: (checked) { + final current = + List.from(state.activeCommentators); + if (checked ?? false) { + for (final t in _torahShebichtav) { + if (!current.contains(t)) current.add(t); + } + } else { + current.removeWhere(_torahShebichtav.contains); + } + context + .read() + .add(UpdateCommentators(current)); + }, + ); + } + if (item == _chazalButton) { + final allActive = + _chazal.every(state.activeCommentators.contains); + return CheckboxListTile( + title: const Text('הצג את כל חז"ל'), + value: allActive, + onChanged: (checked) { + final current = + List.from(state.activeCommentators); + if (checked ?? false) { + for (final t in _chazal) { + if (!current.contains(t)) current.add(t); + } + } else { + current.removeWhere(_chazal.contains); + } + context + .read() + .add(UpdateCommentators(current)); + }, + ); + } if (item == _rishonimButton) { final allActive = _rishonim.every(state.activeCommentators.contains); @@ -216,7 +287,8 @@ class CommentatorsListViewState extends State { title: const Text('הצג את כל הראשונים'), value: allActive, onChanged: (checked) { - final current = List.from(state.activeCommentators); + final current = + List.from(state.activeCommentators); if (checked ?? false) { for (final t in _rishonim) { if (!current.contains(t)) current.add(t); @@ -224,7 +296,9 @@ class CommentatorsListViewState extends State { } else { current.removeWhere(_rishonim.contains); } - context.read().add(UpdateCommentators(current)); + context + .read() + .add(UpdateCommentators(current)); }, ); } @@ -235,7 +309,8 @@ class CommentatorsListViewState extends State { title: const Text('הצג את כל האחרונים'), value: allActive, onChanged: (checked) { - final current = List.from(state.activeCommentators); + final current = + List.from(state.activeCommentators); if (checked ?? false) { for (final t in _acharonim) { if (!current.contains(t)) current.add(t); @@ -243,7 +318,9 @@ class CommentatorsListViewState extends State { } else { current.removeWhere(_acharonim.contains); } - context.read().add(UpdateCommentators(current)); + context + .read() + .add(UpdateCommentators(current)); }, ); } @@ -254,7 +331,8 @@ class CommentatorsListViewState extends State { title: const Text('הצג את כל מחברי זמננו'), value: allActive, onChanged: (checked) { - final current = List.from(state.activeCommentators); + final current = + List.from(state.activeCommentators); if (checked ?? false) { for (final t in _modern) { if (!current.contains(t)) current.add(t); @@ -262,7 +340,9 @@ class CommentatorsListViewState extends State { } else { current.removeWhere(_modern.contains); } - context.read().add(UpdateCommentators(current)); + context + .read() + .add(UpdateCommentators(current)); }, ); } @@ -273,7 +353,8 @@ class CommentatorsListViewState extends State { title: const Text('הצג את כל שאר המפרשים'), value: allActive, onChanged: (checked) { - final current = List.from(state.activeCommentators); + final current = + List.from(state.activeCommentators); if (checked ?? false) { for (final t in _ungrouped) { if (!current.contains(t)) current.add(t); @@ -281,7 +362,9 @@ class CommentatorsListViewState extends State { } else { current.removeWhere(_ungrouped.contains); } - context.read().add(UpdateCommentators(current)); + context + .read() + .add(UpdateCommentators(current)); }, ); } @@ -290,6 +373,12 @@ class CommentatorsListViewState extends State { if (item.startsWith('__TITLE_')) { String titleText = ''; switch (item) { + case _torahShebichtavTitle: + titleText = 'תורה שבכתב'; + break; + case _chazalTitle: + titleText = 'חז"ל'; + break; case _rishonimTitle: titleText = 'ראשונים'; break; @@ -303,7 +392,7 @@ class CommentatorsListViewState extends State { titleText = 'שאר מפרשים'; break; } - + // ווידג'ט הכותרת return Padding( padding: const EdgeInsets.symmetric( @@ -331,7 +420,7 @@ class CommentatorsListViewState extends State { ), ); } - + // אם זה לא כותרת, הצג CheckboxListTile רגיל return CheckboxListTile( title: Text(item), @@ -361,4 +450,4 @@ class CommentatorsListViewState extends State { ); }); } -} \ No newline at end of file +} diff --git a/lib/text_book/view/splited_view/simple_book_view.dart b/lib/text_book/view/splited_view/simple_book_view.dart index c15dd495f..0cfcea929 100644 --- a/lib/text_book/view/splited_view/simple_book_view.dart +++ b/lib/text_book/view/splited_view/simple_book_view.dart @@ -109,7 +109,7 @@ class _SimpleBookViewState extends State { ctx.MenuItem( label: 'חיפוש', onSelected: () => widget.openLeftPaneTab(1)), ctx.MenuItem.submenu( - label: 'פרשנות', + label: 'מפרשים', items: [ ctx.MenuItem( label: 'הצג את כל המפרשים', diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index b0f491ff2..e82f1c206 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -1025,7 +1025,7 @@ class _TextBookViewerBlocState extends State textAlign: TextAlign.center))), Tab( child: Center( - child: Text('פרשנות', + child: Text('מפרשים', textAlign: TextAlign.center))), Tab( child: Center( diff --git a/lib/utils/text_manipulation.dart b/lib/utils/text_manipulation.dart index 731af5bb0..2b8fce43e 100644 --- a/lib/utils/text_manipulation.dart +++ b/lib/utils/text_manipulation.dart @@ -38,49 +38,90 @@ Future hasTopic(String title, String topic) async { '$libraryPath${Platform.pathSeparator}אוצריא${Platform.pathSeparator}אודות התוכנה${Platform.pathSeparator}סדר הדורות.csv'; final csvFile = File(csvPath); - print('DEBUG: Checking CSV for title: $title, topic: $topic'); - print('DEBUG: CSV path: $csvPath'); - print('DEBUG: CSV exists: ${await csvFile.exists()}'); - if (await csvFile.exists()) { final csvString = await csvFile.readAsString(); final lines = csvString.split('\n'); - print('DEBUG: CSV has ${lines.length} lines'); // Skip header and search for the book for (int i = 1; i < lines.length; i++) { - final parts = lines[i].split(','); + final line = lines[i].trim(); + if (line.isEmpty) continue; + + // Parse CSV line properly - handle commas inside quoted fields + final parts = _parseCsvLine(line); if (parts.isNotEmpty && parts[0].trim() == title) { - print('DEBUG: Found book in CSV: $title'); - // Found the book, check if topic matches generation or category - if (parts.length >= 3) { + // Found the book, check if topic matches generation + if (parts.length >= 2) { final generation = parts[1].trim(); - final category = parts[2].trim(); - print('DEBUG: Book generation: $generation, category: $category'); - final result = generation == topic || category == topic; - print('DEBUG: Topic match result: $result'); - return result; + + // Map the CSV generation to our categories + final mappedCategory = _mapGenerationToCategory(generation); + return mappedCategory == topic; } } } // Book not found in CSV, it's "פרשנים נוספים" - print('DEBUG: Book not found in CSV, checking if topic is פרשנים נוספים'); return topic == 'פרשנים נוספים'; - } else { - print('DEBUG: CSV file does not exist, falling back to path-based check'); } } catch (e) { - print('DEBUG: Error reading CSV: $e'); // If CSV fails, fall back to path-based check } // Fallback to original path-based logic - print('DEBUG: Using fallback path-based logic'); final titleToPath = await FileSystemData.instance.titleToPath; return titleToPath[title]?.contains(topic) ?? false; } +// Helper function to parse CSV line with proper comma handling +List _parseCsvLine(String line) { + final List result = []; + bool inQuotes = false; + String currentField = ''; + + for (int i = 0; i < line.length; i++) { + final char = line[i]; + + if (char == '"') { + // Handle escaped quotes (double quotes) + if (i + 1 < line.length && line[i + 1] == '"' && inQuotes) { + currentField += '"'; + i++; // Skip the next quote + } else { + inQuotes = !inQuotes; + } + } else if (char == ',' && !inQuotes) { + result.add(currentField.trim()); + currentField = ''; + } else { + currentField += char; + } + } + + // Add the last field + result.add(currentField.trim()); + + return result; +} + +// Helper function to map CSV generation to our categories +String _mapGenerationToCategory(String generation) { + switch (generation) { + case 'תורה שבכתב': + return 'תורה שבכתב'; + case 'חז"ל': + return 'חז"ל'; + case 'ראשונים': + return 'ראשונים'; + case 'אחרונים': + return 'אחרונים'; + case 'מחברי זמננו': + return 'מחברי זמננו'; + default: + return 'פרשנים נוספים'; + } +} + // Matches the Tetragrammaton with any Hebrew diacritics or cantillation marks. final RegExp _holyNameRegex = RegExp( r"י([\p{Mn}]*)ה([\p{Mn}]*)ו([\p{Mn}]*)ה([\p{Mn}]*)", @@ -391,10 +432,10 @@ String replaceParaphrases(String s) { Future>> splitByEra( List titles, ) async { - // יוצרים מבנה נתונים ריק לכל הקטגוריות + // יוצרים מבנה נתונים ריק לכל הקטגוריות החדשות final Map> byEra = { 'תורה שבכתב': [], - 'חזל': [], + 'חז"ל': [], 'ראשונים': [], 'אחרונים': [], 'מחברי זמננו': [], @@ -405,8 +446,8 @@ Future>> splitByEra( for (final t in titles) { if (await hasTopic(t, 'תורה שבכתב')) { byEra['תורה שבכתב']!.add(t); - } else if (await hasTopic(t, 'חזל')) { - byEra['חזל']!.add(t); + } else if (await hasTopic(t, 'חז"ל')) { + byEra['חז"ל']!.add(t); } else if (await hasTopic(t, 'ראשונים')) { byEra['ראשונים']!.add(t); } else if (await hasTopic(t, 'אחרונים')) { From 7a7f5ab27b61cfbb297353f4d9c02ef9d4422b79 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 5 Aug 2025 01:08:01 +0300 Subject: [PATCH 075/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=91?= =?UTF-8?q?=D7=90=D7=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/text_book/bloc/text_book_bloc.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/text_book/bloc/text_book_bloc.dart b/lib/text_book/bloc/text_book_bloc.dart index bd59206a2..0fa5c329b 100644 --- a/lib/text_book/bloc/text_book_bloc.dart +++ b/lib/text_book/bloc/text_book_bloc.dart @@ -91,11 +91,11 @@ class TextBookBloc extends Bloc { showLeftPane: initial.showLeftPane || initial.searchText.isNotEmpty, showSplitView: event.showSplitView, activeCommentators: initial.commentators, // שימוש במשתנה המקומי - torahShebichtav: eras['תורה שבכתב']!, - chazal: eras['חזל']!, - rishonim: eras['ראשונים']!, - acharonim: eras['אחרונים']!, - modernCommentators: eras['מחברי זמננו']!, + torahShebichtav: eras['תורה שבכתב'] ?? [], + chazal: eras['חז"ל'] ?? [], + rishonim: eras['ראשונים'] ?? [], + acharonim: eras['אחרונים'] ?? [], + modernCommentators: eras['מחברי זמננו'] ?? [], removeNikud: removeNikud, visibleIndices: [initial.index], // שימוש במשתנה המקומי pinLeftPane: Settings.getValue('key-pin-sidebar') ?? false, From 736a51392b8654f2e1aa5204dad59202006125b9 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 5 Aug 2025 01:50:38 +0300 Subject: [PATCH 076/197] =?UTF-8?q?=D7=91=D7=99=D7=98=D7=95=D7=9C=20=D7=98?= =?UTF-8?q?=D7=A2=D7=99=D7=A0=D7=94=20=D7=9E=D7=94=D7=99=D7=A8=D7=94=20?= =?UTF-8?q?=D7=99=D7=95=D7=AA=D7=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file_system_data_provider.dart | 94 +------------------ lib/library/models/library.dart | 46 --------- 2 files changed, 2 insertions(+), 138 deletions(-) diff --git a/lib/data/data_providers/file_system_data_provider.dart b/lib/data/data_providers/file_system_data_provider.dart index 71472e28e..000b28f19 100644 --- a/lib/data/data_providers/file_system_data_provider.dart +++ b/lib/data/data_providers/file_system_data_provider.dart @@ -44,84 +44,10 @@ class FileSystemData { /// Reads the library from the configured path and combines it with metadata /// to create a full [Library] object containing all categories and books. Future getLibrary() async { - // --- הגדרת נתיבים --- - final cachePath = '$libraryPath${Platform.pathSeparator}library_cache.json'; - final cacheFile = File(cachePath); - final metadataPath = '$libraryPath${Platform.pathSeparator}metadata.json'; - final metadataFile = File(metadataPath); - - // --- בדיקת תוקף המטמון --- - bool isCacheValid = await cacheFile.exists(); - - if (isCacheValid) { - try { - final cacheLastModified = await cacheFile.stat(); - - // בדיקה רקורסיבית של כל התיקיות והקבצים בספרייה - final libraryDir = - Directory('$libraryPath${Platform.pathSeparator}אוצריא'); - if (await libraryDir.exists()) { - await for (FileSystemEntity entity - in libraryDir.list(recursive: true)) { - final entityStat = await entity.stat(); - if (cacheLastModified.modified.isBefore(entityStat.modified)) { - isCacheValid = false; - break; - } - } - } - - // 3. בדוק אם המטמון ישן יותר מקובץ המטא-דאטה - if (isCacheValid && await metadataFile.exists()) { - final metadataLastModified = await metadataFile.stat(); - if (cacheLastModified.modified - .isBefore(metadataLastModified.modified)) { - isCacheValid = false; // אם כן, המטמון לא תקין - } - } - } catch (_) { - isCacheValid = false; // אם יש שגיאה בבדיקה, נניח שהמטמון לא תקין - } - } - - // --- טעינה מהמטמון (רק אם הוא קיים ותקין) --- - if (isCacheValid) { - try { - final jsonString = await cacheFile.readAsString(); - final jsonMap = await Isolate.run(() => jsonDecode(jsonString)); - - // טוען את הנתיבים מהמטמון - titleToPath = Future.value( - Map.from(jsonMap['titleToPath'] ?? {})); - - // תמיד טוען את המטא-דאטה מחדש מהקובץ כדי להבטיח עדכניות - metadata = _getMetadata(); - - return Library.fromJson(Map.from(jsonMap['library'])); - } catch (_) { - // אם יש שגיאה בקריאה מהמטמון, נסרוק מחדש - } - } - - // --- סריקה מלאה (אם המטמון לא קיים או לא תקין) --- titleToPath = _getTitleToPath(); metadata = _getMetadata(); - final lib = await _getLibraryFromDirectory( + return _getLibraryFromDirectory( '$libraryPath${Platform.pathSeparator}אוצריא', await metadata); - - // --- יצירת קובץ מטמון חדש --- - try { - final jsonMap = { - 'library': lib.toJson(), - 'titleToPath': await titleToPath, - // לא שומרים את המטא-דאטה במטמון כדי שתיטען תמיד מחדש - }; - await cacheFile.writeAsString(jsonEncode(jsonMap)); - } catch (_) { - // מתעלם משגיאות כתיבה למטמון - } - - return lib; } /// Recursively builds the library structure from a directory. @@ -373,8 +299,7 @@ class FileSystemData { final bytes = await file.readAsBytes(); return Isolate.run(() => docxToText(bytes, title)); } else { - final content = await file.readAsString(); - return Isolate.run(() => content); + return file.readAsString(); } } @@ -541,21 +466,6 @@ class FileSystemData { return titleToPath.keys.contains(title); } - /// Clears the library cache to force a full rescan on next load. - /// Useful for development and troubleshooting. - Future clearCache() async { - try { - final cachePath = - '$libraryPath${Platform.pathSeparator}library_cache.json'; - final cacheFile = File(cachePath); - if (await cacheFile.exists()) { - await cacheFile.delete(); - } - } catch (_) { - // מתעלם משגיאות מחיקה - } - } - /// Returns true if the book belongs to Tanach (Torah, Neviim or Ketuvim). /// /// The check is performed by examining the book path and verifying that it diff --git a/lib/library/models/library.dart b/lib/library/models/library.dart index 5c7be08a5..8261f3a5b 100644 --- a/lib/library/models/library.dart +++ b/lib/library/models/library.dart @@ -90,40 +90,8 @@ class Category { required this.books, required this.parent, }); - - Map toJson() { - return { - 'title': title, - 'description': description, - 'shortDescription': shortDescription, - 'order': order, - 'books': books.map((b) => b.toJson()).toList(), - 'subCategories': subCategories.map((c) => c.toJson()).toList(), - }; - } - - factory Category.fromJson(Map json, [Category? parent]) { - final category = Category( - title: json['title'] as String, - description: json['description'] ?? '', - shortDescription: json['shortDescription'] ?? '', - order: json['order'] ?? 999, - subCategories: [], - books: [], - parent: parent, - ); - - category.books = (json['books'] as List? ?? []) - .map((e) => Book.fromJson(Map.from(e))) - .toList(); - category.subCategories = (json['subCategories'] as List? ?? []) - .map((e) => Category.fromJson(Map.from(e), category)) - .toList(); - return category; - } } - /// Represents a library of categories and books. /// /// A library is a top level category that contains other categories. @@ -146,20 +114,6 @@ class Library extends Category { parent = this; } - Map toJson() { - return { - 'subCategories': subCategories.map((c) => c.toJson()).toList(), - }; - } - - factory Library.fromJson(Map json) { - final lib = Library(categories: []); - lib.subCategories = (json['subCategories'] as List? ?? []) - .map((e) => Category.fromJson(Map.from(e), lib)) - .toList(); - return lib; - } - /// Finds a book by its title in the library. /// /// Searches through all books in the library and its subcategories From c600c67de3ad3304876d1f7b036e71558a64dc86 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 5 Aug 2025 02:46:20 +0300 Subject: [PATCH 077/197] =?UTF-8?q?=D7=A9=D7=9E=D7=99=D7=A8=D7=AA=20=D7=97?= =?UTF-8?q?=D7=99=D7=A4=D7=95=D7=A9=D7=99=D7=9D=20=D7=91=D7=94=D7=99=D7=A1?= =?UTF-8?q?=D7=98=D7=95=D7=A8=D7=99=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bookmarks/models/bookmark.dart | 40 +++++++- lib/history/bloc/history_bloc.dart | 97 ++++++++++++++++++- lib/history/history_screen.dart | 107 +++++++++++++++++---- lib/search/view/enhanced_search_field.dart | 8 ++ 4 files changed, 228 insertions(+), 24 deletions(-) diff --git a/lib/bookmarks/models/bookmark.dart b/lib/bookmarks/models/bookmark.dart index d4c7d1ee9..4036d4661 100644 --- a/lib/bookmarks/models/bookmark.dart +++ b/lib/bookmarks/models/bookmark.dart @@ -6,15 +6,23 @@ class Bookmark { final Book book; final List commentatorsToShow; final int index; + final bool isSearch; + final Map>? searchOptions; + final Map>? alternativeWords; + final Map? spacingValues; /// A stable key for history management, unique per book title. - String get historyKey => book.title; + String get historyKey => isSearch ? ref : book.title; Bookmark({ required this.ref, required this.book, required this.index, this.commentatorsToShow = const [], + this.isSearch = false, + this.searchOptions, + this.alternativeWords, + this.spacingValues, }); factory Bookmark.fromJson(Map json) { @@ -23,7 +31,30 @@ class Bookmark { ref: json['ref'] as String, index: json['index'] as int, book: Book.fromJson(json['book'] as Map), - commentatorsToShow: (rawCommentators ?? []).map((e) => e.toString()).toList(), + commentatorsToShow: + (rawCommentators ?? []).map((e) => e.toString()).toList(), + isSearch: json['isSearch'] ?? false, + searchOptions: json['searchOptions'] != null + ? (json['searchOptions'] as Map).map( + (key, value) => MapEntry( + key, + (value as Map) + .map((k, v) => MapEntry(k, v as bool)), + ), + ) + : null, + alternativeWords: json['alternativeWords'] != null + ? (json['alternativeWords'] as Map).map( + (key, value) => MapEntry( + int.parse(key), + (value as List).map((e) => e.toString()).toList(), + ), + ) + : null, + spacingValues: json['spacingValues'] != null + ? (json['spacingValues'] as Map) + .map((key, value) => MapEntry(key, value.toString())) + : null, ); } @@ -33,6 +64,11 @@ class Bookmark { 'book': book.toJson(), 'index': index, 'commentatorsToShow': commentatorsToShow, + 'isSearch': isSearch, + 'searchOptions': searchOptions, + 'alternativeWords': alternativeWords + ?.map((key, value) => MapEntry(key.toString(), value)), + 'spacingValues': spacingValues, }; } } diff --git a/lib/history/bloc/history_bloc.dart b/lib/history/bloc/history_bloc.dart index b73027398..c758c83ac 100644 --- a/lib/history/bloc/history_bloc.dart +++ b/lib/history/bloc/history_bloc.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:otzaria/bookmarks/models/bookmark.dart'; +import 'package:otzaria/models/books.dart'; import 'package:otzaria/history/bloc/history_event.dart'; import 'package:otzaria/history/bloc/history_state.dart'; import 'package:otzaria/history/history_repository.dart'; @@ -63,7 +64,23 @@ class HistoryBloc extends Bloc { } Future _bookmarkFromTab(OpenedTab tab) async { - if (tab is SearchingTab) return null; + if (tab is SearchingTab) { + final searchingTab = tab; + final text = searchingTab.queryController.text; + if (text.trim().isEmpty) return null; + + final formattedQuery = _buildFormattedQuery(searchingTab); + + return Bookmark( + ref: formattedQuery, + book: TextBook(title: text), // Use the original text for the book title + index: 0, // No specific index for a search + isSearch: true, + searchOptions: searchingTab.searchOptions, + alternativeWords: searchingTab.alternativeWords, + spacingValues: searchingTab.spacingValues, + ); + } if (tab is TextBookTab) { final blocState = tab.bloc.state; @@ -90,6 +107,84 @@ class HistoryBloc extends Bloc { return null; } + String _buildFormattedQuery(SearchingTab tab) { + final text = tab.queryController.text; + if (text.trim().isEmpty) return ''; + + final words = text.trim().split(RegExp(r'\\s+')); + final List parts = []; + + const Map optionAbbreviations = { + 'קידומות': 'ק', + 'סיומות': 'ס', + 'קידומות דקדוקיות': 'קד', + 'סיומות דקדוקיות': 'סד', + 'כתיב מלא/חסר': 'מח', + 'חלק ממילה': 'ש', + }; + + const Set suffixOptions = { + 'סיומות', + 'סיומות דקדוקיות', + }; + + for (int i = 0; i < words.length; i++) { + final word = words[i]; + final wordKey = '${word}_$i'; + + final wordOptions = tab.searchOptions[wordKey]; + final selectedOptions = wordOptions?.entries + .where((entry) => entry.value) + .map((entry) => entry.key) + .toList() ?? + []; + + final alternativeWords = tab.alternativeWords[i] ?? []; + + final prefixes = selectedOptions + .where((opt) => !suffixOptions.contains(opt)) + .map((opt) => optionAbbreviations[opt] ?? opt) + .toList(); + + final suffixes = selectedOptions + .where((opt) => suffixOptions.contains(opt)) + .map((opt) => optionAbbreviations[opt] ?? opt) + .toList(); + + String wordPart = ''; + if (prefixes.isNotEmpty) { + wordPart += '(${prefixes.join(',')})'; + } + wordPart += word; + + if (alternativeWords.isNotEmpty) { + wordPart += ' או ${alternativeWords.join(' או ')}'; + } + + if (suffixes.isNotEmpty) { + wordPart += '(${suffixes.join(',')})'; + } + + parts.add(wordPart); + } + + String result = ''; + for (int i = 0; i < parts.length; i++) { + result += parts[i]; + if (i < parts.length - 1) { + final spacingKey = '$i-${i + 1}'; + final spacingValue = tab.spacingValues[spacingKey]; + if (spacingValue != null && spacingValue.isNotEmpty) { + result += ' +$spacingValue '; + } else { + result += ' + '; + } + } + } + + return result; + } + Future _onCaptureStateForHistory( CaptureStateForHistory event, Emitter emit) async { _debounce?.cancel(); diff --git a/lib/history/history_screen.dart b/lib/history/history_screen.dart index e428fbcf3..73ababd53 100644 --- a/lib/history/history_screen.dart +++ b/lib/history/history_screen.dart @@ -8,8 +8,11 @@ import 'package:otzaria/navigation/bloc/navigation_bloc.dart'; import 'package:otzaria/navigation/bloc/navigation_event.dart'; import 'package:otzaria/navigation/bloc/navigation_state.dart'; import 'package:otzaria/tabs/bloc/tabs_bloc.dart'; +import 'package:otzaria/search/bloc/search_bloc.dart'; +import 'package:otzaria/search/bloc/search_event.dart'; import 'package:otzaria/tabs/bloc/tabs_event.dart'; import 'package:otzaria/tabs/models/pdf_tab.dart'; +import 'package:otzaria/tabs/models/searching_tab.dart'; import 'package:otzaria/tabs/models/text_tab.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; @@ -21,14 +24,16 @@ class HistoryView extends StatelessWidget { ? PdfBookTab( book: book, pageNumber: index, - openLeftPane: (Settings.getValue('key-pin-sidebar') ?? false) || + openLeftPane: (Settings.getValue('key-pin-sidebar') ?? + false) || (Settings.getValue('key-default-sidebar-open') ?? false), ) : TextBookTab( book: book as TextBook, index: index, commentators: commentators, - openLeftPane: (Settings.getValue('key-pin-sidebar') ?? false) || + openLeftPane: (Settings.getValue('key-pin-sidebar') ?? + false) || (Settings.getValue('key-default-sidebar-open') ?? false), ); @@ -40,6 +45,22 @@ class HistoryView extends StatelessWidget { } } + Widget? _getLeadingIcon(Book book, bool isSearch) { + if (isSearch) { + return const Icon(Icons.search); + } + if (book is PdfBook) { + if (book.path.toLowerCase().endsWith('.docx')) { + return const Icon(Icons.description); + } + return const Icon(Icons.picture_as_pdf); + } + if (book is TextBook) { + return const Icon(Icons.article); + } + return null; + } + @override Widget build(BuildContext context) { return BlocBuilder( @@ -61,28 +82,72 @@ class HistoryView extends StatelessWidget { Expanded( child: ListView.builder( itemCount: state.history.length, - itemBuilder: (context, index) => ListTile( - leading: state.history[index].book is PdfBook - ? const Icon(Icons.picture_as_pdf) - : null, - title: Text(state.history[index].ref), - onTap: () { - _openBook( + itemBuilder: (context, index) { + final historyItem = state.history[index]; + return ListTile( + leading: + _getLeadingIcon(historyItem.book, historyItem.isSearch), + title: Text(historyItem.ref), + onTap: () { + if (historyItem.isSearch) { + final tabsBloc = context.read(); + SearchingTab searchTab; + try { + searchTab = tabsBloc.state.tabs + .firstWhere((tab) => tab is SearchingTab) + as SearchingTab; + } catch (e) { + searchTab = SearchingTab('חיפוש', null); + tabsBloc.add(AddTab(searchTab)); + } + + // Restore search query and options + searchTab.queryController.text = historyItem.book.title; + searchTab.searchOptions.clear(); + searchTab.searchOptions + .addAll(historyItem.searchOptions ?? {}); + searchTab.alternativeWords.clear(); + searchTab.alternativeWords + .addAll(historyItem.alternativeWords ?? {}); + searchTab.spacingValues.clear(); + searchTab.spacingValues + .addAll(historyItem.spacingValues ?? {}); + + // Trigger search + searchTab.searchBloc.add(UpdateSearchQuery( + searchTab.queryController.text, + customSpacing: searchTab.spacingValues, + alternativeWords: searchTab.alternativeWords, + searchOptions: searchTab.searchOptions, + )); + + // Navigate to search screen + context + .read() + .add(const NavigateToScreen(Screen.search)); + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + return; + } + _openBook( context, - state.history[index].book, - state.history[index].index, - state.history[index].commentatorsToShow); - }, - trailing: IconButton( - icon: const Icon(Icons.delete_forever), - onPressed: () { - context.read().add(RemoveHistory(index)); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('נמחק בהצלחה')), + historyItem.book, + historyItem.index, + historyItem.commentatorsToShow, ); }, - ), - ), + trailing: IconButton( + icon: const Icon(Icons.delete_forever), + onPressed: () { + context.read().add(RemoveHistory(index)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('נמחק בהצלחה')), + ); + }, + ), + ); + }, ), ), Padding( diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index ddcc0eb56..0164a8a6f 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:otzaria/history/bloc/history_bloc.dart'; +import 'package:otzaria/history/bloc/history_event.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:otzaria/search/bloc/search_bloc.dart'; @@ -1496,6 +1498,9 @@ class _EnhancedSearchFieldState extends State { }); }, onSubmitted: (e) { + context + .read() + .add(AddHistory(widget.widget.tab)); context.read().add(UpdateSearchQuery(e, customSpacing: widget.widget.tab.spacingValues, alternativeWords: widget.widget.tab.alternativeWords, @@ -1508,6 +1513,9 @@ class _EnhancedSearchFieldState extends State { labelText: "לחיפוש הקש אנטר או לחץ על סמל החיפוש", prefixIcon: IconButton( onPressed: () { + context + .read() + .add(AddHistory(widget.widget.tab)); context.read().add(UpdateSearchQuery( widget.widget.tab.queryController.text, customSpacing: widget.widget.tab.spacingValues, From 128686b2f123ad45179b86f687192987ddab17b4 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 5 Aug 2025 11:39:11 +0300 Subject: [PATCH 078/197] =?UTF-8?q?=D7=A9=D7=99=D7=A4=D7=95=D7=A8=20=D7=95?= =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=9C=D7=95=D7=97=20=D7=94?= =?UTF-8?q?=D7=A9=D7=A0=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/navigation/calendar_cubit.dart | 129 +++++----------- lib/navigation/calendar_widget.dart | 187 +++++++++++++++-------- lib/navigation/more_screen.dart | 222 ++++++++++------------------ 3 files changed, 238 insertions(+), 300 deletions(-) diff --git a/lib/navigation/calendar_cubit.dart b/lib/navigation/calendar_cubit.dart index a9d360c7a..b6f1dfb4b 100644 --- a/lib/navigation/calendar_cubit.dart +++ b/lib/navigation/calendar_cubit.dart @@ -28,7 +28,7 @@ class CalendarState extends Equatable { factory CalendarState.initial() { final now = DateTime.now(); final jewishNow = JewishDate(); - + return CalendarState( selectedJewishDate: jewishNow, selectedGregorianDate: now, @@ -51,7 +51,8 @@ class CalendarState extends Equatable { }) { return CalendarState( selectedJewishDate: selectedJewishDate ?? this.selectedJewishDate, - selectedGregorianDate: selectedGregorianDate ?? this.selectedGregorianDate, + selectedGregorianDate: + selectedGregorianDate ?? this.selectedGregorianDate, selectedCity: selectedCity ?? this.selectedCity, dailyTimes: dailyTimes ?? this.dailyTimes, currentJewishDate: currentJewishDate ?? this.currentJewishDate, @@ -60,13 +61,12 @@ class CalendarState extends Equatable { ); } - @override List get props => [ selectedJewishDate.getJewishYear(), selectedJewishDate.getJewishMonth(), selectedJewishDate.getJewishDayOfMonth(), - + selectedGregorianDate, selectedCity, dailyTimes, @@ -75,7 +75,7 @@ class CalendarState extends Equatable { currentJewishDate.getJewishYear(), currentJewishDate.getJewishMonth(), currentJewishDate.getJewishDayOfMonth(), - + currentGregorianDate, calendarType ]; @@ -194,94 +194,41 @@ const Map> cityCoordinates = { // Calculate daily times function Map _calculateDailyTimes(DateTime date, String city) { - final targetDate = date; - final isSummer = targetDate.month >= 4 && targetDate.month <= 9; - - print('Calculating times for date: ${targetDate.day}/${targetDate.month}/${targetDate.year}, city: $city'); - - final dayOfYear = targetDate.difference(DateTime(targetDate.year, 1, 1)).inDays; - final seasonalAdjustment = _getSeasonalAdjustment(dayOfYear); - - Map baseTimes; - final cityData = cityCoordinates[city]!; - final isJerusalem = city == 'ירושלים'; - - if (isJerusalem) { - baseTimes = isSummer - ? { - 'alos': _adjustTime('04:20', seasonalAdjustment), - 'sunrise': _adjustTime('05:45', seasonalAdjustment), - 'sofZmanShma': _adjustTime('09:00', seasonalAdjustment), - 'sofZmanTfila': _adjustTime('10:15', seasonalAdjustment), - 'chatzos': _adjustTime('12:45', seasonalAdjustment), - 'minchaGedola': _adjustTime('13:30', seasonalAdjustment), - 'minchaKetana': _adjustTime('17:15', seasonalAdjustment), - 'plagHamincha': _adjustTime('18:30', seasonalAdjustment), - 'sunset': _adjustTime('19:45', seasonalAdjustment), - 'tzais': _adjustTime('20:30', seasonalAdjustment), - } - : { - 'alos': _adjustTime('05:45', seasonalAdjustment), - 'sunrise': _adjustTime('06:30', seasonalAdjustment), - 'sofZmanShma': _adjustTime('09:15', seasonalAdjustment), - 'sofZmanTfila': _adjustTime('10:00', seasonalAdjustment), - 'chatzos': _adjustTime('12:00', seasonalAdjustment), - 'minchaGedola': _adjustTime('12:30', seasonalAdjustment), - 'minchaKetana': _adjustTime('15:00', seasonalAdjustment), - 'plagHamincha': _adjustTime('16:15', seasonalAdjustment), - 'sunset': _adjustTime('17:30', seasonalAdjustment), - 'tzais': _adjustTime('18:15', seasonalAdjustment), - }; - } else { - final latAdjustment = ((cityData['lat']! - 31.7683) * 2).round(); - baseTimes = isSummer - ? { - 'alos': _adjustTime('04:30', seasonalAdjustment + latAdjustment), - 'sunrise': _adjustTime('05:50', seasonalAdjustment + latAdjustment), - 'sofZmanShma': _adjustTime('09:10', seasonalAdjustment + latAdjustment), - 'sofZmanTfila': _adjustTime('10:20', seasonalAdjustment + latAdjustment), - 'chatzos': _adjustTime('12:50', seasonalAdjustment + latAdjustment), - 'minchaGedola': _adjustTime('13:35', seasonalAdjustment + latAdjustment), - 'minchaKetana': _adjustTime('17:20', seasonalAdjustment + latAdjustment), - 'plagHamincha': _adjustTime('18:35', seasonalAdjustment + latAdjustment), - 'sunset': _adjustTime('19:50', seasonalAdjustment + latAdjustment), - 'tzais': _adjustTime('20:35', seasonalAdjustment + latAdjustment), - } - : { - 'alos': _adjustTime('05:50', seasonalAdjustment + latAdjustment), - 'sunrise': _adjustTime('06:35', seasonalAdjustment + latAdjustment), - 'sofZmanShma': _adjustTime('09:20', seasonalAdjustment + latAdjustment), - 'sofZmanTfila': _adjustTime('10:05', seasonalAdjustment + latAdjustment), - 'chatzos': _adjustTime('12:05', seasonalAdjustment + latAdjustment), - 'minchaGedola': _adjustTime('12:35', seasonalAdjustment + latAdjustment), - 'minchaKetana': _adjustTime('15:05', seasonalAdjustment + latAdjustment), - 'plagHamincha': _adjustTime('16:20', seasonalAdjustment + latAdjustment), - 'sunset': _adjustTime('17:35', seasonalAdjustment + latAdjustment), - 'tzais': _adjustTime('18:20', seasonalAdjustment + latAdjustment), - }; + print( + 'Calculating times for date: ${date.day}/${date.month}/${date.year}, city: $city'); + final cityData = cityCoordinates[city]; + if (cityData == null) { + return {}; } - return baseTimes; + final locationName = city; + final latitude = cityData['lat']!; + final longitude = cityData['lng']!; + final elevation = cityData['elevation']!; + + final location = GeoLocation(); + location.setLocationName(locationName); + location.setLatitude(latitude: latitude); + location.setLongitude(longitude: longitude); + location.setDateTime(date); + location.setElevation(elevation); + + final zmanimCalendar = ZmanimCalendar.intGeolocation(location); + + return { + 'alos': _formatTime(zmanimCalendar.getAlosHashachar()!), + 'sunrise': _formatTime(zmanimCalendar.getSunrise()!), + 'sofZmanShma': _formatTime(zmanimCalendar.getSofZmanShmaGRA()!), + 'sofZmanTfila': _formatTime(zmanimCalendar.getSofZmanTfilaGRA()!), + 'chatzos': _formatTime(zmanimCalendar.getChatzos()!), + 'minchaGedola': _formatTime(zmanimCalendar.getMinchaGedola()!), + 'minchaKetana': _formatTime(zmanimCalendar.getMinchaKetana()!), + 'plagHamincha': _formatTime(zmanimCalendar.getPlagHamincha()!), + 'sunset': _formatTime(zmanimCalendar.getSunset()!), + 'tzais': _formatTime(zmanimCalendar.getTzais()!), + }; } -int _getSeasonalAdjustment(int dayOfYear) { - if (dayOfYear < 80 || dayOfYear > 300) { - return -15; // Winter - earlier times - } else if (dayOfYear > 120 && dayOfYear < 260) { - return 15; // Summer - later times - } else { - return 0; // Spring/Fall - } +String _formatTime(DateTime dt) { + return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; } - -String _adjustTime(String timeStr, int adjustmentMinutes) { - final parts = timeStr.split(':'); - final hour = int.parse(parts[0]); - final minute = int.parse(parts[1]); - - final totalMinutes = hour * 60 + minute + adjustmentMinutes; - final adjustedHour = (totalMinutes ~/ 60) % 24; - final adjustedMinute = totalMinutes % 60; - - return '${adjustedHour.toString().padLeft(2, '0')}:${adjustedMinute.toString().padLeft(2, '0')}'; -} \ No newline at end of file diff --git a/lib/navigation/calendar_widget.dart b/lib/navigation/calendar_widget.dart index c404690fe..a87b607af 100644 --- a/lib/navigation/calendar_widget.dart +++ b/lib/navigation/calendar_widget.dart @@ -9,12 +9,28 @@ class CalendarWidget extends StatelessWidget { // העברנו את רשימות הקבועים לכאן כדי שיהיו זמינים final List hebrewMonths = const [ - 'ניסן', 'אייר', 'סיון', 'תמוז', 'אב', 'אלול', - 'תשרי', 'חשון', 'כסלו', 'טבת', 'שבט', 'אדר' + 'ניסן', + 'אייר', + 'סיון', + 'תמוז', + 'אב', + 'אלול', + 'תשרי', + 'חשון', + 'כסלו', + 'טבת', + 'שבט', + 'אדר' ]; final List hebrewDays = const [ - 'ראשון', 'שני', 'שלישי', 'רביעי', 'חמישי', 'שישי', 'שבת' + 'ראשון', + 'שני', + 'שלישי', + 'רביעי', + 'חמישי', + 'שישי', + 'שבת' ]; @override @@ -163,7 +179,8 @@ class CalendarWidget extends StatelessWidget { ); final startingWeekday = firstDayOfMonth.getGregorianCalendar().weekday % 7; - List dayWidgets = List.generate(startingWeekday, (_) => const SizedBox()); + List dayWidgets = + List.generate(startingWeekday, (_) => const SizedBox()); for (int day = 1; day <= daysInMonth; day++) { dayWidgets.add(_buildHebrewDayCell(context, state, day)); @@ -171,42 +188,51 @@ class CalendarWidget extends StatelessWidget { List rows = []; for (int i = 0; i < dayWidgets.length; i += 7) { - final rowWidgets = dayWidgets.sublist(i, i + 7 > dayWidgets.length ? dayWidgets.length : i + 7); - while(rowWidgets.length < 7) { + final rowWidgets = dayWidgets.sublist( + i, i + 7 > dayWidgets.length ? dayWidgets.length : i + 7); + while (rowWidgets.length < 7) { rowWidgets.add(const SizedBox()); } - rows.add(Row(children: rowWidgets.map((w) => Expanded(child: w)).toList())); + rows.add( + Row(children: rowWidgets.map((w) => Expanded(child: w)).toList())); } return Column(children: rows); } - Widget _buildGregorianCalendarDays(BuildContext context, CalendarState state) { + Widget _buildGregorianCalendarDays( + BuildContext context, CalendarState state) { final currentGregorianDate = state.currentGregorianDate; - final firstDayOfMonth = DateTime(currentGregorianDate.year, currentGregorianDate.month, 1); - final lastDayOfMonth = DateTime(currentGregorianDate.year, currentGregorianDate.month + 1, 0); + final firstDayOfMonth = + DateTime(currentGregorianDate.year, currentGregorianDate.month, 1); + final lastDayOfMonth = + DateTime(currentGregorianDate.year, currentGregorianDate.month + 1, 0); final daysInMonth = lastDayOfMonth.day; final startingWeekday = firstDayOfMonth.weekday % 7; - List dayWidgets = List.generate(startingWeekday, (_) => const SizedBox()); - + List dayWidgets = + List.generate(startingWeekday, (_) => const SizedBox()); + for (int day = 1; day <= daysInMonth; day++) { dayWidgets.add(_buildGregorianDayCell(context, state, day)); } - + List rows = []; for (int i = 0; i < dayWidgets.length; i += 7) { - final rowWidgets = dayWidgets.sublist(i, i + 7 > dayWidgets.length ? dayWidgets.length : i + 7); - while(rowWidgets.length < 7) { + final rowWidgets = dayWidgets.sublist( + i, i + 7 > dayWidgets.length ? dayWidgets.length : i + 7); + while (rowWidgets.length < 7) { rowWidgets.add(const SizedBox()); } - rows.add(Row(children: rowWidgets.map((w) => Expanded(child: w)).toList())); + rows.add( + Row(children: rowWidgets.map((w) => Expanded(child: w)).toList())); } return Column(children: rows); } - Widget _buildHebrewDayCell(BuildContext context, CalendarState state, int day) { + Widget _buildHebrewDayCell( + BuildContext context, CalendarState state, int day) { final jewishDate = JewishDate(); jewishDate.setJewishDate( state.currentJewishDate.getJewishYear(), @@ -216,16 +242,19 @@ class CalendarWidget extends StatelessWidget { final gregorianDate = jewishDate.getGregorianCalendar(); final isSelected = state.selectedJewishDate.getJewishDayOfMonth() == day && - state.selectedJewishDate.getJewishMonth() == jewishDate.getJewishMonth() && + state.selectedJewishDate.getJewishMonth() == + jewishDate.getJewishMonth() && state.selectedJewishDate.getJewishYear() == jewishDate.getJewishYear(); return GestureDetector( - onTap: () => context.read().selectDate(jewishDate, gregorianDate), + onTap: () => + context.read().selectDate(jewishDate, gregorianDate), child: Container( margin: const EdgeInsets.all(2), height: 50, decoration: BoxDecoration( - color: isSelected ? Theme.of(context).primaryColor : Colors.transparent, + color: + isSelected ? Theme.of(context).primaryColor : Colors.transparent, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey.shade300, width: 1), ), @@ -238,7 +267,8 @@ class CalendarWidget extends StatelessWidget { style: TextStyle( color: isSelected ? Colors.white : Colors.black, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - fontSize: state.calendarType == CalendarType.combined ? 14 : 16, + fontSize: + state.calendarType == CalendarType.combined ? 14 : 16, ), ), if (state.calendarType == CalendarType.combined) @@ -256,8 +286,10 @@ class CalendarWidget extends StatelessWidget { ); } - Widget _buildGregorianDayCell(BuildContext context, CalendarState state, int day) { - final gregorianDate = DateTime(state.currentGregorianDate.year, state.currentGregorianDate.month, day); + Widget _buildGregorianDayCell( + BuildContext context, CalendarState state, int day) { + final gregorianDate = DateTime( + state.currentGregorianDate.year, state.currentGregorianDate.month, day); final jewishDate = JewishDate.fromDateTime(gregorianDate); final isSelected = state.selectedGregorianDate.day == day && @@ -265,12 +297,14 @@ class CalendarWidget extends StatelessWidget { state.selectedGregorianDate.year == gregorianDate.year; return GestureDetector( - onTap: () => context.read().selectDate(jewishDate, gregorianDate), + onTap: () => + context.read().selectDate(jewishDate, gregorianDate), child: Container( margin: const EdgeInsets.all(2), height: 50, decoration: BoxDecoration( - color: isSelected ? Theme.of(context).primaryColor : Colors.transparent, + color: + isSelected ? Theme.of(context).primaryColor : Colors.transparent, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey.shade300, width: 1), ), @@ -283,7 +317,8 @@ class CalendarWidget extends StatelessWidget { style: TextStyle( color: isSelected ? Colors.white : Colors.black, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - fontSize: state.calendarType == CalendarType.combined ? 14 : 16, + fontSize: + state.calendarType == CalendarType.combined ? 14 : 16, ), ), if (state.calendarType == CalendarType.combined) @@ -301,7 +336,8 @@ class CalendarWidget extends StatelessWidget { ); } - Widget _buildDayDetailsWithoutEvents(BuildContext context, CalendarState state) { + Widget _buildDayDetailsWithoutEvents( + BuildContext context, CalendarState state) { return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -425,14 +461,16 @@ class CalendarWidget extends StatelessWidget { children: [ Text( timeData['name']!, - style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + style: + const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( timeData['time'] ?? '--:--', - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + style: + const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), ), ], ), @@ -440,7 +478,7 @@ class CalendarWidget extends StatelessWidget { }, ); } - + // פונקציות העזר שלא תלויות במצב נשארות כאן String _getCurrentMonthYearText(CalendarState state) { if (state.calendarType == CalendarType.gregorian) { @@ -471,14 +509,22 @@ class CalendarWidget extends StatelessWidget { int num = number; if (num >= 100) { int hundreds = (num ~/ 100) * 100; - if (hundreds == 900) result += 'תתק'; - else if (hundreds == 800) result += 'תת'; - else if (hundreds == 700) result += 'תש'; - else if (hundreds == 600) result += 'תר'; - else if (hundreds == 500) result += 'תק'; - else if (hundreds == 400) result += 'ת'; - else if (hundreds == 300) result += 'ש'; - else if (hundreds == 200) result += 'ר'; + if (hundreds == 900) + result += 'תתק'; + else if (hundreds == 800) + result += 'תת'; + else if (hundreds == 700) + result += 'תש'; + else if (hundreds == 600) + result += 'תר'; + else if (hundreds == 500) + result += 'תק'; + else if (hundreds == 400) + result += 'ת'; + else if (hundreds == 300) + result += 'ש'; + else if (hundreds == 200) + result += 'ר'; else if (hundreds == 100) result += 'ק'; num %= 100; } @@ -502,43 +548,54 @@ class CalendarWidget extends StatelessWidget { String _getGregorianMonthName(int month) { const months = [ - 'ינואר', 'פברואר', 'מרץ', 'אפריל', 'מאי', 'יוני', - 'יולי', 'אוגוסט', 'ספטמבר', 'אוקטובר', 'נובמבר', 'דצמבר' + 'ינואר', + 'פברואר', + 'מרץ', + 'אפריל', + 'מאי', + 'יוני', + 'יולי', + 'אוגוסט', + 'ספטמבר', + 'אוקטובר', + 'נובמבר', + 'דצמבר' ]; return months[month - 1]; } - + // החלק של האירועים עדיין לא עבר ריפקטורינג, הוא יישאר לא פעיל בינתיים Widget _buildEventsCard(BuildContext context, CalendarState state) { return Card( child: Padding( padding: const EdgeInsets.all(16.0), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.event), - const SizedBox(width: 8), - const Text( - 'אירועים', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), - const Spacer(), - ElevatedButton.icon( - onPressed: () { /* TODO: Implement create event with cubit */ }, - icon: const Icon(Icons.add, size: 16), - label: const Text('צור אירוע'), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - textStyle: const TextStyle(fontSize: 12), - ), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.event), + const SizedBox(width: 8), + const Text( + 'אירועים', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const Spacer(), + ElevatedButton.icon( + onPressed: () {/* TODO: Implement create event with cubit */}, + icon: const Icon(Icons.add, size: 16), + label: const Text('צור אירוע'), + style: ElevatedButton.styleFrom( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + textStyle: const TextStyle(fontSize: 12), ), - ], - ), - const SizedBox(height: 16), - const Text('אין אירועים ליום זה'), - ], + ), + ], + ), + const SizedBox(height: 16), + const Center(child: Text('אין אירועים ליום זה')), + ], ), ), ); diff --git a/lib/navigation/more_screen.dart b/lib/navigation/more_screen.dart index 6e85546c8..12099cc19 100644 --- a/lib/navigation/more_screen.dart +++ b/lib/navigation/more_screen.dart @@ -11,104 +11,98 @@ class MoreScreen extends StatefulWidget { } class _MoreScreenState extends State { - Widget? currentWidget; + int _selectedIndex = 0; @override Widget build(BuildContext context) { - // אם currentWidget אינו null, הצג אותו. אחרת, הצג את התפריט. - if (currentWidget != null) { - return currentWidget!; - } - - // זהו מסך התפריט הראשי return Scaffold( appBar: AppBar( - title: const Text('עוד'), + title: Text(_getTitle(_selectedIndex)), centerTitle: true, + actions: _getActions(context, _selectedIndex), ), - body: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - _buildToolItem( - context, - icon: Icons.calendar_today, - title: 'לוח שנה', - subtitle: 'לוח שנה עברי ולועזי', - onTap: () => _showCalendar(), - ), - const SizedBox(height: 16), - _buildToolItem( - context, - icon: Icons.straighten, - title: 'ממיר מידות', - subtitle: 'המרת מידות ומשקולות', - onTap: () => _showComingSoon(context, 'ממיר מידות ומשקולות'), - ), - const SizedBox(height: 16), - _buildToolItem( - context, - icon: Icons.calculate, - title: 'גימטריות', - subtitle: 'חישובי גימטריה', - onTap: () => _showComingSoon(context, 'גימטריות'), - ), - ], - ), + body: Row( + children: [ + NavigationRail( + selectedIndex: _selectedIndex, + onDestinationSelected: (int index) { + setState(() { + _selectedIndex = index; + }); + }, + labelType: NavigationRailLabelType.all, + destinations: const [ + NavigationRailDestination( + icon: Icon(Icons.calendar_today), + label: Text('לוח שנה'), + ), + NavigationRailDestination( + icon: Icon(Icons.straighten), + label: Text('ממיר מידות'), + ), + NavigationRailDestination( + icon: Icon(Icons.calculate), + label: Text('גימטריות'), + ), + ], + ), + const VerticalDivider(thickness: 1, width: 1), + Expanded( + child: _buildCurrentWidget(_selectedIndex), + ), + ], ), ); } - // פונקציה זו בונה את מסך לוח השנה - void _showCalendar() { - setState(() { - currentWidget = BlocProvider( - create: (context) => CalendarCubit(), - child: Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () { - setState(() { - currentWidget = null; // חזור למסך התפריט - }); - }, - ), - title: BlocBuilder( - builder: (context, state) { - switch (state.calendarType) { - case CalendarType.hebrew: - return const Text('לוח שנה עברי'); - case CalendarType.gregorian: - return const Text('לוח שנה לועזי'); - case CalendarType.combined: - return const Text('לוח שנה משולב'); - } - }, - ), - centerTitle: true, - actions: [ - Builder( - builder: (context) { - return IconButton( - icon: const Icon(Icons.settings), - onPressed: () => _showSettingsDialog(context), - ); - } - ), - ], - ), - body: const CalendarWidget(), + String _getTitle(int index) { + switch (index) { + case 0: + return 'לוח שנה'; + case 1: + return 'ממיר מידות'; + case 2: + return 'גימטריות'; + default: + return 'עוד'; + } + } + + List? _getActions(BuildContext context, int index) { + if (index == 0) { + return [ + Builder( + builder: (context) { + return IconButton( + icon: const Icon(Icons.settings), + onPressed: () => _showSettingsDialog(context), + ); + }, ), - ); - }); + ]; + } + return null; + } + + Widget _buildCurrentWidget(int index) { + switch (index) { + case 0: + return BlocProvider( + create: (context) => CalendarCubit(), + child: const CalendarWidget(), + ); + case 1: + return const Center(child: Text('ממיר מידות - בקרוב...')); + case 2: + return const Center(child: Text('גימטריות - בקרוב...')); + default: + return Container(); + } } - // פונקציה זו מציגה את דיאלוג ההגדרות void _showSettingsDialog(BuildContext context) { final calendarCubit = context.read(); - + showDialog( context: context, builder: (dialogContext) { @@ -136,7 +130,7 @@ class _MoreScreenState extends State { value: CalendarType.gregorian, groupValue: state.calendarType, onChanged: (value) { - if (value != null) { + if (value != null) { calendarCubit.changeCalendarType(value); } Navigator.of(dialogContext).pop(); @@ -147,7 +141,7 @@ class _MoreScreenState extends State { value: CalendarType.combined, groupValue: state.calendarType, onChanged: (value) { - if (value != null) { + if (value != null) { calendarCubit.changeCalendarType(value); } Navigator.of(dialogContext).pop(); @@ -167,64 +161,4 @@ class _MoreScreenState extends State { }, ); } - - // שאר הפונקציות נשארות כפי שהיו - Widget _buildToolItem( - BuildContext context, { - required IconData icon, - required String title, - required String subtitle, - required VoidCallback onTap, - }) { - return Align( - alignment: Alignment.centerRight, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(16), - child: Container( - width: 110, - padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.grey.shade300), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 32, color: Theme.of(context).primaryColor), - const SizedBox(height: 8), - Text( - title, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - ), - const SizedBox(height: 2), - Text( - subtitle, - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ); - } - - void _showComingSoon(BuildContext context, String feature) { - showDialog( - context: context, - builder: (_) => AlertDialog( - title: Text(feature), - content: const Text('בקרוב...'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('אישור'), - ), - ], - ), - ); - } } From 9788c9ba0234561b6f9f3b15b7175ac2d3d7d4b8 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 5 Aug 2025 13:38:17 +0300 Subject: [PATCH 079/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=9E?= =?UTF-8?q?=D7=97=D7=A9=D7=91=D7=95=D7=9F=20=D7=94=D7=9E=D7=A8=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/navigation/more_screen.dart | 3 +- .../measurement_converter_screen.dart | 242 ++++++++ .../measurement_data.dart | 562 ++++++++++++++++++ 3 files changed, 806 insertions(+), 1 deletion(-) create mode 100644 lib/tools/measurement_converter/measurement_converter_screen.dart create mode 100644 lib/tools/measurement_converter/measurement_data.dart diff --git a/lib/navigation/more_screen.dart b/lib/navigation/more_screen.dart index 12099cc19..4788230a6 100644 --- a/lib/navigation/more_screen.dart +++ b/lib/navigation/more_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:otzaria/tools/measurement_converter/measurement_converter_screen.dart'; import 'calendar_widget.dart'; import 'calendar_cubit.dart'; @@ -92,7 +93,7 @@ class _MoreScreenState extends State { child: const CalendarWidget(), ); case 1: - return const Center(child: Text('ממיר מידות - בקרוב...')); + return const MeasurementConverterScreen(); case 2: return const Center(child: Text('גימטריות - בקרוב...')); default: diff --git a/lib/tools/measurement_converter/measurement_converter_screen.dart b/lib/tools/measurement_converter/measurement_converter_screen.dart new file mode 100644 index 000000000..ec3d2258d --- /dev/null +++ b/lib/tools/measurement_converter/measurement_converter_screen.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'measurement_data.dart'; + +class MeasurementConverterScreen extends StatefulWidget { + const MeasurementConverterScreen({super.key}); + + @override + State createState() => + _MeasurementConverterScreenState(); +} + +class _MeasurementConverterScreenState + extends State { + String _selectedCategory = 'אורך'; + String? _selectedFromUnit; + String? _selectedToUnit; + String? _selectedOpinion; + final TextEditingController _inputController = TextEditingController(); + final TextEditingController _resultController = TextEditingController(); + + final Map> _units = { + 'אורך': lengthConversionFactors.keys.toList(), + 'שטח': areaConversionFactors.keys.toList(), + 'נפח': volumeConversionFactors.keys.toList(), + 'משקל': weightConversionFactors.keys.toList(), + 'זמן': timeConversionFactors.keys.first.isNotEmpty + ? timeConversionFactors[timeConversionFactors.keys.first]!.keys.toList() + : [], + }; + + final Map> _opinions = { + 'אורך': modernLengthFactors.keys.toList(), + 'שטח': modernAreaFactors.keys.toList(), + 'נפח': modernVolumeFactors.keys.toList(), + 'משקל': modernWeightFactors.keys.toList(), + 'זמן': timeConversionFactors.keys.toList(), + }; + + @override + void initState() { + super.initState(); + _resetDropdowns(); + } + + void _resetDropdowns() { + setState(() { + _selectedFromUnit = _units[_selectedCategory]!.first; + _selectedToUnit = _units[_selectedCategory]!.first; + _selectedOpinion = _opinions[_selectedCategory]?.first; + _inputController.clear(); + _resultController.clear(); + }); + } + + void _convert() { + final double? input = double.tryParse(_inputController.text); + if (input == null || + _selectedFromUnit == null || + _selectedToUnit == null || + _inputController.text.isEmpty) { + setState(() { + _resultController.clear(); + }); + return; + } + + double conversionFactor = 1.0; + + switch (_selectedCategory) { + case 'אורך': + conversionFactor = + lengthConversionFactors[_selectedFromUnit]![_selectedToUnit]!; + break; + case 'שטח': + conversionFactor = + areaConversionFactors[_selectedFromUnit]![_selectedToUnit]!; + break; + case 'נפח': + conversionFactor = + volumeConversionFactors[_selectedFromUnit]![_selectedToUnit]!; + break; + case 'משקל': + conversionFactor = + weightConversionFactors[_selectedFromUnit]![_selectedToUnit]!; + break; + case 'זמן': + if (_selectedOpinion != null) { + final fromFactor = + timeConversionFactors[_selectedOpinion]![_selectedFromUnit]!; + final toFactor = + timeConversionFactors[_selectedOpinion]![_selectedToUnit]!; + conversionFactor = fromFactor / toFactor; + } + break; + } + + setState(() { + final result = input * conversionFactor; + _resultController.text = result.toStringAsFixed(4); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('ממיר מידות'), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildCategorySelector(), + const SizedBox(height: 20), + _buildUnitSelectors(), + const SizedBox(height: 20), + if (_opinions.containsKey(_selectedCategory) && + _opinions[_selectedCategory]!.isNotEmpty) + _buildOpinionSelector(), + const SizedBox(height: 20), + _buildInputField(), + const SizedBox(height: 20), + _buildResultDisplay(), + ], + ), + ), + ); + } + + Widget _buildCategorySelector() { + return DropdownButtonFormField( + value: _selectedCategory, + decoration: const InputDecoration( + labelText: 'קטגוריה', + border: OutlineInputBorder(), + ), + items: _units.keys.map((String category) { + return DropdownMenuItem( + value: category, + child: Text(category), + ); + }).toList(), + onChanged: (String? newValue) { + if (newValue != null) { + setState(() { + _selectedCategory = newValue; + _resetDropdowns(); + }); + } + }, + ); + } + + Widget _buildUnitSelectors() { + return Row( + children: [ + Expanded( + child: _buildDropdown('מ', _selectedFromUnit, (val) { + setState(() => _selectedFromUnit = val); + _convert(); + })), + const SizedBox(width: 10), + const Icon(Icons.arrow_forward), + const SizedBox(width: 10), + Expanded( + child: _buildDropdown('אל', _selectedToUnit, (val) { + setState(() => _selectedToUnit = val); + _convert(); + })), + ], + ); + } + + Widget _buildDropdown( + String label, String? value, ValueChanged onChanged) { + return DropdownButtonFormField( + value: value, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + items: _units[_selectedCategory]!.map((String unit) { + return DropdownMenuItem( + value: unit, + child: Text(unit), + ); + }).toList(), + onChanged: onChanged, + ); + } + + Widget _buildOpinionSelector() { + return DropdownButtonFormField( + value: _selectedOpinion, + decoration: const InputDecoration( + labelText: 'שיטה', + border: OutlineInputBorder(), + ), + items: _opinions[_selectedCategory]!.map((String opinion) { + return DropdownMenuItem( + value: opinion, + child: Text(opinion), + ); + }).toList(), + onChanged: (String? newValue) { + setState(() { + _selectedOpinion = newValue; + _convert(); + }); + }, + ); + } + + Widget _buildInputField() { + return TextField( + controller: _inputController, + decoration: const InputDecoration( + labelText: 'ערך להמרה', + border: OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')), + ], + onChanged: (value) => _convert(), + ); + } + + Widget _buildResultDisplay() { + return TextField( + controller: _resultController, + readOnly: true, + decoration: const InputDecoration( + labelText: 'תוצאה', + border: OutlineInputBorder(), + ), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ); + } +} diff --git a/lib/tools/measurement_converter/measurement_data.dart b/lib/tools/measurement_converter/measurement_data.dart new file mode 100644 index 000000000..38ef56dc4 --- /dev/null +++ b/lib/tools/measurement_converter/measurement_data.dart @@ -0,0 +1,562 @@ +// ignore_for_file: constant_identifier_names + +const Map> lengthConversionFactors = { + 'אצבעות': { + 'אצבעות': 1, + 'טפחים': 1 / 4, + 'זרתות': 1 / 12, + 'אמות': 1 / 24, + 'קנים': 1 / 144, + 'מילים': 1 / 48000, + 'פרסאות': 1 / 192000, + }, + 'טפחים': { + 'אצבעות': 4, + 'טפחים': 1, + 'זרתות': 1 / 3, + 'אמות': 1 / 6, + 'קנים': 1 / 36, + 'מילים': 1 / 12000, + 'פרסאות': 1 / 48000, + }, + 'זרתות': { + 'אצבעות': 12, + 'טפחים': 3, + 'זרתות': 1, + 'אמות': 1 / 2, + 'קנים': 1 / 12, + 'מילים': 1 / 4000, + 'פרסאות': 1 / 16000, + }, + 'אמות': { + 'אצבעות': 24, + 'טפחים': 6, + 'זרתות': 2, + 'אמות': 1, + 'קנים': 1 / 6, + 'מילים': 1 / 2000, + 'פרסאות': 1 / 8000, + }, + 'קנים': { + 'אצבעות': 144, + 'טפחים': 36, + 'זרתות': 12, + 'אמות': 6, + 'קנים': 1, + 'מילים': 1 / (333 + 1 / 3), + 'פרסאות': 1 / (1333 + 1 / 3), + }, + 'מילים': { + 'אצבעות': 48000, + 'טפחים': 12000, + 'זרתות': 4000, + 'אמות': 2000, + 'קנים': 333 + 1 / 3, + 'מילים': 1, + 'פרסאות': 1 / 4, + }, + 'פרסאות': { + 'אצבעות': 192000, + 'טפחים': 48000, + 'זרתות': 16000, + 'אמות': 8000, + 'קנים': 1333 + 1 / 3, + 'מילים': 4, + 'פרסאות': 1, + }, +}; + +const Map> modernLengthFactors = { + 'מדות ושיעורי תורה, בדעת הרמב"ם': { + 'אצבע': 1.9, // cm + 'טפח': 7.6, // cm + 'זרת': 22.8, // cm + 'אמה': 45.6, // cm + 'קנה': 2.736, // m + 'מיל': 912, // m + 'פרסה': 3.648, // km + }, + 'רא"ח נאה': { + 'אצבע': 2, // cm + 'טפח': 8, // cm + 'זרת': 24, // cm + 'אמה': 48, // cm + 'קנה': 2.88, // m + 'מיל': 960, // m + 'פרסה': 3.84, // km + }, + 'אגרות משה': { + 'אצבע': 2.25, // cm + 'טפח': 9, // cm + 'זרת': 27, // cm + 'אמה': 54, // cm + 'קנה': 3.24, // m + 'מיל': 1080, // m + 'פרסה': 4.32, // km + }, + 'צל"ח': { + 'אצבע': 2.34, // cm + 'טפח': 9.36, // cm + 'זרת': 28.08, // cm + 'אמה': 56.16, // cm + 'קנה': 3.37, // m + 'מיל': 1123, // m + 'פרסה': 4.493, // km + }, + 'חזון איש': { + 'אצבע': 2.4, // cm + 'טפח': 9.6, // cm + 'זרת': 28.8, // cm + 'אמה': 57.6, // cm + 'קנה': 3.456, // m + 'מיל': 1152, // m + 'פרסה': 4.608, // km + }, + 'חתם סופר': { + 'אצבע': 2.7, // cm + 'טפח': 10.8, // cm + 'זרת': 32.4, // cm + 'אמה': 64.8, // cm + 'קנה': 3.888, // m + 'מיל': 1296, // m + 'פרסה': 5.184, // km + }, +}; + +const Map> areaConversionFactors = { + 'בית רובע': { + 'בית רובע': 1, + 'בית קב': 1 / 4, + 'בית סאה': 1 / 24, + 'בית סאתיים': 1 / 48, + 'בית לתך': 1 / 360, + 'בית כור': 1 / 720, + }, + 'בית קב': { + 'בית רובע': 4, + 'בית קב': 1, + 'בית סאה': 1 / 6, + 'בית סאתיים': 1 / 12, + 'בית לתך': 1 / 90, + 'בית כור': 1 / 180, + }, + 'בית סאה': { + 'בית רובע': 24, + 'בית קב': 6, + 'בית סאה': 1, + 'בית סאתיים': 1 / 2, + 'בית לתך': 1 / 15, + 'בית כור': 1 / 30, + }, + 'בית סאתיים': { + 'בית רובע': 48, + 'בית קב': 12, + 'בית סאה': 2, + 'בית סאתיים': 1, + 'בית לתך': 1 / 7.5, + 'בית כור': 1 / 15, + }, + 'בית לתך': { + 'בית רובע': 360, + 'בית קב': 90, + 'בית סאה': 15, + 'בית סאתיים': 7.5, + 'בית לתך': 1, + 'בית כור': 1 / 2, + }, + 'בית כור': { + 'בית רובע': 720, + 'בית קב': 180, + 'בית סאה': 30, + 'בית סאתיים': 15, + 'בית לתך': 2, + 'בית כור': 1, + }, +}; + +const Map areaInSquareAmot = { + 'בית רובע': 104 + 1 / 6, + 'בית קב': 416 + 2 / 3, + 'בית סאה': 2500, + 'בית סאתיים': 5000, + 'בית לתך': 37500, + 'בית כור': 75000, +}; + +const Map> modernAreaFactors = { + 'מדות ושיעורי תורה, בדעת הרמב"ם': { + 'בית רובע': 21 + 2 / 3, // m^2 + 'בית קב': 86 + 2 / 3, // m^2 + 'בית סאה': 520, // m^2 + 'בית סאתיים': 1.04, // dunam + 'בית לתך': 7.8, // dunam + 'בית כור': 15.6, // dunam + }, + 'רא"ח נאה': { + 'בית רובע': 24, // m^2 + 'בית קב': 96, // m^2 + 'בית סאה': 576, // m^2 + 'בית סאתיים': 1.152, // dunam + 'בית לתך': 8.64, // dunam + 'בית כור': 17.28, // dunam + }, + 'אגרות משה': { + 'בית רובע': 30 + 3 / 8, // m^2 + 'בית קב': 121.5, // m^2 + 'בית סאה': 729, // m^2 + 'בית סאתיים': 1.458, // dunam + 'בית לתך': 10.935, // dunam + 'בית כור': 21.87, // dunam + }, + 'צל"ח': { + 'בית רובע': 32.85, // m^2 + 'בית קב': 131.4, // m^2 + 'בית סאה': 788.5, // m^2 + 'בית סאתיים': 1.577, // dunam + 'בית לתך': 11.827, // dunam + 'בית כור': 23.655, // dunam + }, + 'חזון איש': { + 'בית רובע': 34.56, // m^2 + 'בית קב': 138 + 1 / 4, // m^2 + 'בית סאה': 829.5, // m^2 + 'בית סאתיים': 1.659, // dunam + 'בית לתך': 12.442, // dunam + 'בית כור': 24.883, // dunam + }, + 'חתם סופר': { + 'בית רובע': 43 + 3 / 4, // m^2 + 'בית קב': 175, // m^2 + 'בית סאה': 1.05, // dunam + 'בית סאתיים': 2.1, // dunam + 'בית לתך': 15.746, // dunam + 'בית כור': 31.493, // dunam + }, +}; + +const Map> volumeConversionFactors = { + 'רביעיות': { + 'רביעיות': 1, + 'לוגים': 1 / 4, + 'קבים': 1 / 16, + 'עשרונות': 1 / 28.8, + 'הינים': 1 / 48, + 'סאים': 1 / 96, + 'איפות': 1 / 288, + 'לתכים': 1 / 1440, + 'כורים': 1 / 2880, + }, + 'לוגים': { + 'רביעיות': 4, + 'לוגים': 1, + 'קבים': 1 / 4, + 'עשרונות': 1 / 7.2, + 'הינים': 1 / 12, + 'סאים': 1 / 24, + 'איפות': 1 / 72, + 'לתכים': 1 / 360, + 'כורים': 1 / 720, + }, + 'קבים': { + 'רביעיות': 16, + 'לוגים': 4, + 'קבים': 1, + 'עשרונות': 1 / 1.8, + 'הינים': 1 / 3, + 'סאים': 1 / 6, + 'איפות': 1 / 18, + 'לתכים': 1 / 90, + 'כורים': 1 / 180, + }, + 'עשרונות': { + 'רביעיות': 28.8, + 'לוגים': 7.2, + 'קבים': 1.8, + 'עשרונות': 1, + 'הינים': 1 / (1 + 2 / 3), + 'סאים': 1 / (3 + 1 / 3), + 'איפות': 1 / 10, + 'לתכים': 1 / 50, + 'כורים': 1 / 100, + }, + 'הינים': { + 'רביעיות': 48, + 'לוגים': 12, + 'קבים': 3, + 'עשרונות': 1 + 2 / 3, + 'הינים': 1, + 'סאים': 1 / 2, + 'איפות': 1 / 6, + 'לתכים': 1 / 30, + 'כורים': 1 / 60, + }, + 'סאים': { + 'רביעיות': 96, + 'לוגים': 24, + 'קבים': 6, + 'עשרונות': 3 + 1 / 3, + 'הינים': 2, + 'סאים': 1, + 'איפות': 1 / 3, + 'לתכים': 1 / 15, + 'כורים': 1 / 30, + }, + 'איפות': { + 'רביעיות': 288, + 'לוגים': 72, + 'קבים': 18, + 'עשרונות': 10, + 'הינים': 6, + 'סאים': 3, + 'איפות': 1, + 'לתכים': 1 / 5, + 'כורים': 1 / 10, + }, + 'לתכים': { + 'רביעיות': 1440, + 'לוגים': 360, + 'קבים': 90, + 'עשרונות': 50, + 'הינים': 30, + 'סאים': 15, + 'איפות': 5, + 'לתכים': 1, + 'כורים': 1 / 2, + }, + 'כורים': { + 'רביעיות': 2880, + 'לוגים': 720, + 'קבים': 180, + 'עשרונות': 100, + 'הינים': 60, + 'סאים': 30, + 'איפות': 10, + 'לתכים': 2, + 'כורים': 1, + }, +}; + +const Map> modernVolumeFactors = { + 'מדות ושיעורי תורה, בדעת הרמב"ם': { + 'רביעית': 76.4, // cm^3 + 'לוג': 305.6, // cm^3 + 'קב': 1.22, // L + 'עשרון': 2.2, // L + 'הין': 3.67, // L + 'סאה': 7.34, // L + 'איפה': 22, // L + 'לתך': 110, // L + 'כור': 220, // L + }, + 'רא"ח נאה': { + 'רביעית': 86.5, // cm^3 + 'לוג': 346, // cm^3 + 'קב': 1.38, // L + 'עשרון': 2.5, // L + 'הין': 4.15, // L + 'סאה': 8.3, // L + 'איפה': 24.9, // L + 'לתך': 124.5, // L + 'כור': 249, // L + }, + 'אגרות משה': { + 'רביעית': 123, // cm^3 + 'לוג': 492, // cm^3 + 'קב': 1.97, // L + 'עשרון': 3.5, // L + 'הין': 5.9, // L + 'סאה': 11.8, // L + 'איפה': 35.4, // L + 'לתך': 177, // L + 'כור': 354, // L + }, + 'צל"ח': { + 'רביעית': 139, // cm^3 + 'לוג': 556, // cm^3 + 'קב': 2.22, // L + 'עשרון': 4, // L + 'הין': 6.67, // L + 'סאה': 13.34, // L + 'איפה': 40, // L + 'לתך': 200, // L + 'כור': 400, // L + }, + 'חזון איש': { + 'רביעית': 149.3, // cm^3 + 'לוג': 597.2, // cm^3 + 'קב': 2.4, // L + 'עשרון': 4.3, // L + 'הין': 7.2, // L + 'סאה': 14.3, // L + 'איפה': 43, // L + 'לתך': 215, // L + 'כור': 430, // L + }, + 'חתם סופר': { + 'רביעית': 212.6, // cm^3 + 'לוג': 850.4, // cm^3 + 'קב': 3.4, // L + 'עשרון': 6.12, // L + 'הין': 10.2, // L + 'סאה': 20.4, // L + 'איפה': 61.2, // L + 'לתך': 306, // L + 'כור': 612, // L + }, +}; + +const Map> timeConversionFactors = { + 'שולחן ערוך': { + 'הילוך ארבע אמות': 2.16, // seconds + 'הילוך מאה אמה': 54, // seconds + 'הילוך שלושה רבעי מיל': 13.5 * 60, // seconds + 'הילוך מיל': 18 * 60, // seconds + 'הילוך ארבעה מילים': 72 * 60, // seconds + }, + 'גר"א וחתם סופר': { + 'הילוך ארבע אמות': 2.7, // seconds + 'הילוך מאה אמה': 67.5, // seconds + 'הילוך שלושה רבעי מיל': (16 * 60) + 52.51, // seconds + 'הילוך מיל': 22.5 * 60, // seconds + 'הילוך ארבעה מילים': 90 * 60, // seconds + }, + 'רמב"ם': { + 'הילוך ארבע אמות': 2.88, // seconds + 'הילוך מאה אמה': 72, // seconds + 'הילוך שלושה רבעי מיל': 18 * 60, // seconds + 'הילוך מיל': 24 * 60, // seconds + 'הילוך ארבעה מילים': 96 * 60, // seconds + }, +}; + +const Map> weightConversionFactors = { + 'דינרים': { + 'דינרים': 1, + 'שקלים': 1 / 2, + 'סלעים': 1 / 4, + 'טרטימרים': 1 / 50, + 'מנים': 1 / 100, + 'ככרות': 1 / 6000, + 'קנטרים': 1 / 10000, + }, + 'שקלים': { + 'דינרים': 2, + 'שקלים': 1, + 'סלעים': 1 / 2, + 'טרטימרים': 1 / 25, + 'מנים': 1 / 50, + 'ככרות': 1 / 3000, + 'קנטרים': 1 / 5000, + }, + 'סלעים': { + 'דינרים': 4, + 'שקלים': 2, + 'סלעים': 1, + 'טרטימרים': 1 / 12.5, + 'מנים': 1 / 25, + 'ככרות': 1 / 1500, + 'קנטרים': 1 / 2500, + }, + 'טרטימרים': { + 'דינרים': 50, + 'שקלים': 25, + 'סלעים': 12.5, + 'טרטימרים': 1, + 'מנים': 1 / 2, + 'ככרות': 1 / 120, + 'קנטרים': 1 / 200, + }, + 'מנים': { + 'דינרים': 100, + 'שקלים': 50, + 'סלעים': 25, + 'טרטימרים': 2, + 'מנים': 1, + 'ככרות': 1 / 60, + 'קנטרים': 1 / 100, + }, + 'ככרות': { + 'דינרים': 6000, + 'שקלים': 3000, + 'סלעים': 1500, + 'טרטימרים': 120, + 'מנים': 60, + 'ככרות': 1, + 'קנטרים': 1 / (1 + 2 / 3), + }, + 'קנטרים': { + 'דינרים': 10000, + 'שקלים': 5000, + 'סלעים': 2500, + 'טרטימרים': 200, + 'מנים': 100, + 'ככרות': 1 + 2 / 3, + 'קנטרים': 1, + }, +}; + +const Map> modernWeightFactors = { + 'מדות ושיעורי תורה, בדעת רש"י': { + 'דינר': 3.54, // g + 'שקל': 7.08, // g + 'סלע': 14.16, // g + 'טרטימר': 177, // g + 'מנה': 354, // g + 'כיכר': 21.24, // kg + 'קנטר': 35.4, // kg + }, + 'ר"ם מ"ץ': { + 'דינר': 3.84, // g + 'שקל': 7.67, // g + 'סלע': 15.34, // g + 'טרטימר': 192, // g + 'מנה': 384, // g + 'כיכר': 23.04, // kg + 'קנטר': 38.4, // kg + }, + 'ש"ך': { + 'דינר': 3.95, // g + 'שקל': 7.9, // g + 'סלע': 15.8, // g + 'טרטימר': 197, // g + 'מנה': 395, // g + 'כיכר': 23.7, // kg + 'קנטר': 39.5, // kg + }, + 'מדות ושיעורי תורה, בדעת הגאונים והרמב"ם': { + 'דינר': 4.25, // g + 'שקל': 8.5, // g + 'סלע': 17, // g + 'טרטימר': 212.5, // g + 'מנה': 425, // g + 'כיכר': 25.5, // kg + 'קנטר': 42.5, // kg + }, + 'בירורי המידות והשיעורין': { + 'דינר': 4.36, // g + 'שקל': 8.72, // g + 'סלע': 17.44, // g + 'טרטימר': 218, // g + 'מנה': 436, // g + 'כיכר': 26.16, // kg + 'קנטר': 43.6, // kg + }, + 'הליכות עולם (לר"ע יוסף)': { + 'דינר': 4.5, // g + 'שקל': 9, // g + 'סלע': 18, // g + 'טרטימר': 225, // g + 'מנה': 450, // g + 'כיכר': 27, // kg + 'קנטר': 45, // kg + }, + 'חזון איש ורא"ח נאה': { + 'דינר': 4.8, // g + 'שקל': 9.6, // g + 'סלע': 19.2, // g + 'טרטימר': 240, // g + 'מנה': 480, // g + 'כיכר': 28.8, // kg + 'קנטר': 48, // kg + }, +}; From 3a8fc9b0aa29571038016fc9a261bc2d6f20b4b7 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 5 Aug 2025 13:42:03 +0300 Subject: [PATCH 080/197] =?UTF-8?q?=D7=94=D7=97=D7=9C=D7=A4=D7=94=20=D7=9E?= =?UTF-8?q?=D7=A4=D7=A8=D7=A9=D7=A0=D7=99=D7=9D=20=D7=9C=D7=9E=D7=A4=D7=A8?= =?UTF-8?q?=D7=A9=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/text_book/view/commentators_list_screen.dart | 4 ++-- .../commentary_list_for_splited_view.dart | 4 ++-- lib/utils/text_manipulation.dart | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/text_book/view/commentators_list_screen.dart b/lib/text_book/view/commentators_list_screen.dart index 23f4c81e7..d4d34065e 100644 --- a/lib/text_book/view/commentators_list_screen.dart +++ b/lib/text_book/view/commentators_list_screen.dart @@ -131,7 +131,7 @@ class CommentatorsListViewState extends State { if (state is! TextBookLoaded) return const Center(); if (state.availableCommentators.isEmpty) { return const Center( - child: Text("אין פרשנים"), + child: Text("אין מפרשים"), ); } if (commentatorsList.isEmpty) _update(context, state); @@ -204,7 +204,7 @@ class CommentatorsListViewState extends State { if (commentatorsList.isNotEmpty) CheckboxListTile( title: - const Text('הצג את כל הפרשנים'), // שמרתי את השינוי שלך + const Text('הצג את כל המפרשים'), // שמרתי את השינוי שלך value: commentatorsList .where((e) => !e.startsWith('__TITLE_') && diff --git a/lib/text_book/view/splited_view/commentary_list_for_splited_view.dart b/lib/text_book/view/splited_view/commentary_list_for_splited_view.dart index 2405be329..cb3b5c8ae 100644 --- a/lib/text_book/view/splited_view/commentary_list_for_splited_view.dart +++ b/lib/text_book/view/splited_view/commentary_list_for_splited_view.dart @@ -59,7 +59,7 @@ class _CommentaryListState extends State { child: TextField( controller: _searchController, decoration: InputDecoration( - hintText: 'חפש בתוך הפרשנים המוצגים...', + hintText: 'חפש בתוך המפרשים המוצגים...', prefixIcon: const Icon(Icons.search), suffixIcon: _searchQuery.isNotEmpty ? IconButton( @@ -121,7 +121,7 @@ class _CommentaryListState extends State { return const Center(child: CircularProgressIndicator()); } if (thisLinksSnapshot.data!.isEmpty) { - return const Center(child: Text("לא נמצאו פרשנים להצגה")); + return const Center(child: Text("לא נמצאו מפרשים להצגה")); } return ProgressiveScroll( scrollController: scrollController, diff --git a/lib/utils/text_manipulation.dart b/lib/utils/text_manipulation.dart index 2b8fce43e..70a4c1368 100644 --- a/lib/utils/text_manipulation.dart +++ b/lib/utils/text_manipulation.dart @@ -61,8 +61,8 @@ Future hasTopic(String title, String topic) async { } } - // Book not found in CSV, it's "פרשנים נוספים" - return topic == 'פרשנים נוספים'; + // Book not found in CSV, it's "מפרשים נוספים" + return topic == 'מפרשים נוספים'; } } catch (e) { // If CSV fails, fall back to path-based check @@ -118,7 +118,7 @@ String _mapGenerationToCategory(String generation) { case 'מחברי זמננו': return 'מחברי זמננו'; default: - return 'פרשנים נוספים'; + return 'מפרשים נוספים'; } } @@ -439,7 +439,7 @@ Future>> splitByEra( 'ראשונים': [], 'אחרונים': [], 'מחברי זמננו': [], - 'פרשנים נוספים': [], + 'מפרשים נוספים': [], }; // ממיינים כל פרשן לקטגוריה הראשונה שמתאימה לו @@ -455,8 +455,8 @@ Future>> splitByEra( } else if (await hasTopic(t, 'מחברי זמננו')) { byEra['מחברי זמננו']!.add(t); } else { - // כל ספר שלא נמצא בקטגוריות הקודמות יוכנס ל"פרשנים נוספים" - byEra['פרשנים נוספים']!.add(t); + // כל ספר שלא נמצא בקטגוריות הקודמות יוכנס ל"מפרשים נוספים" + byEra['מפרשים נוספים']!.add(t); } } From 459c1f28a81154f10a8ec71d404b52102ca56675 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 5 Aug 2025 16:31:58 +0300 Subject: [PATCH 081/197] =?UTF-8?q?=D7=A9=D7=99=D7=A4=D7=95=D7=A8=D7=99?= =?UTF-8?q?=D7=9D=20=D7=91=D7=9C=D7=95=D7=97=20=D7=94=D7=A9=D7=A0=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/navigation/calendar_cubit.dart | 44 +- lib/navigation/calendar_widget.dart | 634 ++++++++++++++++++++++++++-- lib/navigation/more_screen.dart | 37 +- 3 files changed, 666 insertions(+), 49 deletions(-) diff --git a/lib/navigation/calendar_cubit.dart b/lib/navigation/calendar_cubit.dart index b6f1dfb4b..ae547495f 100644 --- a/lib/navigation/calendar_cubit.dart +++ b/lib/navigation/calendar_cubit.dart @@ -1,10 +1,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:kosher_dart/kosher_dart.dart'; -import 'package:flutter/material.dart'; enum CalendarType { hebrew, gregorian, combined } +enum CalendarView { month, week, day } + // Calendar State class CalendarState extends Equatable { final JewishDate selectedJewishDate; @@ -14,6 +15,7 @@ class CalendarState extends Equatable { final JewishDate currentJewishDate; final DateTime currentGregorianDate; final CalendarType calendarType; + final CalendarView calendarView; const CalendarState({ required this.selectedJewishDate, @@ -23,6 +25,7 @@ class CalendarState extends Equatable { required this.currentJewishDate, required this.currentGregorianDate, required this.calendarType, + required this.calendarView, }); factory CalendarState.initial() { @@ -37,6 +40,7 @@ class CalendarState extends Equatable { currentJewishDate: jewishNow, currentGregorianDate: now, calendarType: CalendarType.combined, + calendarView: CalendarView.month, ); } @@ -48,6 +52,7 @@ class CalendarState extends Equatable { JewishDate? currentJewishDate, DateTime? currentGregorianDate, CalendarType? calendarType, + CalendarView? calendarView, }) { return CalendarState( selectedJewishDate: selectedJewishDate ?? this.selectedJewishDate, @@ -58,6 +63,7 @@ class CalendarState extends Equatable { currentJewishDate: currentJewishDate ?? this.currentJewishDate, currentGregorianDate: currentGregorianDate ?? this.currentGregorianDate, calendarType: calendarType ?? this.calendarType, + calendarView: calendarView ?? this.calendarView, ); } @@ -77,7 +83,8 @@ class CalendarState extends Equatable { currentJewishDate.getJewishDayOfMonth(), currentGregorianDate, - calendarType + calendarType, + calendarView ]; } @@ -166,6 +173,37 @@ class CalendarCubit extends Cubit { void changeCalendarType(CalendarType type) { emit(state.copyWith(calendarType: type)); } + + void changeCalendarView(CalendarView view) { + emit(state.copyWith(calendarView: view)); + } + + void jumpToToday() { + final now = DateTime.now(); + final jewishNow = JewishDate(); + final newTimes = _calculateDailyTimes(now, state.selectedCity); + + emit(state.copyWith( + selectedJewishDate: jewishNow, + selectedGregorianDate: now, + currentJewishDate: jewishNow, + currentGregorianDate: now, + dailyTimes: newTimes, + )); + } + + void jumpToDate(DateTime date) { + final jewishDate = JewishDate.fromDateTime(date); + final newTimes = _calculateDailyTimes(date, state.selectedCity); + + emit(state.copyWith( + selectedJewishDate: jewishDate, + selectedGregorianDate: date, + currentJewishDate: jewishDate, + currentGregorianDate: date, + dailyTimes: newTimes, + )); + } } // City coordinates map @@ -194,8 +232,6 @@ const Map> cityCoordinates = { // Calculate daily times function Map _calculateDailyTimes(DateTime date, String city) { - print( - 'Calculating times for date: ${date.day}/${date.month}/${date.year}, city: $city'); final cityData = cityCoordinates[city]; if (cityData == null) { return {}; diff --git a/lib/navigation/calendar_widget.dart b/lib/navigation/calendar_widget.dart index a87b607af..ac5f3f9e0 100644 --- a/lib/navigation/calendar_widget.dart +++ b/lib/navigation/calendar_widget.dart @@ -104,7 +104,7 @@ class CalendarWidget extends StatelessWidget { padding: const EdgeInsets.all(16.0), child: Column( children: [ - _buildMonthYearSelector(context, state), + _buildCalendarHeader(context, state), const SizedBox(height: 16), _buildCalendarGrid(context, state), ], @@ -113,27 +113,93 @@ class CalendarWidget extends StatelessWidget { ); } - Widget _buildMonthYearSelector(BuildContext context, CalendarState state) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Widget _buildCalendarHeader(BuildContext context, CalendarState state) { + return Column( children: [ - IconButton( - onPressed: () => context.read().previousMonth(), - icon: const Icon(Icons.chevron_left), - ), - Text( - _getCurrentMonthYearText(state), - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + // שורה עליונה עם כפתורים וכותרת + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + ElevatedButton( + onPressed: () => context.read().jumpToToday(), + child: const Text('היום'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () => _showJumpToDateDialog(context), + child: const Text('קפוץ אל'), + ), + ], + ), + Text( + _getCurrentMonthYearText(state), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + Row( + children: [ + IconButton( + onPressed: () => + context.read().previousMonth(), + icon: const Icon(Icons.chevron_left), + ), + IconButton( + onPressed: () => context.read().nextMonth(), + icon: const Icon(Icons.chevron_right), + ), + ], + ), + ], ), - IconButton( - onPressed: () => context.read().nextMonth(), - icon: const Icon(Icons.chevron_right), + const SizedBox(height: 8), + // שורה תחתונה עם בחירת תצוגה + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SegmentedButton( + segments: const [ + ButtonSegment( + value: CalendarView.month, + label: Text('חודש'), + icon: Icon(Icons.calendar_view_month), + ), + ButtonSegment( + value: CalendarView.week, + label: Text('שבוע'), + icon: Icon(Icons.calendar_view_week), + ), + ButtonSegment( + value: CalendarView.day, + label: Text('יום'), + icon: Icon(Icons.calendar_view_day), + ), + ], + selected: {state.calendarView}, + onSelectionChanged: (Set newSelection) { + context + .read() + .changeCalendarView(newSelection.first); + }, + ), + ], ), ], ); } Widget _buildCalendarGrid(BuildContext context, CalendarState state) { + switch (state.calendarView) { + case CalendarView.month: + return _buildMonthView(context, state); + case CalendarView.week: + return _buildWeekView(context, state); + case CalendarView.day: + return _buildDayView(context, state); + } + } + + Widget _buildMonthView(BuildContext context, CalendarState state) { return Column( children: [ Row( @@ -160,6 +226,78 @@ class CalendarWidget extends StatelessWidget { ); } + Widget _buildWeekView(BuildContext context, CalendarState state) { + return Column( + children: [ + Row( + children: hebrewDays + .map((day) => Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + day, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + )) + .toList(), + ), + const SizedBox(height: 8), + _buildWeekDays(context, state), + ], + ); + } + + Widget _buildDayView(BuildContext context, CalendarState state) { + return Container( + height: 200, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withAlpha(51), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + hebrewDays[state.selectedGregorianDate.weekday % 7], + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + '${_formatHebrewDay(state.selectedJewishDate.getJewishDayOfMonth())} ${hebrewMonths[state.selectedJewishDate.getJewishMonth() - 1]}', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + '${state.selectedGregorianDate.day} ${_getGregorianMonthName(state.selectedGregorianDate.month)} ${state.selectedGregorianDate.year}', + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + ), + ], + ), + ), + ); + } + Widget _buildCalendarDays(BuildContext context, CalendarState state) { if (state.calendarType == CalendarType.gregorian) { return _buildGregorianCalendarDays(context, state); @@ -231,6 +369,81 @@ class CalendarWidget extends StatelessWidget { return Column(children: rows); } + Widget _buildWeekDays(BuildContext context, CalendarState state) { + // מחשב את תחילת השבוע (ראשון) + final selectedDate = state.selectedGregorianDate; + final startOfWeek = + selectedDate.subtract(Duration(days: selectedDate.weekday % 7)); + + List weekDays = []; + for (int i = 0; i < 7; i++) { + final dayDate = startOfWeek.add(Duration(days: i)); + final jewishDate = JewishDate.fromDateTime(dayDate); + + final isSelected = dayDate.day == selectedDate.day && + dayDate.month == selectedDate.month && + dayDate.year == selectedDate.year; + + final isToday = dayDate.day == DateTime.now().day && + dayDate.month == DateTime.now().month && + dayDate.year == DateTime.now().year; + + weekDays.add( + Expanded( + child: GestureDetector( + onTap: () => + context.read().selectDate(jewishDate, dayDate), + child: Container( + margin: const EdgeInsets.all(2), + height: 80, + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).primaryColor + : isToday + ? Theme.of(context).primaryColor.withAlpha(76) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isToday + ? Theme.of(context).primaryColor + : Colors.grey.shade300, + width: isToday ? 2 : 1, + ), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${dayDate.day}', + style: TextStyle( + color: isSelected ? Colors.white : Colors.black, + fontWeight: isSelected || isToday + ? FontWeight.bold + : FontWeight.normal, + fontSize: 16, + ), + ), + const SizedBox(height: 2), + Text( + _formatHebrewDay(jewishDate.getJewishDayOfMonth()), + style: TextStyle( + color: isSelected ? Colors.white70 : Colors.grey[600], + fontSize: 12, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + return Row(children: weekDays); + } + Widget _buildHebrewDayCell( BuildContext context, CalendarState state, int day) { final jewishDate = JewishDate(); @@ -246,6 +459,10 @@ class CalendarWidget extends StatelessWidget { jewishDate.getJewishMonth() && state.selectedJewishDate.getJewishYear() == jewishDate.getJewishYear(); + final isToday = gregorianDate.day == DateTime.now().day && + gregorianDate.month == DateTime.now().month && + gregorianDate.year == DateTime.now().year; + return GestureDetector( onTap: () => context.read().selectDate(jewishDate, gregorianDate), @@ -253,10 +470,17 @@ class CalendarWidget extends StatelessWidget { margin: const EdgeInsets.all(2), height: 50, decoration: BoxDecoration( - color: - isSelected ? Theme.of(context).primaryColor : Colors.transparent, + color: isSelected + ? Theme.of(context).primaryColor + : isToday + ? Theme.of(context).primaryColor.withAlpha(76) + : Colors.transparent, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade300, width: 1), + border: Border.all( + color: + isToday ? Theme.of(context).primaryColor : Colors.grey.shade300, + width: isToday ? 2 : 1, + ), ), child: Center( child: Column( @@ -296,6 +520,10 @@ class CalendarWidget extends StatelessWidget { state.selectedGregorianDate.month == gregorianDate.month && state.selectedGregorianDate.year == gregorianDate.year; + final isToday = gregorianDate.day == DateTime.now().day && + gregorianDate.month == DateTime.now().month && + gregorianDate.year == DateTime.now().year; + return GestureDetector( onTap: () => context.read().selectDate(jewishDate, gregorianDate), @@ -303,10 +531,17 @@ class CalendarWidget extends StatelessWidget { margin: const EdgeInsets.all(2), height: 50, decoration: BoxDecoration( - color: - isSelected ? Theme.of(context).primaryColor : Colors.transparent, + color: isSelected + ? Theme.of(context).primaryColor + : isToday + ? Theme.of(context).primaryColor.withAlpha(76) + : Colors.transparent, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade300, width: 1), + border: Border.all( + color: + isToday ? Theme.of(context).primaryColor : Colors.grey.shade300, + width: isToday ? 2 : 1, + ), ), child: Center( child: Column( @@ -415,6 +650,24 @@ class CalendarWidget extends StatelessWidget { ], ), const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.orange.withAlpha(51), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange, width: 1), + ), + child: const Text( + 'אין לסמוך על הזמנים!', + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.orange, + ), + ), + ), _buildTimesGrid(context, state), ], ), @@ -509,23 +762,25 @@ class CalendarWidget extends StatelessWidget { int num = number; if (num >= 100) { int hundreds = (num ~/ 100) * 100; - if (hundreds == 900) + if (hundreds == 900) { result += 'תתק'; - else if (hundreds == 800) + } else if (hundreds == 800) { result += 'תת'; - else if (hundreds == 700) + } else if (hundreds == 700) { result += 'תש'; - else if (hundreds == 600) + } else if (hundreds == 600) { result += 'תר'; - else if (hundreds == 500) + } else if (hundreds == 500) { result += 'תק'; - else if (hundreds == 400) + } else if (hundreds == 400) { result += 'ת'; - else if (hundreds == 300) + } else if (hundreds == 300) { result += 'ש'; - else if (hundreds == 200) + } else if (hundreds == 200) { result += 'ר'; - else if (hundreds == 100) result += 'ק'; + } else if (hundreds == 100) { + result += 'ק'; + } num %= 100; } const ones = ['', 'א', 'ב', 'ג', 'ד', 'ה', 'ו', 'ז', 'ח', 'ט']; @@ -564,6 +819,325 @@ class CalendarWidget extends StatelessWidget { return months[month - 1]; } + void _showJumpToDateDialog(BuildContext context) { + DateTime selectedDate = DateTime.now(); + final TextEditingController dateController = TextEditingController(); + + showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + title: const Text('קפוץ לתאריך'), + content: SizedBox( + width: 350, + height: 450, + child: Column( + children: [ + // הזנת תאריך ידנית + TextField( + controller: dateController, + decoration: const InputDecoration( + labelText: 'הזן תאריך', + hintText: 'דוגמאות: 15/3/2025, כ״ה אדר תשפ״ה', + border: OutlineInputBorder(), + helperText: + 'ניתן להזין תאריך לועזי (יום/חודש/שנה) או עברי', + ), + onChanged: (value) => setState(() {}), + ), + const SizedBox(height: 20), + + const Divider(), + const Text( + 'או בחר מלוח השנה:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 10), + + // לוח שנה + Expanded( + child: CalendarDatePicker( + initialDate: selectedDate, + firstDate: DateTime(1900), + lastDate: DateTime(2100), + onDateChanged: (date) { + setState(() { + selectedDate = date; + // עדכן את תיבת הטקסט עם התאריך שנבחר + dateController.text = + '${date.day}/${date.month}/${date.year}'; + }); + }, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('ביטול'), + ), + ElevatedButton( + onPressed: () { + DateTime? dateToJump; + + if (dateController.text.isNotEmpty) { + // נסה לפרש את הטקסט שהוזן + dateToJump = _parseInputDate(dateController.text); + + if (dateToJump == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'לא הצלחתי לפרש את התאריך. נסה פורמט כמו: 15/3/2025'), + backgroundColor: Colors.red, + ), + ); + return; + } + } else { + // אם לא הוזן כלום, השתמש בתאריך שנבחר מהלוח + dateToJump = selectedDate; + } + + context.read().jumpToDate(dateToJump); + Navigator.of(dialogContext).pop(); + }, + child: const Text('קפוץ'), + ), + ], + ); + }, + ); + }, + ); + } + + DateTime? _parseInputDate(String input) { + // נקה את הטקסט + String cleanInput = input.trim(); + + // נסה פורמט לועזי: יום/חודש/שנה או יום-חודש-שנה + RegExp gregorianPattern = + RegExp(r'^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$'); + Match? match = gregorianPattern.firstMatch(cleanInput); + + if (match != null) { + try { + int day = int.parse(match.group(1)!); + int month = int.parse(match.group(2)!); + int year = int.parse(match.group(3)!); + + // בדוק שהתאריך תקין + if (month >= 1 && + month <= 12 && + day >= 1 && + day <= 31 && + year >= 1900 && + year <= 2100) { + return DateTime(year, month, day); + } + } catch (e) { + // המשך לנסות פורמטים אחרים + } + } + + // נסה פורמט עברי פשוט - רק מספרים + RegExp hebrewNumberPattern = RegExp(r'^(\d{1,2})\s*(\d{1,2})\s*(\d{4})$'); + match = hebrewNumberPattern.firstMatch(cleanInput); + + if (match != null) { + try { + int day = int.parse(match.group(1)!); + int month = int.parse(match.group(2)!); + int year = int.parse(match.group(3)!); + + // נניח שזה תאריך עברי ונמיר לגרגוריאני + if (month >= 1 && + month <= 12 && + day >= 1 && + day <= 30 && + year >= 5700 && + year <= 6000) { + try { + final jewishDate = JewishDate(); + jewishDate.setJewishDate(year, month, day); + return jewishDate.getGregorianCalendar(); + } catch (e) { + // אם נכשל, נסה כתאריך גרגוריאני + if (year >= 1900 && year <= 2100) { + return DateTime(year, month, day); + } + } + } + } catch (e) { + // המשך + } + } + + return null; + } + + void _showCreateEventDialog(BuildContext context, CalendarState state) { + final TextEditingController titleController = TextEditingController(); + final TextEditingController descriptionController = TextEditingController(); + bool isRecurring = false; + bool useHebrewCalendar = true; + + showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + title: const Text('צור אירוע חדש'), + content: SizedBox( + width: 400, + height: 500, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: titleController, + decoration: const InputDecoration( + labelText: 'כותרת האירוע', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + TextField( + controller: descriptionController, + decoration: const InputDecoration( + labelText: 'תיאור (אופציונלי)', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + const SizedBox(height: 16), + + // תאריך נבחר + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withAlpha(51), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'תאריך לועזי: ${state.selectedGregorianDate.day}/${state.selectedGregorianDate.month}/${state.selectedGregorianDate.year}', + style: + const TextStyle(fontWeight: FontWeight.bold), + ), + Text( + 'תאריך עברי: ${_formatHebrewDay(state.selectedJewishDate.getJewishDayOfMonth())} ${hebrewMonths[state.selectedJewishDate.getJewishMonth() - 1]} ${_formatHebrewYear(state.selectedJewishDate.getJewishYear())}', + style: + const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ), + const SizedBox(height: 16), + + // אירוע חוזר + Row( + children: [ + Checkbox( + value: isRecurring, + onChanged: (value) { + setState(() { + isRecurring = value ?? false; + }); + }, + ), + const SizedBox(width: 8), + const Text( + 'אירוע חוזר', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w500), + ), + const SizedBox(width: 16), + if (isRecurring) + Expanded( + child: DropdownButtonFormField( + value: useHebrewCalendar, + decoration: const InputDecoration( + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + ), + items: [ + DropdownMenuItem( + value: true, + child: Text( + 'לפי הלוח העברי (${_formatHebrewDay(state.selectedJewishDate.getJewishDayOfMonth())} ${hebrewMonths[state.selectedJewishDate.getJewishMonth() - 1]})'), + ), + DropdownMenuItem( + value: false, + child: Text( + 'לפי הלוח הלועזי (${state.selectedGregorianDate.day}/${state.selectedGregorianDate.month})'), + ), + ], + onChanged: (value) { + setState(() { + useHebrewCalendar = value ?? true; + }); + }, + ), + ), + ], + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('ביטול'), + ), + ElevatedButton( + onPressed: () { + if (titleController.text.isNotEmpty) { + String eventDetails = titleController.text; + if (isRecurring) { + eventDetails += + ' (חוזר ${useHebrewCalendar ? "לפי לוח עברי" : "לפי לוח לועזי"})'; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('האירוע "$eventDetails" נוצר בהצלחה!'), + backgroundColor: Colors.green, + duration: const Duration(seconds: 3), + ), + ); + Navigator.of(dialogContext).pop(); + } else { + ScaffoldMessenger.of(dialogContext).showSnackBar( + const SnackBar( + content: Text('אנא הכנס כותרת לאירוע'), + backgroundColor: Colors.red, + ), + ); + } + }, + child: const Text('צור'), + ), + ], + ); + }, + ); + }, + ); + } + // החלק של האירועים עדיין לא עבר ריפקטורינג, הוא יישאר לא פעיל בינתיים Widget _buildEventsCard(BuildContext context, CalendarState state) { return Card( @@ -582,7 +1156,7 @@ class CalendarWidget extends StatelessWidget { ), const Spacer(), ElevatedButton.icon( - onPressed: () {/* TODO: Implement create event with cubit */}, + onPressed: () => _showCreateEventDialog(context, state), icon: const Icon(Icons.add, size: 16), label: const Text('צור אירוע'), style: ElevatedButton.styleFrom( diff --git a/lib/navigation/more_screen.dart b/lib/navigation/more_screen.dart index 4788230a6..534b19265 100644 --- a/lib/navigation/more_screen.dart +++ b/lib/navigation/more_screen.dart @@ -13,6 +13,19 @@ class MoreScreen extends StatefulWidget { class _MoreScreenState extends State { int _selectedIndex = 0; + late final CalendarCubit _calendarCubit; + + @override + void initState() { + super.initState(); + _calendarCubit = CalendarCubit(); + } + + @override + void dispose() { + _calendarCubit.close(); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -72,13 +85,9 @@ class _MoreScreenState extends State { List? _getActions(BuildContext context, int index) { if (index == 0) { return [ - Builder( - builder: (context) { - return IconButton( - icon: const Icon(Icons.settings), - onPressed: () => _showSettingsDialog(context), - ); - }, + IconButton( + icon: const Icon(Icons.settings), + onPressed: () => _showSettingsDialog(context), ), ]; } @@ -88,8 +97,8 @@ class _MoreScreenState extends State { Widget _buildCurrentWidget(int index) { switch (index) { case 0: - return BlocProvider( - create: (context) => CalendarCubit(), + return BlocProvider.value( + value: _calendarCubit, child: const CalendarWidget(), ); case 1: @@ -102,13 +111,11 @@ class _MoreScreenState extends State { } void _showSettingsDialog(BuildContext context) { - final calendarCubit = context.read(); - showDialog( context: context, builder: (dialogContext) { return BlocBuilder( - bloc: calendarCubit, + bloc: _calendarCubit, builder: (context, state) { return AlertDialog( title: const Text('הגדרות לוח שנה'), @@ -121,7 +128,7 @@ class _MoreScreenState extends State { groupValue: state.calendarType, onChanged: (value) { if (value != null) { - calendarCubit.changeCalendarType(value); + _calendarCubit.changeCalendarType(value); } Navigator.of(dialogContext).pop(); }, @@ -132,7 +139,7 @@ class _MoreScreenState extends State { groupValue: state.calendarType, onChanged: (value) { if (value != null) { - calendarCubit.changeCalendarType(value); + _calendarCubit.changeCalendarType(value); } Navigator.of(dialogContext).pop(); }, @@ -143,7 +150,7 @@ class _MoreScreenState extends State { groupValue: state.calendarType, onChanged: (value) { if (value != null) { - calendarCubit.changeCalendarType(value); + _calendarCubit.changeCalendarType(value); } Navigator.of(dialogContext).pop(); }, From 2a86c35062e8c435462408666f69c24379fc0d8f Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 5 Aug 2025 16:43:50 +0300 Subject: [PATCH 082/197] =?UTF-8?q?=D7=94=D7=9B=D7=A0=D7=94=20=D7=9C=D7=A9?= =?UTF-8?q?=D7=9E=D7=95=D7=A8=20=D7=95=D7=96=D7=9B=D7=95=D7=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...27\225\327\226\327\233\327\225\327\250.png" | Bin 0 -> 428467 bytes lib/navigation/more_screen.dart | 14 +++++++++++--- pubspec.yaml | 1 + 3 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 "assets/icon/\327\251\327\236\327\225\327\250 \327\225\327\226\327\233\327\225\327\250.png" diff --git "a/assets/icon/\327\251\327\236\327\225\327\250 \327\225\327\226\327\233\327\225\327\250.png" "b/assets/icon/\327\251\327\236\327\225\327\250 \327\225\327\226\327\233\327\225\327\250.png" new file mode 100644 index 0000000000000000000000000000000000000000..208767012f8901bd7a3d2894f7f5ce1f2ba6b80d GIT binary patch literal 428467 zcmeENWmlU~v&CJCyA*eKf)@7%io3g8ad)S<7A@}X?p|CPNO6k0-n@6M_w)S&_pFti zCo9R9IWu!+@7WWrrXq`uLW}|h1%)mzCk2Fpf`L53Kp`POwp;Jt&yWq-Sx(0d3JPQB zKQHJpr(fn!P?S*eQW6^8xo16y+WJ=MbaB2y@&8(Df}n!oB<23^skGWpwXz^fsYxQ!xPFq8g!vJ=?b)?0a&y+VxfG~+aW2L!Y=(T2-!%8+_%nUG z(aZNe|G4$7XYNMin@80z$)gpKi|=#3v*o2D1OLDNFAH344=M&88wG*uZb}|I|JPzy z+t7WZfr(exw&@C`cd$}58P*H_X>BW*8RQ=ed*(r*p`lzB1+jSlmNjg8Rc6B9_C#RB+?78(@GLzTSkG7LY z_Qz_9@s{TI2u?*6Gy%wZZ@N^PC_TN@3gzwE$w+_y>*GJ!n1}4yYGFuX{

J*pX0G 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%0Ddk>21)0LuZz&YtfqkSEC<`6gf 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 { icon: Icon(Icons.calendar_today), label: Text('לוח שנה'), ), + NavigationRailDestination( + icon: ImageIcon(AssetImage('assets/icon/שמור וזכור.png')), + label: Text('זכור ושמור'), + ), NavigationRailDestination( icon: Icon(Icons.straighten), label: Text('ממיר מידות'), @@ -74,8 +78,10 @@ class _MoreScreenState extends State { case 0: return 'לוח שנה'; case 1: - return 'ממיר מידות'; + return 'זכור ושמור'; case 2: + return 'ממיר מידות'; + case 3: return 'גימטריות'; default: return 'עוד'; @@ -102,9 +108,11 @@ class _MoreScreenState extends State { child: const CalendarWidget(), ); case 1: - return const MeasurementConverterScreen(); + return const Center(child: Text('בקרוב...')); case 2: - return const Center(child: Text('גימטריות - בקרוב...')); + return const MeasurementConverterScreen(); + case 3: + return const Center(child: Text('בקרוב...')); default: return Container(); } diff --git a/pubspec.yaml b/pubspec.yaml index e3e86c481..8fb056bc4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -150,6 +150,7 @@ flutter: - assets/ - assets/logos/ - assets/ca/ + - assets/icon/שמור וזכור.png # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware From 0c93771f5570044cc7a39451f1b3818469a8e9ef Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 5 Aug 2025 17:45:43 +0300 Subject: [PATCH 083/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=91?= =?UTF-8?q?=D7=A2=D7=99=D7=99=D7=AA=20=D7=94=D7=95=D7=A4=D7=A2=D7=AA=20?= =?UTF-8?q?=D7=94=D7=91=D7=95=D7=A2=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/enhanced_search_field.dart | 93 +++++++++++++++++----- 1 file changed, 72 insertions(+), 21 deletions(-) diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 0164a8a6f..90ca5c524 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -11,6 +11,8 @@ import 'package:otzaria/search/models/search_terms_model.dart'; import 'package:otzaria/search/view/tantivy_full_text_search.dart'; import 'package:otzaria/navigation/bloc/navigation_bloc.dart'; import 'package:otzaria/navigation/bloc/navigation_state.dart'; +import 'package:otzaria/tabs/bloc/tabs_bloc.dart'; +import 'package:otzaria/tabs/bloc/tabs_state.dart'; // הווידג'ט החדש לניהול מצבי הכפתור class _PlusButton extends StatefulWidget { @@ -507,8 +509,16 @@ class _EnhancedSearchFieldState extends State { }); } + @override + void deactivate() { + debugPrint('⏸️ EnhancedSearchField deactivating - clearing overlays'); + _clearAllOverlays(); + super.deactivate(); + } + @override void dispose() { + debugPrint('🗑️ EnhancedSearchField disposing'); _clearAllOverlays(); widget.widget.tab.queryController.removeListener(_onTextChanged); widget.widget.tab.searchFieldFocusNode @@ -627,8 +637,12 @@ class _EnhancedSearchFieldState extends State { void _clearAllOverlays( {bool keepSearchDrawer = false, bool keepFilledBubbles = false}) { + debugPrint( + '🧹 CLEAR OVERLAYS: ${DateTime.now()} - keepSearchDrawer: $keepSearchDrawer, keepFilledBubbles: $keepFilledBubbles'); // ניקוי אלטרנטיבות - רק אם לא ביקשנו לשמור בועות מלאות או אם הן ריקות if (!keepFilledBubbles) { + debugPrint( + '🧹 Clearing ${_alternativeOverlays.length} alternative overlay groups'); for (final entries in _alternativeOverlays.values) { for (final entry in entries) { entry.remove(); @@ -677,6 +691,7 @@ class _EnhancedSearchFieldState extends State { // ניקוי מרווחים - רק אם לא ביקשנו לשמור בועות מלאות או אם הן ריקות if (!keepFilledBubbles) { + debugPrint('🧹 Clearing ${_spacingOverlays.length} spacing overlays'); for (final entry in _spacingOverlays.values) { entry.remove(); } @@ -1059,10 +1074,14 @@ class _EnhancedSearchFieldState extends State { } void _showAlternativeOverlay(int termIndex, int altIndex) { + debugPrint( + '🎈 Showing alternative overlay: term=$termIndex, alt=$altIndex'); + // בדיקה שהאינדקסים תקינים if (termIndex >= _wordPositions.length || !_alternativeControllers.containsKey(termIndex) || altIndex >= _alternativeControllers[termIndex]!.length) { + debugPrint('❌ Invalid indices for alternative overlay'); return; } @@ -1074,6 +1093,7 @@ class _EnhancedSearchFieldState extends State { Overlay.of(context).mounted && // ודא שה-Overlay קיים existingOverlays[altIndex].mounted) { // ודא שהבועה הספציפית הזו עדיין על המסך + debugPrint('⚠️ Alternative overlay already exists and mounted'); return; // אם הבועה כבר קיימת ומוצגת, אל תעשה כלום } @@ -1118,7 +1138,11 @@ class _EnhancedSearchFieldState extends State { void _showSpacingOverlay(int leftIndex, int rightIndex) { final key = _spaceKey(leftIndex, rightIndex); - if (_spacingOverlays.containsKey(key)) return; + debugPrint('🎈 Showing spacing overlay: $key'); + if (_spacingOverlays.containsKey(key)) { + debugPrint('⚠️ Spacing overlay already exists: $key'); + return; + } // בדיקה שהאינדקסים תקינים if (leftIndex >= _wordRightEdges.length || @@ -1423,27 +1447,54 @@ class _EnhancedSearchFieldState extends State { @override Widget build(BuildContext context) { - return BlocListener( - listener: (context, state) { - // כשעוברים ממסך החיפוש למסך אחר, שמור נתונים ונקה את כל הבועות - if (state.currentScreen != Screen.search) { - _saveDataToTab(); - _clearAllOverlays(); - } else if (state.currentScreen == Screen.search) { - // כשחוזרים למסך החיפוש, שחזר את הנתונים והצג את הבועות - WidgetsBinding.instance.addPostFrameCallback((_) { - _restoreDataFromTab(); // 1. שחזר את תוכן הבועות מהזיכרון - // עיכוב נוסף כדי לוודא שהטקסט מעודכן - Future.delayed(const Duration(milliseconds: 50), () { - // השאר את העיכוב הקטן הזה - if (mounted) { - _calculateWordPositions(); // 2. חשב מיקומים (עכשיו זה יעבוד) - _showRestoredBubbles(); // 3. הצג את הבועות המשוחזרות + return MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) { + debugPrint('🔄 Navigation changed to: ${state.currentScreen}'); + + // תמיד נקה בועות כשמשנים מסך - זה יפתור את הבאג + // שבו בועות נשארות כשעוברים ממסך אחד לשני (לא דרך החיפוש) + _clearAllOverlays(); + + // אם עוזבים את מסך החיפוש - שמור נתונים + if (state.currentScreen != Screen.search) { + debugPrint('📤 Leaving search screen, saving data'); + _saveDataToTab(); + } + // אם חוזרים למסך החיפוש - שחזר נתונים והצג בועות + else if (state.currentScreen == Screen.search) { + debugPrint('📥 Returning to search screen, restoring data'); + WidgetsBinding.instance.addPostFrameCallback((_) { + _restoreDataFromTab(); // 1. שחזר את תוכן הבועות מהזיכרון + // עיכוב נוסף כדי לוודא שהטקסט מעודכן + Future.delayed(const Duration(milliseconds: 50), () { + // השאר את העיכוב הקטן הזה + if (mounted) { + _calculateWordPositions(); // 2. חשב מיקומים (עכשיו זה יעבוד) + _showRestoredBubbles(); // 3. הצג את הבועות המשוחזרות + } + }); + }); + } + }, + ), + // הוספת listener לשינויי tabs - למקרה שהבעיה קשורה לכך + BlocListener( + listener: (context, state) { + debugPrint( + '📑 Tabs changed - current tab index: ${state.currentTabIndex}'); + // אם עברנו לטאב שאינו search tab, נקה בועות + if (state.currentTabIndex < state.tabs.length) { + final currentTab = state.tabs[state.currentTabIndex]; + if (currentTab.runtimeType.toString() != 'SearchingTab') { + debugPrint('📤 Switched to non-search tab, clearing overlays'); + _clearAllOverlays(); } - }); - }); - } - }, + } + }, + ), + ], child: Stack( key: _stackKey, clipBehavior: Clip.none, From 030aca9e9e9c05762a02b007b795f05e203a541c Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 5 Aug 2025 18:27:01 +0300 Subject: [PATCH 084/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=97?= =?UTF-8?q?=D7=9C=D7=A7=20=D7=9E=D7=9E=D7=99=D7=9C=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data_providers/tantivy_data_provider.dart | 13 ++++++++++++- lib/history/bloc/history_bloc.dart | 2 +- lib/search/search_repository.dart | 15 +++++++++++++-- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/lib/data/data_providers/tantivy_data_provider.dart b/lib/data/data_providers/tantivy_data_provider.dart index 6cffea020..69d429879 100644 --- a/lib/data/data_providers/tantivy_data_provider.dart +++ b/lib/data/data_providers/tantivy_data_provider.dart @@ -284,6 +284,7 @@ class TantivyDataProvider { final hasGrammaticalPrefixes = wordOptions['קידומות דקדוקיות'] == true; final hasGrammaticalSuffixes = wordOptions['סיומות דקדוקיות'] == true; final hasFullPartialSpelling = wordOptions['כתיב מלא/חסר'] == true; + final hasPartialWord = wordOptions['חלק ממילה'] == true; // קבלת מילים חילופיות final alternatives = alternativeWords?[i]; @@ -382,6 +383,15 @@ class TantivyDataProvider { // מילה ארוכה - ללא הגבלה allVariations.add('${RegExp.escape(baseVariation)}.*'); } + } else if (hasPartialWord) { + // חלק ממילה - הגבלה חכמה לפי אורך המילה + if (baseVariation.length <= 3) { + // מילה קצרה (1-3 תווים) - 3 תווים לפני ו3 אחרי + allVariations.add('.{0,3}${RegExp.escape(baseVariation)}.{0,3}'); + } else { + // מילה ארוכה (4+ תווים) - 2 תווים לפני ו2 אחרי + allVariations.add('.{0,2}${RegExp.escape(baseVariation)}.{0,2}'); + } } else { // ללא אפשרויות מיוחדות - מילה מדויקת allVariations.add(RegExp.escape(baseVariation)); @@ -425,7 +435,8 @@ class TantivyDataProvider { if (wordOptions['סיומות'] == true || wordOptions['קידומות'] == true || wordOptions['קידומות דקדוקיות'] == true || - wordOptions['סיומות דקדוקיות'] == true) { + wordOptions['סיומות דקדוקיות'] == true || + wordOptions['חלק ממילה'] == true) { hasSuffixOrPrefix = true; shortestWordLength = math.min(shortestWordLength, word.length); } diff --git a/lib/history/bloc/history_bloc.dart b/lib/history/bloc/history_bloc.dart index c758c83ac..89ca8c2ea 100644 --- a/lib/history/bloc/history_bloc.dart +++ b/lib/history/bloc/history_bloc.dart @@ -120,7 +120,7 @@ class HistoryBloc extends Bloc { 'קידומות דקדוקיות': 'קד', 'סיומות דקדוקיות': 'סד', 'כתיב מלא/חסר': 'מח', - 'חלק ממילה': 'ש', + 'חלק ממילה': 'חמ', }; const Set suffixOptions = { diff --git a/lib/search/search_repository.dart b/lib/search/search_repository.dart index 3e9a65b92..e7484d2bf 100644 --- a/lib/search/search_repository.dart +++ b/lib/search/search_repository.dart @@ -116,6 +116,7 @@ class SearchRepository { final hasGrammaticalPrefixes = wordOptions['קידומות דקדוקיות'] == true; final hasGrammaticalSuffixes = wordOptions['סיומות דקדוקיות'] == true; final hasFullPartialSpelling = wordOptions['כתיב מלא/חסר'] == true; + final hasPartialWord = wordOptions['חלק ממילה'] == true; // קבלת מילים חילופיות final alternatives = alternativeWords?[i]; @@ -218,6 +219,15 @@ class SearchRepository { // מילה ארוכה - ללא הגבלה allVariations.add(RegExp.escape(baseVariation) + '.*'); } + } else if (hasPartialWord) { + // חלק ממילה - הגבלה חכמה לפי אורך המילה + if (baseVariation.length <= 3) { + // מילה קצרה (1-3 תווים) - 3 תווים לפני ו3 אחרי + allVariations.add('.{0,3}' + RegExp.escape(baseVariation) + '.{0,3}'); + } else { + // מילה ארוכה (4+ תווים) - 2 תווים לפני ו2 אחרי + allVariations.add('.{0,2}' + RegExp.escape(baseVariation) + '.{0,2}'); + } } else { // ללא אפשרויות מיוחדות - מילה מדויקת allVariations.add(RegExp.escape(baseVariation)); @@ -237,7 +247,7 @@ class SearchRepository { regexTerms.add(finalPattern); print( - '🔄 מילה $i: $finalPattern (קידומות: $hasPrefix, סיומות: $hasSuffix, קידומות דקדוקיות: $hasGrammaticalPrefixes, סיומות דקדוקיות: $hasGrammaticalSuffixes, כתיב מלא/חסר: $hasFullPartialSpelling)'); + '🔄 מילה $i: $finalPattern (קידומות: $hasPrefix, סיומות: $hasSuffix, קידומות דקדוקיות: $hasGrammaticalPrefixes, סיומות דקדוקיות: $hasGrammaticalSuffixes, כתיב מלא/חסר: $hasFullPartialSpelling, חלק ממילה: $hasPartialWord)'); } else { // fallback למילה המקורית regexTerms.add(word); @@ -263,7 +273,8 @@ class SearchRepository { if (wordOptions['סיומות'] == true || wordOptions['קידומות'] == true || wordOptions['קידומות דקדוקיות'] == true || - wordOptions['סיומות דקדוקיות'] == true) { + wordOptions['סיומות דקדוקיות'] == true || + wordOptions['חלק ממילה'] == true) { hasSuffixOrPrefix = true; shortestWordLength = math.min(shortestWordLength, word.length); } From ee0e6f0def45a17d1d8a7490951dd81ffefaa157 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 5 Aug 2025 21:46:39 +0300 Subject: [PATCH 085/197] =?UTF-8?q?=D7=A1=D7=99=D7=93=D7=95=D7=A8=20=D7=9B?= =?UTF-8?q?=D7=9C=D7=9C=20=D7=94=D7=A8=D7=92=D7=A7=D7=A1'=D7=99=D7=9D,=20?= =?UTF-8?q?=D7=95=D7=A2=D7=93=D7=9B=D7=95=D7=A0=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/models/search_terms_model.dart | 4 +- lib/search/search_repository.dart | 70 +++-- lib/search/utils/README_REGEX_REFACTOR.md | 99 +++++++ lib/search/utils/hebrew_morphology.dart | 90 +------ lib/search/utils/regex_examples.dart | 202 +++++++++++++++ lib/search/utils/regex_patterns.dart | 288 +++++++++++++++++++++ lib/search/view/enhanced_search_field.dart | 3 +- lib/utils/text_manipulation.dart | 15 +- 8 files changed, 642 insertions(+), 129 deletions(-) create mode 100644 lib/search/utils/README_REGEX_REFACTOR.md create mode 100644 lib/search/utils/regex_examples.dart create mode 100644 lib/search/utils/regex_patterns.dart diff --git a/lib/search/models/search_terms_model.dart b/lib/search/models/search_terms_model.dart index 0499fbdfa..7684035eb 100644 --- a/lib/search/models/search_terms_model.dart +++ b/lib/search/models/search_terms_model.dart @@ -1,3 +1,5 @@ +import 'package:otzaria/search/utils/regex_patterns.dart'; + class SearchTerm { final String word; final List alternatives; @@ -70,7 +72,7 @@ class SearchQuery { return SearchQuery(); } - final words = query.trim().split(RegExp(r'\s+')); + final words = query.trim().split(SearchRegexPatterns.wordSplitter); final terms = words.map((word) => SearchTerm(word: word)).toList(); return SearchQuery(terms: terms); } diff --git a/lib/search/search_repository.dart b/lib/search/search_repository.dart index e7484d2bf..636066913 100644 --- a/lib/search/search_repository.dart +++ b/lib/search/search_repository.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:otzaria/data/data_providers/tantivy_data_provider.dart'; import 'package:otzaria/search/utils/hebrew_morphology.dart'; +import 'package:otzaria/search/utils/regex_patterns.dart'; import 'package:search_engine/search_engine.dart'; /// Performs a search operation across indexed texts. @@ -35,7 +36,7 @@ class SearchRepository { final hasSearchOptions = searchOptions != null && searchOptions.isNotEmpty; // המרת החיפוש לפורמט המנוע החדש - final words = query.trim().split(RegExp(r'\s+')); + final words = query.trim().split(SearchRegexPatterns.wordSplitter); final List regexTerms; final int effectiveSlop; @@ -189,45 +190,18 @@ class SearchRepository { allVariations.addAll( HebrewMorphology.generateSuffixVariations(baseVariation)); } + } else if (hasPrefix && hasSuffix) { + // קידומות וסיומות יחד - משתמש בחיפוש "חלק ממילה" + allVariations.add(SearchRegexPatterns.createPartialWordPattern(baseVariation)); } else if (hasPrefix) { - // קידומות רגילות - הגבלה חכמה לפי אורך המילה - if (baseVariation.length <= 1) { - // מילה של תו אחד - הגבלה קיצונית (מקסימום 5 תווים קידומת) - allVariations.add('.{1,5}' + RegExp.escape(baseVariation)); - } else if (baseVariation.length <= 2) { - // מילה של 2 תווים - הגבלה בינונית (מקסימום 4 תווים קידומת) - allVariations.add('.{1,4}' + RegExp.escape(baseVariation)); - } else if (baseVariation.length <= 3) { - // מילה של 3 תווים - הגבלה קלה (מקסימום 3 תווים קידומת) - allVariations.add('.{1,3}' + RegExp.escape(baseVariation)); - } else { - // מילה ארוכה - ללא הגבלה - allVariations.add('.*' + RegExp.escape(baseVariation)); - } + // קידומות רגילות - שימוש ברגקס מרכזי + allVariations.add(SearchRegexPatterns.createPrefixSearchPattern(baseVariation)); } else if (hasSuffix) { - // סיומות רגילות - הגבלה חכמה לפי אורך המילה - if (baseVariation.length <= 1) { - // מילה של תו אחד - הגבלה קיצונית (מקסימום 7 תווים סיומת) - allVariations.add(RegExp.escape(baseVariation) + '.{1,7}'); - } else if (baseVariation.length <= 2) { - // מילה של 2 תווים - הגבלה בינונית (מקסימום 6 תווים סיומת) - allVariations.add(RegExp.escape(baseVariation) + '.{1,6}'); - } else if (baseVariation.length <= 3) { - // מילה של 3 תווים - הגבלה קלה (מקסימום 5 תווים סיומת) - allVariations.add(RegExp.escape(baseVariation) + '.{1,5}'); - } else { - // מילה ארוכה - ללא הגבלה - allVariations.add(RegExp.escape(baseVariation) + '.*'); - } + // סיומות רגילות - שימוש ברגקס מרכזי + allVariations.add(SearchRegexPatterns.createSuffixSearchPattern(baseVariation)); } else if (hasPartialWord) { - // חלק ממילה - הגבלה חכמה לפי אורך המילה - if (baseVariation.length <= 3) { - // מילה קצרה (1-3 תווים) - 3 תווים לפני ו3 אחרי - allVariations.add('.{0,3}' + RegExp.escape(baseVariation) + '.{0,3}'); - } else { - // מילה ארוכה (4+ תווים) - 2 תווים לפני ו2 אחרי - allVariations.add('.{0,2}' + RegExp.escape(baseVariation) + '.{0,2}'); - } + // חלק ממילה - שימוש ברגקס מרכזי + allVariations.add(SearchRegexPatterns.createPartialWordPattern(baseVariation)); } else { // ללא אפשרויות מיוחדות - מילה מדויקת allVariations.add(RegExp.escape(baseVariation)); @@ -246,8 +220,26 @@ class SearchRepository { : '(${limitedVariations.join('|')})'; regexTerms.add(finalPattern); - print( - '🔄 מילה $i: $finalPattern (קידומות: $hasPrefix, סיומות: $hasSuffix, קידומות דקדוקיות: $hasGrammaticalPrefixes, סיומות דקדוקיות: $hasGrammaticalSuffixes, כתיב מלא/חסר: $hasFullPartialSpelling, חלק ממילה: $hasPartialWord)'); + // הודעת דיבוג עם הסבר על הלוגיקה + final searchType = hasPrefix && hasSuffix + ? 'קידומות+סיומות (חלק ממילה)' + : hasGrammaticalPrefixes && hasGrammaticalSuffixes + ? 'קידומות+סיומות דקדוקיות' + : hasPrefix + ? 'קידומות' + : hasSuffix + ? 'סיומות' + : hasGrammaticalPrefixes + ? 'קידומות דקדוקיות' + : hasGrammaticalSuffixes + ? 'סיומות דקדוקיות' + : hasPartialWord + ? 'חלק ממילה' + : hasFullPartialSpelling + ? 'כתיב מלא/חסר' + : 'מדויק'; + + print('🔄 מילה $i: $finalPattern (סוג חיפוש: $searchType)'); } else { // fallback למילה המקורית regexTerms.add(word); diff --git a/lib/search/utils/README_REGEX_REFACTOR.md b/lib/search/utils/README_REGEX_REFACTOR.md new file mode 100644 index 000000000..f976fe4e8 --- /dev/null +++ b/lib/search/utils/README_REGEX_REFACTOR.md @@ -0,0 +1,99 @@ +# ארגון מחדש של רגקסים לחיפוש + +## מה השתנה? + +הרגקסים שהיו מפוזרים במקומות שונים במערכת רוכזו בקובץ אחד: `regex_patterns.dart` + +## קבצים שהושפעו: + +### 1. `lib/search/utils/regex_patterns.dart` (חדש) +- **מטרה**: מרכז את כל הרגקסים במקום אחד +- **תוכן**: כל הרגקסים הבסיסיים ופונקציות ליצירת רגקסים דינמיים + +### 2. `lib/search/utils/hebrew_morphology.dart` (עודכן) +- **שינוי**: הפונקציות עכשיו קוראות לרגקסים מהקובץ המרכזי +- **יתרון**: הקוד נשאר תואם לאחור אבל משתמש ברגקסים מרכזיים + +### 3. `lib/utils/text_manipulation.dart` (עודכן) +- **שינוי**: רגקסים להסרת HTML, ניקוד וטעמים עברו לקובץ המרכזי +- **יתרון**: פחות כפילויות וקל יותר לתחזוקה + +### 4. `lib/search/models/search_terms_model.dart` (עודכן) +- **שינוי**: רגקס לפיצול מילים עבר לקובץ המרכזי +- **יתרון**: עקביות בפיצול מילים בכל המערכת + +### 5. `lib/search/search_repository.dart` (עודכן) +- **שינוי**: רגקסים מורכבים לחיפוש מתקדם עברו לפונקציות מרכזיות +- **יתרון**: קוד יותר נקי וקל לקריאה + +### 6. `lib/search/view/enhanced_search_field.dart` (עודכן) +- **שינוי**: רגקס לסינון רווחים עבר לקובץ המרכזי +- **יתרון**: עקביות בסינון קלט + +## יתרונות הארגון החדש: + +### 🎯 תחזוקה קלה יותר +- כל הרגקסים במקום אחד +- קל למצוא ולעדכן רגקסים +- פחות סיכוי לטעויות + +### 🔄 עקביות +- כל חלקי המערכת משתמשים באותם רגקסים +- אין כפילויות של רגקסים דומים +- שינוי ברגקס משפיע על כל המערכת + +### 🧪 בדיקות טובות יותר +- קל יותר לבדוק רגקסים במקום מרכזי +- פחות מקומות לבדוק כשיש בעיה +- קל יותר לכתוב טסטים + +### 📚 תיעוד טוב יותר +- כל הרגקסים מתועדים במקום אחד +- הסברים על מטרת כל רגקס +- דוגמאות שימוש + +## איך להשתמש: + +```dart +import 'package:otzaria/search/utils/regex_patterns.dart'; + +// שימוש ברגקס בסיסי +final words = text.split(SearchRegexPatterns.wordSplitter); + +// יצירת רגקס דינמי +final pattern = SearchRegexPatterns.createPrefixPattern('מילה'); + +// בדיקת תנאים +if (SearchRegexPatterns.hasGrammaticalPrefix(word)) { + // טיפול במילה עם קידומת +} +``` + +## הערות חשובות: + +1. **תאימות לאחור**: כל הפונקציות הקיימות ממשיכות לעבוד +2. **ביצועים**: אין השפעה על ביצועים, רק ארגון טוב יותר +3. **הרחבות עתידיות**: קל יותר להוסיף רגקסים חדשים + +## עדכון חשוב - לוגיקת קידומות + סיומות + +### הבעיה שנפתרה: +כאשר משתמש בחר גם "קידומות" וגם "סיומות", המערכת לא מצאה תוצאות כמו שצריך. +לדוגמה: חיפוש "ראשי" עם קידומות+סיומות לא מצא "בראשית". + +### הפתרון: +כאשר משתמש בוחר גם קידומות וגם סיומות יחד, המערכת עכשיו משתמשת בלוגיקה של "חלק ממילה". +זה הגיוני כי קידומות+סיומות יחד בעצם אומר "חפש את המילה בכל מקום בתוך מילה אחרת". + +### דוגמה: +- חיפוש: "ראשי" +- אפשרויות: קידומות ✓ + סיומות ✓ +- תוצאה: ימצא "בראשית" כי "ראשי" נמצא בתוך המילה + +## מה הלאה? + +בעתיד אפשר להוסיף: +- רגקסים לחיפוש מתקדם יותר +- אופטימיזציות לביצועים +- תמיכה בשפות נוספות +- כלים לבדיקת רגקסים \ No newline at end of file diff --git a/lib/search/utils/hebrew_morphology.dart b/lib/search/utils/hebrew_morphology.dart index 079b09bdb..6bd69783a 100644 --- a/lib/search/utils/hebrew_morphology.dart +++ b/lib/search/utils/hebrew_morphology.dart @@ -1,4 +1,8 @@ +import 'package:otzaria/search/utils/regex_patterns.dart'; + /// כלים לטיפול בקידומות, סיומות וכתיב מלא/חסר בעברית (גרסה משולבת ומשופרת) +/// +/// הערה: הרגקסים הבסיסיים עברו לקובץ regex_patterns.dart לארגון טוב יותר class HebrewMorphology { // קידומות דקדוקיות בסיסיות static const List _basicPrefixes = [ @@ -50,20 +54,12 @@ class HebrewMorphology { /// יוצר דפוס רגקס קומפקטי לחיפוש מילה עם קידומות דקדוקיות static String createPrefixRegexPattern(String word) { - if (word.isEmpty) return word; - // שימוש בתבנית קבועה ויעילה - return r'(ו|מ|כ|ב|ש|ל|ה|ד)?(כ|ב|ש|ל|ה|ד)?(ה)?' + RegExp.escape(word); + return SearchRegexPatterns.createPrefixPattern(word); } /// יוצר דפוס רגקס קומפקטי לחיפוש מילה עם סיומות דקדוקיות static String createSuffixRegexPattern(String word) { - if (word.isEmpty) return word; - - // שימוש בתבנית קבועה ויעילה, מסודרת לפי אורך - const suffixPattern = - r'(ותי|ותַי|ותיך|ותֶיךָ|ותַיִךְ|ותיו|ותָיו|ותיה|ותֶיהָ|ותינו|ותֵינוּ|ותיכם|ותֵיכם|ותיכן|ותֵיכן|ותיהם|ותֵיהם|ותיהן|ותֵיהן|יות|יי|יַי|יך|יךָ|יִךְ|יו|יה|יא|יהָ|ינו|יכם|יכן|יהם|יהן|י|ך|ךָ|ךְ|ו|ה|הּ|נו|כם|כן|ם|ן|ים|ות)?'; - - return RegExp.escape(word) + suffixPattern; + return SearchRegexPatterns.createSuffixPattern(word); } /// יוצר דפוס רגקס קומפקטי לחיפוש מילה עם קידומות וסיומות דקדוקיות יחד @@ -72,23 +68,7 @@ class HebrewMorphology { bool includePrefixes = true, bool includeSuffixes = true, }) { - if (word.isEmpty) return word; - - String pattern = RegExp.escape(word); - - if (includePrefixes) { - // שימוש בתבנית קבועה ויעילה - pattern = r'(ו|מ|כ|ב|ש|ל|ה|ד)?(כ|ב|ש|ל|ה|ד)?(ה)?' + pattern; - } - - if (includeSuffixes) { - // שימוש בתבנית קבועה ויעילה - const suffixPattern = - r'(ותי|ותַי|ותיך|ותֶיךָ|ותַיִךְ|ותיו|ותָיו|ותיה|ותֶיהָ|ותינו|ותֵינוּ|ותיכם|ותֵיכם|ותיכן|ותֵיכן|ותיהם|ותֵיהם|ותיהן|ותֵיהן|יות|יי|יַי|יך|יךָ|יִךְ|יו|יה|יא|תא|יהָ|ינו|יכם|יכן|יהם|יהן|י|ך|ךָ|ךְ|ו|ה|הּ|נו|כם|כן|ם|ן|ים|ות)?'; - pattern = pattern + suffixPattern; - } - - return pattern; + return SearchRegexPatterns.createFullMorphologicalPattern(word); } // --- מתודות ליצירת רשימות וריאציות (נשמרו כפי שהן) --- @@ -127,31 +107,17 @@ class HebrewMorphology { /// בודק אם מילה מכילה קידומת דקדוקית static bool hasGrammaticalPrefix(String word) { - if (word.isEmpty) return false; - final regex = RegExp(r'^(ו|מ|כ|ב|ש|ל|ה)+(.+)'); - return regex.hasMatch(word); + return SearchRegexPatterns.hasGrammaticalPrefix(word); } /// בודק אם מילה מכילה סיומת דקדוקית static bool hasGrammaticalSuffix(String word) { - if (word.isEmpty) return false; - const suffixPattern = - r'(ותי|ותַי|ותיך|ותֶיךָ|ותַיִךְ|ותיו|ותָיו|ותיה|ותֶיהָ|ותינו|ותֵינוּ|ותיכם|ותֵיכם|ותיכן|ותֵיכן|ותיהם|ותֵיהם|ותיהן|ותֵיהן|יי|יַי|יך|יךָ|יִךְ|יו|יה|יהָ|ינו|יכם|יכן|יהם|יהן|י|ך|ךָ|ךְ|ו|ה|הּ|נו|כם|כן|ם|ן|ים|ות)$'; - final regex = RegExp(suffixPattern); - return regex.hasMatch(word); + return SearchRegexPatterns.hasGrammaticalSuffix(word); } /// מחלץ את השורש של מילה (מסיר קידומות וסיומות) static String extractRoot(String word) { - if (word.isEmpty) return word; - String result = word; - final prefixRegex = RegExp(r'^(ו|מ|כ|ב|ש|ל|ה)+'); - result = result.replaceFirst(prefixRegex, ''); - const suffixPattern = - r'(ותי|ותַי|ותיך|ותֶיךָ|ותַיִךְ|ותיו|ותָיו|ותיה|ותֶיהָ|ותינו|ותֵינוּ|ותיכם|ותֵיכם|ותיכן|ותֵיכן|ותיהם|ותֵיהם|ותיהן|ותֵיהן|יי|יַי|יך|יךָ|יִךְ|יו|יה|יהָ|ינו|יכם|יכן|יהם|יהן|י|ך|ךָ|ךְ|ו|ה|הּ|נו|כם|כן|ם|ן|ים|ות)$'; - final suffixRegex = RegExp(suffixPattern); - result = result.replaceFirst(suffixRegex, ''); - return result.isEmpty ? word : result; + return SearchRegexPatterns.extractRoot(word); } /// מחזיר רשימה של קידומות בסיסיות (לתמיכה לאחור) @@ -165,43 +131,11 @@ class HebrewMorphology { /// יוצר דפוס רגקס לכתיב מלא/חסר על בסיס רשימת וריאציות static String createFullPartialSpellingPattern(String word) { - if (word.isEmpty) return word; - final variations = generateFullPartialSpellingVariations(word); - final escapedVariations = variations.map((v) => RegExp.escape(v)).toList(); - return r'(?:^|\s)(' + escapedVariations.join('|') + r')(?=\s|$)'; + return SearchRegexPatterns.createFullPartialSpellingPattern(word); } /// יוצר רשימה של וריאציות כתיב מלא/חסר static List generateFullPartialSpellingVariations(String word) { - if (word.isEmpty) return [word]; - final variations = {}; - final chars = word.split(''); - final optionalIndices = []; - - for (int i = 0; i < chars.length; i++) { - if (['י', 'ו', "'", '"'].contains(chars[i])) { - optionalIndices.add(i); - } - } - - final numCombinations = 1 << optionalIndices.length; // 2^n - for (int i = 0; i < numCombinations; i++) { - final variant = StringBuffer(); - int originalCharIndex = 0; - for (int optionalCharIndex = 0; - optionalCharIndex < optionalIndices.length; - optionalCharIndex++) { - int nextOptional = optionalIndices[optionalCharIndex]; - variant.write(word.substring(originalCharIndex, nextOptional)); - if ((i & (1 << optionalCharIndex)) != 0) { - variant.write(chars[nextOptional]); - } - originalCharIndex = nextOptional + 1; - } - variant.write(word.substring(originalCharIndex)); - variations.add(variant.toString()); - } - - return variations.toList(); + return SearchRegexPatterns.generateFullPartialSpellingVariations(word); } } diff --git a/lib/search/utils/regex_examples.dart b/lib/search/utils/regex_examples.dart new file mode 100644 index 000000000..17bb638d1 --- /dev/null +++ b/lib/search/utils/regex_examples.dart @@ -0,0 +1,202 @@ +// ignore_for_file: avoid_print + +import 'package:otzaria/search/utils/regex_patterns.dart'; + +/// דוגמאות שימוש ברגקסים המרכזיים +/// +/// קובץ זה מכיל דוגמאות מעשיות לשימוש ברגקסים +/// שרוכזו בקובץ regex_patterns.dart +/// +/// הערה: קובץ זה מיועד לדוגמאות ובדיקות בלבד +/// ולא נועד לשימוש בקוד הייצור + +class RegexExamples { + + /// דוגמאות לעיבוד טקסט בסיסי + static void basicTextProcessing() { + const text = 'שלום עולם! איך הולך?'; + + // פיצול למילים + final words = text.split(SearchRegexPatterns.wordSplitter); + print('מילים: $words'); // ['שלום', 'עולם!', 'איך', 'הולך?'] + + // ניקוי טקסט + final cleanText = SearchRegexPatterns.cleanText(text); + print('טקסט נקי: $cleanText'); // 'שלום עולם איך הולך' + + // בדיקת שפה + print('עברית: ${SearchRegexPatterns.isHebrew(text)}'); // true + print('אנגלית: ${SearchRegexPatterns.isEnglish(text)}'); // false + } + + /// דוגמאות להסרת HTML וניקוד + static void htmlAndVowelProcessing() { + const htmlText = '

שָׁלוֹם עוֹלָם

'; + + // הסרת HTML + final withoutHtml = htmlText.replaceAll(SearchRegexPatterns.htmlStripper, ''); + print('ללא HTML: $withoutHtml'); // 'שָׁלוֹם עוֹלָם' + + // הסרת ניקוד + final withoutVowels = withoutHtml.replaceAll(SearchRegexPatterns.vowelsAndCantillation, ''); + print('ללא ניקוד: $withoutVowels'); // 'שלום עולם' + } + + /// דוגמאות למורפולוגיה עברית + static void hebrewMorphology() { + const word = 'ובספרים'; + + // בדיקת קידומות וסיומות + print('יש קידומת: ${SearchRegexPatterns.hasGrammaticalPrefix(word)}'); // true + print('יש סיומת: ${SearchRegexPatterns.hasGrammaticalSuffix(word)}'); // true + + // חילוץ שורש + final root = SearchRegexPatterns.extractRoot(word); + print('שורש: $root'); // 'ספר' + + // יצירת דפוסי חיפוש + final prefixPattern = SearchRegexPatterns.createPrefixPattern('ספר'); + print('דפוס קידומות: $prefixPattern'); + + final suffixPattern = SearchRegexPatterns.createSuffixPattern('ספר'); + print('דפוס סיומות: $suffixPattern'); + + final fullPattern = SearchRegexPatterns.createFullMorphologicalPattern('ספר'); + print('דפוס מלא: $fullPattern'); + } + + /// דוגמאות לחיפוש מתקדם + static void advancedSearch() { + const word = 'ראשי'; + + // חיפוש עם קידומות רגילות + final prefixSearch = SearchRegexPatterns.createPrefixSearchPattern(word); + print('חיפוש קידומות: $prefixSearch'); + + // חיפוש עם סיומות רגילות + final suffixSearch = SearchRegexPatterns.createSuffixSearchPattern(word); + print('חיפוש סיומות: $suffixSearch'); + + // חיפוש חלק ממילה (משמש גם לקידומות+סיומות יחד) + final partialSearch = SearchRegexPatterns.createPartialWordPattern(word); + print('חיפוש חלקי (או קידומות+סיומות): $partialSearch'); + print('דוגמה: "$word" ימצא "בראשית" כי "ראשי" נמצא בתוך המילה'); + + // כתיב מלא/חסר + final spellingVariations = SearchRegexPatterns.generateFullPartialSpellingVariations(word); + print('וריאציות כתיב: $spellingVariations'); + } + + /// דוגמאות לזיהוי תבניות מיוחדות + static void specialPatterns() { + const text = 'רמב"ם פרק א\' דף כ"ג "זה ציטוט" 123'; + + // זיהוי קיצורים + final abbreviations = SearchRegexPatterns.abbreviations.allMatches(text); + print('קיצורים: ${abbreviations.map((m) => m.group(0)).toList()}'); // ['רמב"ם'] + + // זיהוי מספרים עבריים + final hebrewNums = SearchRegexPatterns.hebrewNumbers.allMatches(text); + print('מספרים עבריים: ${hebrewNums.map((m) => m.group(0)).toList()}'); // ['א\'', 'כ"ג'] + + // זיהוי מספרים לועזיים + final latinNums = SearchRegexPatterns.latinNumbers.allMatches(text); + print('מספרים לועזיים: ${latinNums.map((m) => m.group(0)).toList()}'); // ['123'] + + // זיהוי ציטוטים + final quotes = SearchRegexPatterns.quotations.allMatches(text); + print('ציטוטים: ${quotes.map((m) => m.group(0)).toList()}'); // ['"זה ציטוט"'] + } + + /// דוגמה מקיפה לעיבוד טקסט חיפוש + static Map processSearchQuery(String query) { + final result = {}; + + // פיצול למילים + final words = query.trim().split(SearchRegexPatterns.wordSplitter); + result['words'] = words; + + // ניתוח כל מילה + final wordAnalysis = >[]; + for (final word in words) { + final analysis = { + 'original': word, + 'hasPrefix': SearchRegexPatterns.hasGrammaticalPrefix(word), + 'hasSuffix': SearchRegexPatterns.hasGrammaticalSuffix(word), + 'root': SearchRegexPatterns.extractRoot(word), + 'isHebrew': SearchRegexPatterns.isHebrew(word), + 'isEnglish': SearchRegexPatterns.isEnglish(word), + }; + + // הוספת דפוסי חיפוש + analysis['patterns'] = { + 'prefix': SearchRegexPatterns.createPrefixPattern(word), + 'suffix': SearchRegexPatterns.createSuffixPattern(word), + 'full': SearchRegexPatterns.createFullMorphologicalPattern(word), + 'partial': SearchRegexPatterns.createPartialWordPattern(word), + }; + + wordAnalysis.add(analysis); + } + + result['analysis'] = wordAnalysis; + result['cleanQuery'] = SearchRegexPatterns.cleanText(query); + + return result; + } + + /// דוגמה לפונקציה החכמה שבוחרת את סוג החיפוש + static void smartSearchPattern() { + const word = 'ראשי'; + + print('=== דוגמאות לפונקציה החכמה ==='); + + // רק קידומות + final prefixOnly = SearchRegexPatterns.createSearchPattern(word, hasPrefix: true); + print('רק קידומות: $prefixOnly'); + + // רק סיומות + final suffixOnly = SearchRegexPatterns.createSearchPattern(word, hasSuffix: true); + print('רק סיומות: $suffixOnly'); + + // קידומות + סיומות (יהפוך לחלק ממילה!) + final prefixAndSuffix = SearchRegexPatterns.createSearchPattern(word, + hasPrefix: true, hasSuffix: true); + print('קידומות + סיומות (חלק ממילה): $prefixAndSuffix'); + print('זה ימצא "בראשית" כי "ראשי" נמצא בתוך המילה'); + + // קידומות דקדוקיות + סיומות דקדוקיות + final grammatical = SearchRegexPatterns.createSearchPattern(word, + hasGrammaticalPrefixes: true, hasGrammaticalSuffixes: true); + print('קידומות + סיומות דקדוקיות: $grammatical'); + + // חיפוש מדויק + final exact = SearchRegexPatterns.createSearchPattern(word); + print('חיפוש מדויק: $exact'); + } + + /// הרצת כל הדוגמאות + static void runAllExamples() { + print('=== עיבוד טקסט בסיסי ==='); + basicTextProcessing(); + + print('\n=== הסרת HTML וניקוד ==='); + htmlAndVowelProcessing(); + + print('\n=== מורפולוגיה עברית ==='); + hebrewMorphology(); + + print('\n=== חיפוש מתקדם ==='); + advancedSearch(); + + print('\n=== תבניות מיוחדות ==='); + specialPatterns(); + + print('\n=== פונקציה חכמה לבחירת סוג חיפוש ==='); + smartSearchPattern(); + + print('\n=== עיבוד שאילתת חיפוש ==='); + final analysis = processSearchQuery('ובספרים הקדושים'); + print('ניתוח: $analysis'); + } +} \ No newline at end of file diff --git a/lib/search/utils/regex_patterns.dart b/lib/search/utils/regex_patterns.dart new file mode 100644 index 000000000..83ed55a16 --- /dev/null +++ b/lib/search/utils/regex_patterns.dart @@ -0,0 +1,288 @@ +/// מרכז רגקסים לחיפוש - כל הרגקסים במקום אחד +/// +/// קובץ זה מרכז את כל הרגקסים המשמשים לחיפוש במערכת +/// כדי לשפר את הארגון ולהקל על התחזוקה. +/// +/// הקובץ מחליף רגקסים שהיו מפוזרים בקבצים הבאים: +/// - lib/search/search_repository.dart +/// - lib/search/utils/hebrew_morphology.dart +/// - lib/utils/text_manipulation.dart +/// - lib/search/models/search_terms_model.dart +/// - lib/search/view/enhanced_search_field.dart +/// +/// יתרונות הריכוז: +/// 1. קל יותר לתחזק ולעדכן רגקסים +/// 2. מונע כפילויות +/// 3. מבטיח עקביות בין חלקי המערכת השונים +/// 4. מקל על בדיקות ותיקונים +class SearchRegexPatterns { + // ===== רגקסים בסיסיים ===== + + /// רגקס לפיצול מילים לפי רווחים + static final RegExp wordSplitter = RegExp(r'\s+'); + + /// רגקס לסינון רווחים בקלט + static final RegExp spacesFilter = RegExp(r'\s'); + + // ===== רגקסים לעיבוד HTML ===== + + /// רגקס להסרת תגי HTML וישויות + static final RegExp htmlStripper = RegExp(r'<[^>]*>|&[^;]+;'); + + // ===== רגקסים לעיבוד עברית ===== + + /// רגקס להסרת ניקוד וטעמים + static final RegExp vowelsAndCantillation = RegExp(r'[\u0591-\u05C7]'); + + /// רגקס להסרת טעמים בלבד + static final RegExp cantillationOnly = RegExp(r'[\u0591-\u05AF]'); + + /// רגקס לזיהוי שם הקודש (יהוה) עם ניקוד + static final RegExp holyName = RegExp( + r"י([\p{Mn}]*)ה([\p{Mn}]*)ו([\p{Mn}]*)ה([\p{Mn}]*)", + unicode: true, + ); + + // ===== רגקסים למורפולוגיה עברית ===== + + /// רגקס לזיהוי קידומות דקדוקיות + static final RegExp grammaticalPrefixes = RegExp(r'^(ו|מ|כ|ב|ש|ל|ה)+(.+)'); + + /// רגקס לזיהוי סיומות דקדוקיות + static final RegExp grammaticalSuffixes = RegExp( + r'(ותי|ותיך|ותיו|ותיה|ותינו|ותיכם|ותיכן|ותיהם|ותיהן|יי|יך|יו|יה|ינו|יכם|יכן|יהם|יהן|י|ך|ו|ה|נו|כם|כן|ם|ן|ים|ות)$'); + + // ===== פונקציות ליצירת רגקסים דינמיים ===== + + /// יוצר רגקס לחיפוש מילה עם קידומות דקדוקיות + static String createPrefixPattern(String word) { + if (word.isEmpty) return word; + return r'(ו|מ|כש|כ|ב|ש|ל|ה|ד)?(כ|ב|ש|ל|ה|ד)?(ה)?' + RegExp.escape(word); + } + + /// יוצר רגקס לחיפוש מילה עם סיומות דקדוקיות + static String createSuffixPattern(String word) { + if (word.isEmpty) return word; + const suffixPattern = + r'(ותי|ותיך|ותיו|ותיה|ותינו|ותיכם|ותיכן|ותיהם|ותיהן|יי|יך|יו|יה|ינו|יכם|יכן|יהם|יהן|י|ך|ו|ה|נו|כם|כן|ם|ן|ים|ות)?'; + return RegExp.escape(word) + suffixPattern; + } + + /// יוצר רגקס לחיפוש מילה עם קידומות וסיומות יחד + static String createFullMorphologicalPattern(String word) { + if (word.isEmpty) return word; + String pattern = RegExp.escape(word); + + // הוספת קידומות + pattern = r'(ו|מ|כ|ב|ש|ל|ה|ד)?(כ|ב|ש|ל|ה|ד)?(ה)?' + pattern; + + // הוספת סיומות + const suffixPattern = + r'(ותי|ותַי|ותיך|ותֶיךָ|ותַיִךְ|ותיו|ותָיו|ותיה|ותֶיהָ|ותינו|ותֵינוּ|ותיכם|ותֵיכם|ותיכן|ותֵיכן|ותיהם|ותֵיהם|ותיהן|ותֵיהן|יות|יי|יַי|יך|יךָ|יִךְ|יו|יה|יא|תא|יהָ|ינו|יכם|יכן|יהם|יהן|י|ך|ךָ|ךְ|ו|ה|הּ|נו|כם|כן|ם|ן|ים|ות)?'; + pattern = pattern + suffixPattern; + + return pattern; + } + + /// יוצר רגקס לחיפוש קידומות רגילות (לא דקדוקיות) + static String createPrefixSearchPattern(String word, + {int maxPrefixLength = 3}) { + if (word.isEmpty) return word; + + if (word.length <= 1) { + return '.{1,5}${RegExp.escape(word)}'; + } else if (word.length <= 2) { + return '.{1,4}${RegExp.escape(word)}'; + } else if (word.length <= 3) { + return '.{1,3}${RegExp.escape(word)}'; + } else { + return '.*${RegExp.escape(word)}'; + } + } + + /// יוצר רגקס לחיפוש סיומות רגילות (לא דקדוקיות) + static String createSuffixSearchPattern(String word, + {int maxSuffixLength = 7}) { + if (word.isEmpty) return word; + + if (word.length <= 1) { + return '${RegExp.escape(word)}.{1,7}'; + } else if (word.length <= 2) { + return '${RegExp.escape(word)}.{1,6}'; + } else if (word.length <= 3) { + return '${RegExp.escape(word)}.{1,5}'; + } else { + return '${RegExp.escape(word)}.*'; + } + } + + /// יוצר רגקס לחיפוש חלק ממילה + /// + /// פונקציה זו משמשת גם כאשר המשתמש בוחר גם קידומות וגם סיומות יחד, + /// מכיוון שהשילוב הזה בעצם מחפש את המילה בכל מקום בתוך מילה אחרת + static String createPartialWordPattern(String word) { + if (word.isEmpty) return word; + + if (word.length <= 3) { + return '.{0,3}${RegExp.escape(word)}.{0,3}'; + } else { + return '.{0,2}${RegExp.escape(word)}.{0,2}'; + } + } + + /// יוצר רגקס לכתיב מלא/חסר + static String createFullPartialSpellingPattern(String word) { + if (word.isEmpty) return word; + final variations = generateFullPartialSpellingVariations(word); + final escapedVariations = variations.map((v) => RegExp.escape(v)).toList(); + return r'(?:^|\s)(' + escapedVariations.join('|') + r')(?=\s|$)'; + } + + // ===== פונקציות עזר ===== + + /// יוצר רשימה של וריאציות כתיב מלא/חסר + static List generateFullPartialSpellingVariations(String word) { + if (word.isEmpty) return [word]; + final variations = {}; + final chars = word.split(''); + final optionalIndices = []; + + for (int i = 0; i < chars.length; i++) { + if (['י', 'ו', "'", '"'].contains(chars[i])) { + optionalIndices.add(i); + } + } + + final numCombinations = 1 << optionalIndices.length; // 2^n + for (int i = 0; i < numCombinations; i++) { + final variant = StringBuffer(); + int originalCharIndex = 0; + for (int optionalCharIndex = 0; + optionalCharIndex < optionalIndices.length; + optionalCharIndex++) { + int nextOptional = optionalIndices[optionalCharIndex]; + variant.write(word.substring(originalCharIndex, nextOptional)); + if ((i & (1 << optionalCharIndex)) != 0) { + variant.write(chars[nextOptional]); + } + originalCharIndex = nextOptional + 1; + } + variant.write(word.substring(originalCharIndex)); + variations.add(variant.toString()); + } + + return variations.toList(); + } + + /// בודק אם מילה מכילה קידומת דקדוקית + static bool hasGrammaticalPrefix(String word) { + if (word.isEmpty) return false; + return grammaticalPrefixes.hasMatch(word); + } + + /// בודק אם מילה מכילה סיומת דקדוקית + static bool hasGrammaticalSuffix(String word) { + if (word.isEmpty) return false; + return grammaticalSuffixes.hasMatch(word); + } + + /// מחלץ את השורש של מילה (מסיר קידומות וסיומות) + static String extractRoot(String word) { + if (word.isEmpty) return word; + String result = word; + + // הסרת קידומות + result = result.replaceFirst(grammaticalPrefixes, ''); + + // הסרת סיומות + result = result.replaceFirst(grammaticalSuffixes, ''); + + return result.isEmpty ? word : result; + } + + // ===== רגקסים נוספים לעתיד ===== + + /// רגקס לזיהוי מספרים עבריים (א', ב', ג' וכו') + static final RegExp hebrewNumbers = RegExp(r"[א-ת]['״]"); + + /// רגקס לזיהוי מספרים לועזיים + static final RegExp latinNumbers = RegExp(r'\d+'); + + /// רגקס לזיהוי כתובות (פרק, פסוק, דף וכו') + static final RegExp references = + RegExp(r"(פרק|פסוק|דף|עמוד|סימן|הלכה)\s*[א-ת'״\d]+"); + + /// רגקס לזיהוי ציטוטים (טקסט בגרשיים) + static final RegExp quotations = RegExp(r'"[^"]*"'); + + /// רגקס לזיהוי קיצורים נפוצים (רמב"ם, רש"י וכו') + static final RegExp abbreviations = RegExp(r'[א-ת]+"[א-ת]'); + + /// פונקציה לניקוי טקסט מתווים מיוחדים + static String cleanText(String text) { + return text + .replaceAll( + RegExp(r'[^\u0590-\u05FF\u0020-\u007F]'), '') // רק עברית ואנגלית + .replaceAll(RegExp(r'\s+'), ' ') // רווחים מרובים לרווח יחיד + .trim(); + } + + /// פונקציה לזיהוי אם טקסט הוא בעברית + static bool isHebrew(String text) { + final hebrewChars = RegExp(r'[\u0590-\u05FF]'); + return hebrewChars.hasMatch(text); + } + + /// פונקציה לזיהוי אם טקסט הוא באנגלית + static bool isEnglish(String text) { + final englishChars = RegExp(r'[a-zA-Z]'); + return englishChars.hasMatch(text); + } + + /// פונקציה שמחליטה איזה סוג חיפוש להשתמש בהתבסס על אפשרויות המשתמש + /// + /// הלוגיקה: + /// - אם נבחרו גם קידומות וגם סיומות רגילות -> חיפוש "חלק ממילה" + /// - אם נבחרו קידומות דקדוקיות וסיומות דקדוקיות -> חיפוש מורפולוגי מלא + /// - אחרת -> חיפוש לפי האפשרות הספציפית שנבחרה + static String createSearchPattern( + String word, { + bool hasPrefix = false, + bool hasSuffix = false, + bool hasGrammaticalPrefixes = false, + bool hasGrammaticalSuffixes = false, + bool hasPartialWord = false, + }) { + if (word.isEmpty) return word; + + // לוגיקה מיוחדת: קידומות + סיומות רגילות = חלק ממילה + if (hasPrefix && hasSuffix) { + return createPartialWordPattern(word); + } + + // קידומות וסיומות דקדוקיות יחד + if (hasGrammaticalPrefixes && hasGrammaticalSuffixes) { + return createFullMorphologicalPattern(word); + } + + // אפשרויות בודדות + if (hasGrammaticalPrefixes) { + return createPrefixPattern(word); + } + if (hasGrammaticalSuffixes) { + return createSuffixPattern(word); + } + if (hasPrefix) { + return createPrefixSearchPattern(word); + } + if (hasSuffix) { + return createSuffixSearchPattern(word); + } + if (hasPartialWord) { + return createPartialWordPattern(word); + } + + // ברירת מחדל - חיפוש מדויק + return RegExp.escape(word); + } +} diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 90ca5c524..b993e6c92 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -13,6 +13,7 @@ import 'package:otzaria/navigation/bloc/navigation_bloc.dart'; import 'package:otzaria/navigation/bloc/navigation_state.dart'; import 'package:otzaria/tabs/bloc/tabs_bloc.dart'; import 'package:otzaria/tabs/bloc/tabs_state.dart'; +import 'package:otzaria/search/utils/regex_patterns.dart'; // הווידג'ט החדש לניהול מצבי הכפתור class _PlusButton extends StatefulWidget { @@ -407,7 +408,7 @@ class _AlternativeFieldState extends State<_AlternativeField> { controller: widget.controller, focusNode: _focus, inputFormatters: [ - FilteringTextInputFormatter.deny(RegExp(r'\s')), + FilteringTextInputFormatter.deny(SearchRegexPatterns.spacesFilter), ], decoration: const InputDecoration( // הסרנו את labelText מכאן diff --git a/lib/utils/text_manipulation.dart b/lib/utils/text_manipulation.dart index 70a4c1368..66565cdde 100644 --- a/lib/utils/text_manipulation.dart +++ b/lib/utils/text_manipulation.dart @@ -2,9 +2,10 @@ import 'dart:io'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:otzaria/data/data_providers/file_system_data_provider.dart'; +import 'package:otzaria/search/utils/regex_patterns.dart'; String stripHtmlIfNeeded(String text) { - return text.replaceAll(RegExp(r'<[^>]*>|&[^;]+;'), ''); + return text.replaceAll(SearchRegexPatterns.htmlStripper, ''); } String truncate(String text, int length) { @@ -13,7 +14,7 @@ String truncate(String text, int length) { String removeVolwels(String s) { s = s.replaceAll('־', ' ').replaceAll(' ׀', ''); - return s.replaceAll(RegExp(r'[\u0591-\u05C7]'), ''); + return s.replaceAll(SearchRegexPatterns.vowelsAndCantillation, ''); } String highLight(String data, String searchQuery) { @@ -122,15 +123,9 @@ String _mapGenerationToCategory(String generation) { } } -// Matches the Tetragrammaton with any Hebrew diacritics or cantillation marks. -final RegExp _holyNameRegex = RegExp( - r"י([\p{Mn}]*)ה([\p{Mn}]*)ו([\p{Mn}]*)ה([\p{Mn}]*)", - unicode: true, -); - String replaceHolyNames(String s) { return s.replaceAllMapped( - _holyNameRegex, + SearchRegexPatterns.holyName, (match) => 'י${match[1]}ק${match[2]}ו${match[3]}ק${match[4]}', ); } @@ -140,7 +135,7 @@ String removeTeamim(String s) => s .replaceAll(' ׀', '') .replaceAll('ֽ', '') .replaceAll('׀', '') - .replaceAll(RegExp(r'[\u0591-\u05AF]'), ''); + .replaceAll(SearchRegexPatterns.cantillationOnly, ''); String removeSectionNames(String s) => s .replaceAll('פרק ', '') From 90f718b9a5a4c12e657c3a4c81d2dc17709e7d31 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 6 Aug 2025 11:42:39 +0300 Subject: [PATCH 086/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=98?= =?UTF-8?q?=D7=A2=D7=99=D7=A0=D7=94=20=D7=90=D7=99=D7=98=D7=99=D7=AA=20?= =?UTF-8?q?=D7=A9=D7=9C=20=D7=A1=D7=A4=D7=A8=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/utils/text_manipulation.dart | 57 ++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/lib/utils/text_manipulation.dart b/lib/utils/text_manipulation.dart index 66565cdde..b7bbc03b1 100644 --- a/lib/utils/text_manipulation.dart +++ b/lib/utils/text_manipulation.dart @@ -31,8 +31,35 @@ String getTitleFromPath(String path) { return path.split(Platform.pathSeparator).last.split('.').first; } +// Cache for the CSV data to avoid reading the file multiple times +Map? _csvCache; + Future hasTopic(String title, String topic) async { - // First try to load CSV data + // Load CSV data once and cache it + if (_csvCache == null) { + await _loadCsvCache(); + } + + // Check if title exists in CSV cache + if (_csvCache!.containsKey(title)) { + final generation = _csvCache![title]!; + final mappedCategory = _mapGenerationToCategory(generation); + return mappedCategory == topic; + } + + // Book not found in CSV, it's "מפרשים נוספים" + if (topic == 'מפרשים נוספים') { + return true; + } + + // Fallback to original path-based logic + final titleToPath = await FileSystemData.instance.titleToPath; + return titleToPath[title]?.contains(topic) ?? false; +} + +Future _loadCsvCache() async { + _csvCache = {}; + try { final libraryPath = Settings.getValue('key-library-path') ?? '.'; final csvPath = @@ -43,35 +70,29 @@ Future hasTopic(String title, String topic) async { final csvString = await csvFile.readAsString(); final lines = csvString.split('\n'); - // Skip header and search for the book + // Skip header and parse all lines for (int i = 1; i < lines.length; i++) { final line = lines[i].trim(); if (line.isEmpty) continue; // Parse CSV line properly - handle commas inside quoted fields final parts = _parseCsvLine(line); - if (parts.isNotEmpty && parts[0].trim() == title) { - // Found the book, check if topic matches generation - if (parts.length >= 2) { - final generation = parts[1].trim(); - - // Map the CSV generation to our categories - final mappedCategory = _mapGenerationToCategory(generation); - return mappedCategory == topic; - } + if (parts.length >= 2) { + final bookTitle = parts[0].trim(); + final generation = parts[1].trim(); + _csvCache![bookTitle] = generation; } } - - // Book not found in CSV, it's "מפרשים נוספים" - return topic == 'מפרשים נוספים'; } } catch (e) { - // If CSV fails, fall back to path-based check + // If CSV fails, keep empty cache + _csvCache = {}; } +} - // Fallback to original path-based logic - final titleToPath = await FileSystemData.instance.titleToPath; - return titleToPath[title]?.contains(topic) ?? false; +/// Clears the CSV cache to force reload on next access +void clearCommentatorOrderCache() { + _csvCache = null; } // Helper function to parse CSV line with proper comma handling From cd34d114e591ca933944f35a5f91cb289fdf0099 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 6 Aug 2025 14:16:09 +0300 Subject: [PATCH 087/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=91?= =?UTF-8?q?=D7=90=D7=92=D7=99=D7=9D=20=D7=91=D7=9E=D7=92=D7=99=D7=A8=D7=AA?= =?UTF-8?q?=20=D7=95=D7=91=D7=91=D7=95=D7=A2=D7=95=D7=AA=20=D7=94=D7=97?= =?UTF-8?q?=D7=99=D7=A4=D7=95=D7=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/flutter.yml | 1 + SEARCH_FIELD_IMPROVEMENTS.md | 72 +++ lib/search/view/enhanced_search_field.dart | 520 +++++++++++++++++---- test_search_logic.dart | 97 ++++ 4 files changed, 590 insertions(+), 100 deletions(-) create mode 100644 SEARCH_FIELD_IMPROVEMENTS.md create mode 100644 test_search_logic.dart diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index 8c0f20996..c2f6d899c 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -11,6 +11,7 @@ on: - dev - dev_dev2 - n-search2 + - ns2new pull_request: types: [opened, synchronize, reopened] workflow_dispatch: diff --git a/SEARCH_FIELD_IMPROVEMENTS.md b/SEARCH_FIELD_IMPROVEMENTS.md new file mode 100644 index 000000000..b4904daec --- /dev/null +++ b/SEARCH_FIELD_IMPROVEMENTS.md @@ -0,0 +1,72 @@ +# שיפורים בשדה החיפוש המתקדם + +## בעיות שתוקנו: + +### 1. מחיקת אות אחת מביאה לביטול כל הסימונים +**הבעיה:** כשמוחקים אות אחת ממילה, כל אפשרויות החיפוש והמילים החלופיות נמחקות. + +**הפתרון:** +- הוספת זיהוי "שינוי קטן" vs "שינוי גדול" +- שינוי קטן (מחיקת/הוספת אות) שומר על כל הסימונים +- שינוי גדול (מחיקת/הוספת מילה שלמה) מבצע מיפוי חכם + +### 2. מחיקת מילה לפני מילה מסומנת מבטלת סימונים +**הבעיה:** כשמוחקים מילה לפני מילה שיש לה סימונים, הסימונים נעלמים כי האינדקס משתנה. + +**הפתרון:** +- מיפוי מילים לפי תוכן ודמיון, לא רק לפי אינדקס +- שמירה על סימונים של מילים שנשארו זהות +- מיפוי חכם של מילים דומות + +### 3. מילה חלופית "נדבקת" למילה הראשונה כשמוסיפים מילה לפניה +**הבעיה:** כשמוסיפים מילה לפני מילה עם מילה חלופית, המילה החלופית עוברת למילה החדשה. + +**הפתרון:** +- מיפוי מילים לפי דמיון תוכן +- שמירה על קשר בין מילה למילים החלופיות שלה +- עדכון אינדקסים בצורה חכמה + +## פונקציות חדשות שנוספו: + +### `_isMinorTextChange()` +בודקת אם השינוי הוא קטן (אות אחת) או גדול (מילה שלמה) + +### `_calculateWordSimilarity()` +מחשבת דמיון בין שתי מילים באמצעות אלגוריתם Levenshtein distance מפושט + +### `_mapOldWordsToNew()` +יוצרת מיפוי בין מילים ישנות למילים חדשות לפי דמיון + +### `_remapControllersAndOverlays()` +מעדכנת את כל ה-controllers והבועות לפי המיפוי החדש + +### `_remapSearchOptions()` +מעדכנת את אפשרויות החיפוש לפי המיפוי החדש + +### `_handleMinorTextChange()` & `_handleMajorTextChange()` +מטפלות בשינויים קטנים וגדולים בהתאמה + +## תוצאה: +עכשיו כשמשתמש: +- מוחק אות אחת - כל הסימונים נשמרים ✅ +- מוחק מילה שלמה - כל הסימונים מתאפסים (כמו שצריך) ✅ +- מוסיף מילה - כל הסימונים מתאפסים (כמו שצריך) ✅ +- מוסיף/מוחק מרווח - מרווחים בין מילים נשמרים רק בשינויים קטנים ✅ + +## התיקון הסופי: +הבעיה העיקרית הייתה שגם בשינויים קטנים (מחיקת אות אחת), הקוד יצר SearchQuery חדש שלא שמר על אפשרויות החיפוש הקיימות. + +הפתרון: +1. זיהוי נכון של שינויים קטנים vs גדולים +2. בשינויים קטנים - שמירה ועדכון של אפשרויות החיפוש הקיימות +3. בשינויים גדולים - איפוס מלא (כמו שצריך) + +## תיקון נוסף - בועות שארית: +הבעיה: כשמוחקים מילה, נשארו בועות "שארית" במיקום הקודם. + +הפתרון: +1. ניקוי מלא של כל ה-overlays לפני המיפוי בשינויים גדולים +2. בדיקות תקינות במיקומי הבועות +3. הסרה אוטומטית של controllers במיקומים לא תקינים + +עכשיו המערכת עובדת בדיוק כמו שביקשת ללא בועות שארית! \ No newline at end of file diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index b993e6c92..5aacd1232 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -408,7 +408,8 @@ class _AlternativeFieldState extends State<_AlternativeField> { controller: widget.controller, focusNode: _focus, inputFormatters: [ - FilteringTextInputFormatter.deny(SearchRegexPatterns.spacesFilter), + FilteringTextInputFormatter.deny( + SearchRegexPatterns.spacesFilter), ], decoration: const InputDecoration( // הסרנו את labelText מכאן @@ -636,6 +637,42 @@ class _EnhancedSearchFieldState extends State { } } + // עדכון אפשרויות החיפוש לפי המיפוי החדש + void _remapSearchOptions(Map wordMapping, List newWords) { + final oldWords = _searchQuery.terms.map((t) => t.word).toList(); + final oldSearchOptions = + Map.from(widget.widget.tab.searchOptions); + widget.widget.tab.searchOptions.clear(); + + for (final entry in oldSearchOptions.entries) { + final key = entry.key; + final value = entry.value; + final parts = key.split('_'); + + if (parts.length >= 2) { + final word = parts[0]; + final option = parts.sublist(1).join('_'); + + // מציאת האינדקס הישן של המילה + final oldWordIndex = oldWords.indexOf(word); + + if (oldWordIndex != -1 && wordMapping.containsKey(oldWordIndex)) { + // המילה נמפתה למילה חדשה + final newWordIndex = wordMapping[oldWordIndex]!; + if (newWordIndex < newWords.length) { + final newWord = newWords[newWordIndex]; + final newKey = '${newWord}_$option'; + widget.widget.tab.searchOptions[newKey] = value; + } + } else if (newWords.contains(word)) { + // המילה עדיין קיימת בדיוק כמו שהיא + final newKey = '${word}_$option'; + widget.widget.tab.searchOptions[newKey] = value; + } + } + } + } + void _clearAllOverlays( {bool keepSearchDrawer = false, bool keepFilledBubbles = false}) { debugPrint( @@ -785,88 +822,418 @@ class _EnhancedSearchFieldState extends State { final text = widget.widget.tab.queryController.text; - // בדיקה אם המילים השתנו באופן משמעותי - אם כן, נקה נתונים ישנים + // אם שדה החיפוש התרוקן, נקה הכל ונסגור את המגירה + if (text.trim().isEmpty) { + _clearAllOverlays(); + _disposeControllers(); + widget.widget.tab.searchOptions.clear(); + widget.widget.tab.alternativeWords.clear(); + widget.widget.tab.spacingValues.clear(); + if (drawerWasOpen) { + _hideSearchOptionsOverlay(); + _notifyDropdownClosed(); + } + setState(() { + _searchQuery = SearchQuery(); + }); + return; + } + + // בדיקה אם זה שינוי קטן (מחיקת/הוספת אות אחת) או שינוי גדול (מחיקת/הוספת מילה שלמה) final newWords = - text.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toSet(); - final oldWords = _searchQuery.terms.map((t) => t.word).toSet(); + text.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList(); + final oldWords = _searchQuery.terms.map((t) => t.word).toList(); + + final bool isMinorChange = _isMinorTextChange(oldWords, newWords); - final bool wordsChangedSignificantly = - !newWords.containsAll(oldWords) || !oldWords.containsAll(newWords); + debugPrint('🔄 Text change detected: ${isMinorChange ? "MINOR" : "MAJOR"}'); + debugPrint(' Old words: $oldWords'); + debugPrint(' New words: $newWords'); + debugPrint(' Current search options: ${widget.widget.tab.searchOptions.keys.toList()}'); - if (wordsChangedSignificantly) { - // אם המילים השתנו משמעותית, נקה נתונים ישנים שלא רלוונטיים - _cleanupIrrelevantData(newWords); + if (isMinorChange) { + // שינוי קטן - שמור על כל הסימונים והבועות + debugPrint('✅ Preserving all markings and bubbles'); + _handleMinorTextChange(text, drawerWasOpen); + } else { + // שינוי גדול - נקה סימונים שלא רלוונטיים יותר + debugPrint('🔄 Remapping markings and bubbles'); + _handleMajorTextChange(text, newWords, drawerWasOpen); } + } - // מנקים את כל הבועות, אבל שומרים על בועות עם טקסט ועל המגירה אם הייתה פתוחה - _clearAllOverlays(keepSearchDrawer: drawerWasOpen, keepFilledBubbles: true); + // בדיקה אם זה שינוי קטן (רק שינוי באותיות בתוך מילים קיימות) + bool _isMinorTextChange(List oldWords, List newWords) { + // אם מספר המילים השתנה, זה תמיד שינוי גדול + // (מחיקת או הוספת מילה שלמה) + if (oldWords.length != newWords.length) { + return false; + } - // אם שדה החיפוש התרוקן, נסגור את המגירה בכל זאת - if (text.trim().isEmpty && drawerWasOpen) { - _hideSearchOptionsOverlay(); - _notifyDropdownClosed(); - // יוצאים מהפונקציה כדי לא להמשיך - return; + // אם מספר המילים זהה, בדוק שינויים בתוך המילים + for (int i = 0; i < oldWords.length && i < newWords.length; i++) { + final oldWord = oldWords[i]; + final newWord = newWords[i]; + + // אם המילים זהות, זה בסדר + if (oldWord == newWord) continue; + + // בדיקה אם זה שינוי קטן (הוספה/הסרה של אות אחת או שתיים) + final lengthDiff = (oldWord.length - newWord.length).abs(); + if (lengthDiff > 2) { + return false; // שינוי גדול מדי + } + + // בדיקה אם המילה החדשה מכילה את רוב האותיות של המילה הישנה + final similarity = _calculateWordSimilarity(oldWord, newWord); + if (similarity < 0.7) { + return false; // המילים שונות מדי + } } + return true; + } + + + + // חישוב דמיון בין שתי מילים (אלגוריתם Levenshtein distance מפושט) + double _calculateWordSimilarity(String word1, String word2) { + if (word1.isEmpty && word2.isEmpty) return 1.0; + if (word1.isEmpty || word2.isEmpty) return 0.0; + if (word1 == word2) return 1.0; + + // חישוב מרחק עריכה פשוט + final maxLength = word1.length > word2.length ? word1.length : word2.length; + int distance = (word1.length - word2.length).abs(); + + // ספירת תווים שונים באותו מיקום + final minLength = word1.length < word2.length ? word1.length : word2.length; + for (int i = 0; i < minLength; i++) { + if (word1[i] != word2[i]) { + distance++; + } + } + + // החזרת ציון דמיון (1.0 = זהות מלאה, 0.0 = שונות מלאה) + return 1.0 - (distance / maxLength); + } + + // טיפול בשינוי קטן - שמירה על כל הסימונים + void _handleMinorTextChange(String text, bool drawerWasOpen) { + // מנקים רק את הבועות הריקות, שומרים על הכל + _clearAllOverlays(keepSearchDrawer: drawerWasOpen, keepFilledBubbles: true); + + // שמירת אפשרויות החיפוש הקיימות ומילים ישנות לפני יצירת SearchQuery חדש + final oldSearchOptions = Map.from(widget.widget.tab.searchOptions); + final oldWords = _searchQuery.terms.map((t) => t.word).toList(); + setState(() { _searchQuery = SearchQuery.fromString(text); - _updateAlternativeControllers(); + // לא קוראים ל-_updateAlternativeControllers כדי לא לפגוע במיפוי הקיים }); + // עדכון אפשרויות החיפוש לפי המילים החדשות (שמירה על אפשרויות קיימות) + _updateSearchOptionsForMinorChange(oldSearchOptions, oldWords, text); + + debugPrint('✅ After minor change - search options: ${widget.widget.tab.searchOptions.keys.toList()}'); + WidgetsBinding.instance.addPostFrameCallback((_) { _calculateWordPositions(); + _showAllExistingBubbles(); - // הצגת alternatives מה-SearchQuery - for (int i = 0; i < _searchQuery.terms.length; i++) { - for (int j = 0; j < _searchQuery.terms[i].alternatives.length; j++) { - _showAlternativeOverlay(i, j); - } + if (drawerWasOpen) { + _updateSearchOptionsOverlay(); } + }); + } - // הצגת alternatives קיימים שנשמרו - for (final entry in _alternativeControllers.entries) { - final termIndex = entry.key; - final controllers = entry.value; - for (int j = 0; j < controllers.length; j++) { - if (controllers[j].text.trim().isNotEmpty) { - // בדיקה שהבועה לא מוצגת כבר - final existingOverlays = _alternativeOverlays[termIndex] ?? []; - if (j >= existingOverlays.length) { - _showAlternativeOverlay(termIndex, j); - } + // עדכון אפשרויות החיפוש בשינוי קטן - שמירה על אפשרויות קיימות + void _updateSearchOptionsForMinorChange(Map oldSearchOptions, List oldWords, String newText) { + final newWords = newText.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList(); + + debugPrint('🔧 Updating search options for minor change:'); + debugPrint(' Old search options: ${oldSearchOptions.keys.toList()}'); + debugPrint(' Old words: $oldWords'); + debugPrint(' New words: $newWords'); + + // אם מספר המילים זהה, פשוט נעדכן את המפתחות לפי המילים החדשות + if (newWords.length == oldWords.length) { + debugPrint(' Same number of words - updating keys'); + widget.widget.tab.searchOptions.clear(); + + for (final entry in oldSearchOptions.entries) { + final key = entry.key; + final value = entry.value; + final parts = key.split('_'); + + if (parts.length >= 2) { + final oldWord = parts[0]; + final option = parts.sublist(1).join('_'); + + // מציאת האינדקס של המילה הישנה + final oldWordIndex = oldWords.indexOf(oldWord); + if (oldWordIndex != -1 && oldWordIndex < newWords.length) { + // עדכון המפתח עם המילה החדשה + final newWord = newWords[oldWordIndex]; + final newKey = '${newWord}_$option'; + widget.widget.tab.searchOptions[newKey] = value; + debugPrint('🔄 Updated search option: $key -> $newKey'); } } } - - // הצגת spacing overlays קיימים - for (final entry in _spacingControllers.entries) { + } else { + // אם מספר המילים השתנה, נשמור רק אפשרויות של מילים שעדיין קיימות + debugPrint(' Different number of words - preserving existing words only'); + widget.widget.tab.searchOptions.clear(); + + for (final entry in oldSearchOptions.entries) { final key = entry.key; - final controller = entry.value; - if (controller.text.trim().isNotEmpty && - !_spacingOverlays.containsKey(key)) { - // פירוק המפתח לאינדקסים - final parts = key.split('-'); - if (parts.length == 2) { - final leftIndex = int.tryParse(parts[0]); - final rightIndex = int.tryParse(parts[1]); - if (leftIndex != null && - rightIndex != null && - leftIndex < _wordPositions.length && - rightIndex < _wordPositions.length) { - _showSpacingOverlay(leftIndex, rightIndex); - } + final value = entry.value; + final parts = key.split('_'); + + if (parts.length >= 2) { + final word = parts[0]; + + // אם המילה עדיין קיימת ברשימה החדשה, נשמור את האפשרות + if (newWords.contains(word)) { + widget.widget.tab.searchOptions[key] = value; + debugPrint('🔄 Preserved search option: $key'); + } else { + debugPrint('❌ Removed search option for deleted word: $key'); } } } + } + } + + // טיפול בשינוי גדול - ניקוי סימונים לא רלוונטיים + void _handleMajorTextChange( + String text, List newWords, bool drawerWasOpen) { + // מיפוי מילים ישנות למילים חדשות לפי דמיון + final wordMapping = _mapOldWordsToNew(newWords); + debugPrint('🗺️ Word mapping: $wordMapping'); + + // עדכון controllers ו-overlays לפי המיפוי החדש + _remapControllersAndOverlays(wordMapping); + + // עדכון אפשרויות החיפוש לפי המיפוי החדש + _remapSearchOptions(wordMapping, newWords); + + // ניקוי נתונים לא רלוונטיים + _cleanupIrrelevantData(newWords.toSet()); + + // לא צריך לקרוא ל-_clearAllOverlays כי כבר ניקינו הכל ב-_remapControllersAndOverlays + + setState(() { + _searchQuery = SearchQuery.fromString(text); + }); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _calculateWordPositions(); + debugPrint('🎈 Showing remapped bubbles after major change'); + _showAllExistingBubbles(); - // אם המגירה הייתה פתוחה, מרעננים את התוכן שלה if (drawerWasOpen) { _updateSearchOptionsOverlay(); } }); } + // מיפוי מילים ישנות למילים חדשות + Map _mapOldWordsToNew(List newWords) { + final oldWords = _searchQuery.terms.map((t) => t.word).toList(); + final Map mapping = {}; + + // שלב 1: מיפוי מילים זהות לחלוטין + for (int oldIndex = 0; oldIndex < oldWords.length; oldIndex++) { + for (int newIndex = 0; newIndex < newWords.length; newIndex++) { + if (mapping.containsValue(newIndex)) continue; + + if (oldWords[oldIndex] == newWords[newIndex]) { + mapping[oldIndex] = newIndex; + break; + } + } + } + + // שלב 2: מיפוי מילים דומות (לטיפול במחיקת/הוספת אותיות) + for (int oldIndex = 0; oldIndex < oldWords.length; oldIndex++) { + if (mapping.containsKey(oldIndex)) continue; // כבר נמפה + + final oldWord = oldWords[oldIndex]; + double bestSimilarity = 0.0; + int bestNewIndex = -1; + + for (int newIndex = 0; newIndex < newWords.length; newIndex++) { + if (mapping.containsValue(newIndex)) continue; + + final newWord = newWords[newIndex]; + final similarity = _calculateWordSimilarity(oldWord, newWord); + + // סף נמוך יותר לדמיון כדי לתפוס גם שינויים קטנים + if (similarity > bestSimilarity && similarity > 0.3) { + bestSimilarity = similarity; + bestNewIndex = newIndex; + } + } + + if (bestNewIndex != -1) { + mapping[oldIndex] = bestNewIndex; + } + } + + return mapping; + } + + // עדכון controllers ו-overlays לפי המיפוי החדש + void _remapControllersAndOverlays(Map wordMapping) { + // שמירת controllers ישנים + final oldAlternativeControllers = + Map>.from(_alternativeControllers); + final oldSpacingControllers = + Map.from(_spacingControllers); + + // ניקוי כל ה-overlays הישנים לפני המיפוי + debugPrint('🧹 Clearing all old overlays before remapping'); + for (final entries in _alternativeOverlays.values) { + for (final entry in entries) { + entry.remove(); + } + } + _alternativeOverlays.clear(); + + for (final entry in _spacingOverlays.values) { + entry.remove(); + } + _spacingOverlays.clear(); + + // ניקוי המפות הנוכחיות + _alternativeControllers.clear(); + _spacingControllers.clear(); + + // מיפוי controllers של מילים חלופיות + for (final entry in oldAlternativeControllers.entries) { + final oldIndex = entry.key; + final controllers = entry.value; + + if (wordMapping.containsKey(oldIndex)) { + final newIndex = wordMapping[oldIndex]!; + _alternativeControllers[newIndex] = controllers; + } else { + // אם המילה לא נמפתה, נמחק את ה-controllers + for (final controller in controllers) { + controller.dispose(); + } + } + } + + // מיפוי controllers של מרווחים + for (final entry in oldSpacingControllers.entries) { + final oldKey = entry.key; + final controller = entry.value; + final parts = oldKey.split('-'); + + if (parts.length == 2) { + final oldLeft = int.tryParse(parts[0]); + final oldRight = int.tryParse(parts[1]); + + if (oldLeft != null && + oldRight != null && + wordMapping.containsKey(oldLeft) && + wordMapping.containsKey(oldRight)) { + final newLeft = wordMapping[oldLeft]!; + final newRight = wordMapping[oldRight]!; + final newKey = _spaceKey(newLeft, newRight); + _spacingControllers[newKey] = controller; + } else { + // אם המרווח לא רלוונטי יותר, נמחק את ה-controller + controller.dispose(); + } + } + } + + // עדכון המילים החלופיות ב-tab + _updateAlternativeWordsInTab(); + // עדכון המרווחים ב-tab + _updateSpacingInTab(); + } + + // הצגת כל הבועות הקיימות + void _showAllExistingBubbles() { + debugPrint('🎈 Showing existing bubbles - word positions: ${_wordPositions.length}'); + + // הצגת alternatives מה-SearchQuery + for (int i = 0; i < _searchQuery.terms.length; i++) { + for (int j = 0; j < _searchQuery.terms[i].alternatives.length; j++) { + if (i < _wordPositions.length) { + _showAlternativeOverlay(i, j); + } else { + debugPrint('⚠️ Skipping SearchQuery alternative at invalid position: $i'); + } + } + } + + // הצגת alternatives קיימים שנשמרו + final invalidControllerKeys = []; + for (final entry in _alternativeControllers.entries) { + final termIndex = entry.key; + final controllers = entry.value; + + // בדיקה שהאינדקס תקין + if (termIndex >= _wordPositions.length) { + debugPrint('⚠️ Marking invalid alternative controllers for removal: $termIndex'); + invalidControllerKeys.add(termIndex); + // מחיקת controllers לא תקינים + for (final controller in controllers) { + controller.dispose(); + } + continue; + } + + for (int j = 0; j < controllers.length; j++) { + if (controllers[j].text.trim().isNotEmpty) { + debugPrint('🎈 Showing alternative bubble at position $termIndex, alt $j'); + _showAlternativeOverlay(termIndex, j); + } + } + } + + // הסרת controllers לא תקינים + for (final key in invalidControllerKeys) { + _alternativeControllers.remove(key); + } + + // הצגת spacing overlays קיימים + final invalidSpacingKeys = []; + for (final entry in _spacingControllers.entries) { + final key = entry.key; + final controller = entry.value; + if (controller.text.trim().isNotEmpty) { + final parts = key.split('-'); + if (parts.length == 2) { + final leftIndex = int.tryParse(parts[0]); + final rightIndex = int.tryParse(parts[1]); + if (leftIndex != null && + rightIndex != null && + leftIndex < _wordPositions.length && + rightIndex < _wordPositions.length) { + debugPrint('🎈 Showing spacing bubble between $leftIndex and $rightIndex'); + _showSpacingOverlay(leftIndex, rightIndex); + } else { + debugPrint('⚠️ Marking invalid spacing controller for removal: $key'); + invalidSpacingKeys.add(key); + controller.dispose(); + } + } + } + } + + // הסרת spacing controllers לא תקינים + for (final key in invalidSpacingKeys) { + _spacingControllers.remove(key); + } + } + void _onCursorPositionChanged() { // עדכון המגירה כשהסמן זז (אם היא פתוחה) if (_searchOptionsOverlay != null) { @@ -888,7 +1255,7 @@ class _EnhancedSearchFieldState extends State { // החזרת מיקום הסמן אחרי העדכון WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { - print( + debugPrint( 'DEBUG: Restoring cursor position in update: ${currentSelection.baseOffset}'); widget.widget.tab.queryController.selection = currentSelection; } @@ -896,53 +1263,6 @@ class _EnhancedSearchFieldState extends State { } } - void _updateAlternativeControllers() { - // שמירה על controllers קיימים שיש בהם טקסט - final Map> existingControllers = {}; - for (final entry in _alternativeControllers.entries) { - final termIndex = entry.key; - final controllers = entry.value; - final controllersWithText = - controllers.where((c) => c.text.trim().isNotEmpty).toList(); - if (controllersWithText.isNotEmpty) { - existingControllers[termIndex] = controllersWithText; - } - } - - // מחיקת controllers ריקים בלבד - for (final entry in _alternativeControllers.entries) { - final controllers = entry.value; - for (final controller in controllers) { - if (controller.text.trim().isEmpty) { - controller.dispose(); - } - } - } - - // איפוס המפה - _alternativeControllers.clear(); - - // החזרת controllers עם טקסט - _alternativeControllers.addAll(existingControllers); - - // הוספת controllers חדשים מה-SearchQuery - for (int i = 0; i < _searchQuery.terms.length; i++) { - final term = _searchQuery.terms[i]; - _alternativeControllers.putIfAbsent(i, () => []); - - // הוספת alternatives מה-SearchQuery שלא קיימים כבר - for (final alt in term.alternatives) { - final existingTexts = - _alternativeControllers[i]!.map((c) => c.text).toList(); - if (!existingTexts.contains(alt)) { - final controller = TextEditingController(text: alt); - controller.addListener(() => _updateAlternativeWordsInTab()); - _alternativeControllers[i]!.add(controller); - } - } - } - } - void _calculateWordPositions() { if (_textFieldKey.currentContext == null) return; diff --git a/test_search_logic.dart b/test_search_logic.dart new file mode 100644 index 000000000..f895bebb3 --- /dev/null +++ b/test_search_logic.dart @@ -0,0 +1,97 @@ +// בדיקה מהירה של הלוגיקה החדשה + +void main() { + // סימולציה של הבדיקה + print('Testing search logic...'); + + // בדיקה 1: מחיקת אות אחת + List oldWords1 = ['בראשית', 'ברא']; + List newWords1 = ['בראשי', 'ברא']; // מחקנו ת' מהמילה הראשונה + bool isMinor1 = isMinorTextChange(oldWords1, newWords1); + print('Test 1 - Delete one letter: $isMinor1 (should be true)'); + + // בדיקה 2: מחיקת מילה שלמה + List oldWords2 = ['בראשית', 'ברא']; + List newWords2 = ['ברא']; // מחקנו את המילה הראשונה + bool isMinor2 = isMinorTextChange(oldWords2, newWords2); + print('Test 2 - Delete whole word: $isMinor2 (should be false)'); + + // בדיקה 3: הוספת אות אחת + List oldWords3 = ['בראשי', 'ברא']; + List newWords3 = ['בראשית', 'ברא']; // הוספנו ת' למילה הראשונה + bool isMinor3 = isMinorTextChange(oldWords3, newWords3); + print('Test 3 - Add one letter: $isMinor3 (should be true)'); + + // בדיקה 4: שינוי מילה לחלוטין + List oldWords4 = ['בראשית', 'ברא']; + List newWords4 = ['שלום', 'ברא']; // שינינו את המילה הראשונה לחלוטין + bool isMinor4 = isMinorTextChange(oldWords4, newWords4); + print('Test 4 - Complete word change: $isMinor4 (should be false)'); +} + +bool isMinorTextChange(List oldWords, List newWords) { + // אם מספר המילים השתנה, זה תמיד שינוי גדול + // (מחיקת או הוספת מילה שלמה) + if (oldWords.length != newWords.length) { + return false; + } + + // אם מספר המילים זהה, בדוק שינויים בתוך המילים + for (int i = 0; i < oldWords.length && i < newWords.length; i++) { + final oldWord = oldWords[i]; + final newWord = newWords[i]; + + // אם המילים זהות, זה בסדר + if (oldWord == newWord) continue; + + // בדיקה אם זה שינוי קטן (הוספה/הסרה של אות אחת או שתיים) + final lengthDiff = (oldWord.length - newWord.length).abs(); + if (lengthDiff > 2) { + return false; // שינוי גדול מדי + } + + // בדיקה אם המילה החדשה מכילה את רוב האותיות של המילה הישנה + final similarity = calculateWordSimilarity(oldWord, newWord); + if (similarity < 0.7) { + return false; // המילים שונות מדי + } + } + + return true; +} + +bool areWordsSubset(List smaller, List larger) { + if (smaller.length > larger.length) return false; + + int smallerIndex = 0; + for (int largerIndex = 0; + largerIndex < larger.length && smallerIndex < smaller.length; + largerIndex++) { + if (smaller[smallerIndex] == larger[largerIndex]) { + smallerIndex++; + } + } + + return smallerIndex == smaller.length; +} + +double calculateWordSimilarity(String word1, String word2) { + if (word1.isEmpty && word2.isEmpty) return 1.0; + if (word1.isEmpty || word2.isEmpty) return 0.0; + if (word1 == word2) return 1.0; + + // חישוב מרחק עריכה פשוט + final maxLength = word1.length > word2.length ? word1.length : word2.length; + int distance = (word1.length - word2.length).abs(); + + // ספירת תווים שונים באותו מיקום + final minLength = word1.length < word2.length ? word1.length : word2.length; + for (int i = 0; i < minLength; i++) { + if (word1[i] != word2[i]) { + distance++; + } + } + + // החזרת ציון דמיון (1.0 = זהות מלאה, 0.0 = שונות מלאה) + return 1.0 - (distance / maxLength); +} From 3b93fc4a716b9af5457a3b27347fc65b3d8a3f28 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 6 Aug 2025 14:33:25 +0300 Subject: [PATCH 088/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=94?= =?UTF-8?q?=D7=91=D7=A0=D7=99=D7=99=D7=94=20=D7=9C=D7=90=D7=A0=D7=93=D7=A8?= =?UTF-8?q?=D7=95=D7=90=D7=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle | 4 ++-- android/build.gradle | 6 +++--- android/settings.gradle | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 2ba2c3d68..d7c7c85a1 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -25,7 +25,7 @@ if (flutterVersionName == null) { android { namespace "com.example.otzaria" - compileSdkVersion 34 + compileSdkVersion 35 ndkVersion flutter.ndkVersion compileOptions { @@ -47,7 +47,7 @@ android { // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. minSdkVersion 23 - targetSdkVersion flutter.targetSdkVersion + targetSdkVersion 35 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } diff --git a/android/build.gradle b/android/build.gradle index a2c29ca41..1246f0c52 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.9.0' + ext.kotlin_version = '1.9.25' repositories { google() mavenCentral() @@ -26,8 +26,8 @@ subprojects { if (project.plugins.hasPlugin("com.android.application") || project.plugins.hasPlugin("com.android.library")) { project.android { - compileSdkVersion 34 - buildToolsVersion "34.0.0" + compileSdkVersion 35 + buildToolsVersion "35.0.0" } } } diff --git a/android/settings.gradle b/android/settings.gradle index c7577d4ff..537297f96 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -23,8 +23,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id("com.android.application") version "8.3.0" apply false - id("org.jetbrains.kotlin.android") version "1.9.0" apply false + id("com.android.application") version "8.7.0" apply false + id("org.jetbrains.kotlin.android") version "1.9.25" apply false } include ":app" From 9e9e28c8ceb308a9d619b909b58fe1545a565c5e Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 7 Aug 2025 14:42:57 +0300 Subject: [PATCH 089/197] =?UTF-8?q?=D7=A9=D7=99=D7=A4=D7=95=D7=A8=D7=99?= =?UTF-8?q?=D7=9D=20=D7=91=D7=A9=D7=95=D7=9C=D7=97=D7=A0=D7=95=D7=AA=20?= =?UTF-8?q?=D7=94=D7=A2=D7=91=D7=95=D7=93=D7=94,=20=D7=9B=D7=95=D7=9C?= =?UTF-8?q?=D7=9C=20=D7=9E=D7=A1=D7=A4=D7=A8=20=D7=A2=D7=95=D7=A7=D7=91=20?= =?UTF-8?q?=D7=95=D7=A4=D7=95=D7=A7=D7=95=D7=A1=20=D7=90=D7=95=D7=98=D7=95?= =?UTF-8?q?=D7=9E=D7=98=D7=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../view/workspace_switcher_dialog.dart | 42 ++++++++++++++++--- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/lib/workspaces/view/workspace_switcher_dialog.dart b/lib/workspaces/view/workspace_switcher_dialog.dart index 65fba4b9d..5af31b3ad 100644 --- a/lib/workspaces/view/workspace_switcher_dialog.dart +++ b/lib/workspaces/view/workspace_switcher_dialog.dart @@ -32,6 +32,19 @@ class _WorkspaceSwitcherDialogState extends State { super.dispose(); } + String _generateUniqueWorkspaceName(List existingWorkspaces) { + final existingNames = existingWorkspaces.map((w) => w.name).toSet(); + int counter = existingWorkspaces.length + 1; + + while (true) { + final candidateName = "שולחן עבודה $counter"; + if (!existingNames.contains(candidateName)) { + return candidateName; + } + counter++; + } + } + @override Widget build(BuildContext context) { return Dialog( @@ -109,9 +122,9 @@ class _WorkspaceSwitcherDialogState extends State { child: InkWell( onTap: () { final workspaceBloc = context.read(); + final newWorkspaceName = _generateUniqueWorkspaceName(workspaceBloc.state.workspaces); workspaceBloc.add(AddWorkspace( - name: - "שולחן עבודה חדש ${workspaceBloc.state.workspaces.length + 1}", + name: newWorkspaceName, tabs: const [], currentTabIndex: 0)); }, @@ -184,19 +197,31 @@ class _WorkspaceSwitcherDialogState extends State { padding: const EdgeInsets.all(8.0), child: Builder(builder: (context) { bool isEditing = false; // Flag to track editing + late TextEditingController editController; return StatefulBuilder(builder: (context, setState) { return isEditing ? TextField( - controller: - TextEditingController(text: workspace.name), + controller: editController, + autofocus: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + isDense: true, + ), onSubmitted: (newName) { - setState(() { + if (newName.trim().isNotEmpty) { context.read().add( RenameWorkspace( workspace, - newName, + newName.trim(), ), ); + } + setState(() { + isEditing = false; + }); + }, + onTapOutside: (event) { + setState(() { isEditing = false; }); }, @@ -217,6 +242,11 @@ class _WorkspaceSwitcherDialogState extends State { icon: const Icon(Icons.edit), onPressed: () { setState(() { + editController = TextEditingController(text: workspace.name); + // Set cursor position to end of text + editController.selection = TextSelection.fromPosition( + TextPosition(offset: workspace.name.length), + ); isEditing = true; }); }) From fe3c0e2390ae74993b31db5b5e96708f8804ff39 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 7 Aug 2025 15:16:50 +0300 Subject: [PATCH 090/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=91?= =?UTF-8?q?=D7=90=D7=92=20=D7=A0=D7=95=D7=A1=D7=A3=20=D7=91=D7=9E=D7=92?= =?UTF-8?q?=D7=99=D7=A8=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SEARCH_FIELD_IMPROVEMENTS.md | 10 +- lib/search/view/enhanced_search_field.dart | 184 ++++++++++++--------- 2 files changed, 119 insertions(+), 75 deletions(-) diff --git a/SEARCH_FIELD_IMPROVEMENTS.md b/SEARCH_FIELD_IMPROVEMENTS.md index b4904daec..cb439f4ce 100644 --- a/SEARCH_FIELD_IMPROVEMENTS.md +++ b/SEARCH_FIELD_IMPROVEMENTS.md @@ -69,4 +69,12 @@ 2. בדיקות תקינות במיקומי הבועות 3. הסרה אוטומטית של controllers במיקומים לא תקינים -עכשיו המערכת עובדת בדיוק כמו שביקשת ללא בועות שארית! \ No newline at end of file +## תיקון נוסף - בעיית הפוקוס: +הבעיה: אחרי שינוי טקסט בשדה החיפוש, הפוקוס עבר למילה החלופית וגרם למחיקה לא רצויה. + +הפתרון: +1. הוספת פרמטר `requestFocus` ל-`_AlternativeField` ו-`_SpacingField` +2. בועות חדשות (כשלוחצים על כפתור +) - מבקשות פוקוס +3. בועות קיימות (כשמציגים מחדש אחרי שינוי טקסט) - לא מבקשות פוקוס + +עכשיו המערכת עובדת מושלם ללא בעיות פוקוס! \ No newline at end of file diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 5aacd1232..30c8625ce 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -133,11 +133,13 @@ class _SpacingField extends StatefulWidget { final TextEditingController controller; final VoidCallback onRemove; final VoidCallback? onFocusLost; + final bool requestFocus; // פרמטר חדש לקביעה אם לבקש פוקוס const _SpacingField({ required this.controller, required this.onRemove, this.onFocusLost, + this.requestFocus = true, // ברירת מחדל - כן לבקש פוקוס }); @override @@ -150,11 +152,14 @@ class _SpacingFieldState extends State<_SpacingField> { @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _focus.requestFocus(); - } - }); + // רק אם נדרש לבקש פוקוס (בועות חדשות) + if (widget.requestFocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _focus.requestFocus(); + } + }); + } _focus.addListener(_onFocusChanged); widget.controller.addListener(_onTextChanged); } @@ -300,11 +305,13 @@ class _AlternativeField extends StatefulWidget { final TextEditingController controller; final VoidCallback onRemove; final VoidCallback? onFocusLost; + final bool requestFocus; // פרמטר חדש לקביעה אם לבקש פוקוס const _AlternativeField({ required this.controller, required this.onRemove, this.onFocusLost, + this.requestFocus = true, // ברירת מחדל - כן לבקש פוקוס }); @override @@ -317,11 +324,17 @@ class _AlternativeFieldState extends State<_AlternativeField> { @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _focus.requestFocus(); - } - }); + // רק אם נדרש לבקש פוקוס (בועות חדשות) + if (widget.requestFocus) { + debugPrint('🎯 Requesting focus for new alternative field'); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _focus.requestFocus(); + } + }); + } else { + debugPrint('🚫 NOT requesting focus for existing alternative field'); + } _focus.addListener(_onFocusChanged); widget.controller.addListener(_onTextChanged); } @@ -597,7 +610,7 @@ class _EnhancedSearchFieldState extends State { final controllers = entry.value; for (int j = 0; j < controllers.length; j++) { if (termIndex < _wordPositions.length) { - _showAlternativeOverlay(termIndex, j); + _showAlternativeOverlay(termIndex, j, requestFocus: false); } } } @@ -612,7 +625,7 @@ class _EnhancedSearchFieldState extends State { rightIndex != null && leftIndex < _wordPositions.length && rightIndex < _wordPositions.length) { - _showSpacingOverlay(leftIndex, rightIndex); + _showSpacingOverlay(leftIndex, rightIndex, requestFocus: false); } } } @@ -639,38 +652,44 @@ class _EnhancedSearchFieldState extends State { // עדכון אפשרויות החיפוש לפי המיפוי החדש void _remapSearchOptions(Map wordMapping, List newWords) { - final oldWords = _searchQuery.terms.map((t) => t.word).toList(); - final oldSearchOptions = - Map.from(widget.widget.tab.searchOptions); - widget.widget.tab.searchOptions.clear(); + // ניצור עותק של האפשרויות הישנות ונתייחס אליו כאל Map מהסוג הנכון + final oldSearchOptions = Map>.from(widget.widget.tab.searchOptions); + final newSearchOptions = >{}; + // נעבור על כל האפשרויות הישנות for (final entry in oldSearchOptions.entries) { - final key = entry.key; - final value = entry.value; - final parts = key.split('_'); + final oldKey = entry.key; // לדוגמה: "מילה_1" + final optionsMap = entry.value; // מפת האפשרויות של המילה + final parts = oldKey.split('_'); + // נוודא שהמפתח תקין (מכיל מילה ואינדקס) if (parts.length >= 2) { - final word = parts[0]; - final option = parts.sublist(1).join('_'); - - // מציאת האינדקס הישן של המילה - final oldWordIndex = oldWords.indexOf(word); - - if (oldWordIndex != -1 && wordMapping.containsKey(oldWordIndex)) { - // המילה נמפתה למילה חדשה - final newWordIndex = wordMapping[oldWordIndex]!; - if (newWordIndex < newWords.length) { - final newWord = newWords[newWordIndex]; - final newKey = '${newWord}_$option'; - widget.widget.tab.searchOptions[newKey] = value; + // נחלץ את האינדקס הישן מהמפתח + final oldIndex = int.tryParse(parts.last); + + // אם הצלחנו לקרוא את האינדקס הישן, והוא קיים במפת המיפוי שלנו + if (oldIndex != null && wordMapping.containsKey(oldIndex)) { + // נמצא את האינדקס החדש של המילה + final newIndex = wordMapping[oldIndex]!; + + // נוודא שהאינדקס החדש תקין ביחס לרשימת המילים החדשה + if (newIndex < newWords.length) { + final newWord = newWords[newIndex]; + + // ✅ כאן התיקון המרכזי: נייצר מפתח חדש עם המילה החדשה והאינדקס החדש + final newKey = '${newWord}_$newIndex'; + + // נוסיף את האפשרויות למפה החדשה שיצרנו + newSearchOptions[newKey] = optionsMap; } - } else if (newWords.contains(word)) { - // המילה עדיין קיימת בדיוק כמו שהיא - final newKey = '${word}_$option'; - widget.widget.tab.searchOptions[newKey] = value; } + // אם המילה נמחקה (ולא נמצאת ב-wordMapping), אנחנו פשוט מתעלמים מהאפשרויות שלה, וזה תקין. } } + + // לבסוף, נחליף את מפת האפשרויות הישנה במפה החדשה והמעודכנת שבנינו + widget.widget.tab.searchOptions.clear(); + widget.widget.tab.searchOptions.addAll(newSearchOptions); } void _clearAllOverlays( @@ -849,7 +868,8 @@ class _EnhancedSearchFieldState extends State { debugPrint('🔄 Text change detected: ${isMinorChange ? "MINOR" : "MAJOR"}'); debugPrint(' Old words: $oldWords'); debugPrint(' New words: $newWords'); - debugPrint(' Current search options: ${widget.widget.tab.searchOptions.keys.toList()}'); + debugPrint( + ' Current search options: ${widget.widget.tab.searchOptions.keys.toList()}'); if (isMinorChange) { // שינוי קטן - שמור על כל הסימונים והבועות @@ -894,8 +914,6 @@ class _EnhancedSearchFieldState extends State { return true; } - - // חישוב דמיון בין שתי מילים (אלגוריתם Levenshtein distance מפושט) double _calculateWordSimilarity(String word1, String word2) { if (word1.isEmpty && word2.isEmpty) return 1.0; @@ -924,9 +942,10 @@ class _EnhancedSearchFieldState extends State { _clearAllOverlays(keepSearchDrawer: drawerWasOpen, keepFilledBubbles: true); // שמירת אפשרויות החיפוש הקיימות ומילים ישנות לפני יצירת SearchQuery חדש - final oldSearchOptions = Map.from(widget.widget.tab.searchOptions); + final oldSearchOptions = + Map.from(widget.widget.tab.searchOptions); final oldWords = _searchQuery.terms.map((t) => t.word).toList(); - + setState(() { _searchQuery = SearchQuery.fromString(text); // לא קוראים ל-_updateAlternativeControllers כדי לא לפגוע במיפוי הקיים @@ -934,8 +953,9 @@ class _EnhancedSearchFieldState extends State { // עדכון אפשרויות החיפוש לפי המילים החדשות (שמירה על אפשרויות קיימות) _updateSearchOptionsForMinorChange(oldSearchOptions, oldWords, text); - - debugPrint('✅ After minor change - search options: ${widget.widget.tab.searchOptions.keys.toList()}'); + + debugPrint( + '✅ After minor change - search options: ${widget.widget.tab.searchOptions.keys.toList()}'); WidgetsBinding.instance.addPostFrameCallback((_) { _calculateWordPositions(); @@ -948,28 +968,33 @@ class _EnhancedSearchFieldState extends State { } // עדכון אפשרויות החיפוש בשינוי קטן - שמירה על אפשרויות קיימות - void _updateSearchOptionsForMinorChange(Map oldSearchOptions, List oldWords, String newText) { - final newWords = newText.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList(); - + void _updateSearchOptionsForMinorChange(Map oldSearchOptions, + List oldWords, String newText) { + final newWords = newText + .trim() + .split(RegExp(r'\s+')) + .where((w) => w.isNotEmpty) + .toList(); + debugPrint('🔧 Updating search options for minor change:'); debugPrint(' Old search options: ${oldSearchOptions.keys.toList()}'); debugPrint(' Old words: $oldWords'); debugPrint(' New words: $newWords'); - + // אם מספר המילים זהה, פשוט נעדכן את המפתחות לפי המילים החדשות if (newWords.length == oldWords.length) { debugPrint(' Same number of words - updating keys'); widget.widget.tab.searchOptions.clear(); - + for (final entry in oldSearchOptions.entries) { final key = entry.key; final value = entry.value; final parts = key.split('_'); - + if (parts.length >= 2) { final oldWord = parts[0]; final option = parts.sublist(1).join('_'); - + // מציאת האינדקס של המילה הישנה final oldWordIndex = oldWords.indexOf(oldWord); if (oldWordIndex != -1 && oldWordIndex < newWords.length) { @@ -983,17 +1008,18 @@ class _EnhancedSearchFieldState extends State { } } else { // אם מספר המילים השתנה, נשמור רק אפשרויות של מילים שעדיין קיימות - debugPrint(' Different number of words - preserving existing words only'); + debugPrint( + ' Different number of words - preserving existing words only'); widget.widget.tab.searchOptions.clear(); - + for (final entry in oldSearchOptions.entries) { final key = entry.key; final value = entry.value; final parts = key.split('_'); - + if (parts.length >= 2) { final word = parts[0]; - + // אם המילה עדיין קיימת ברשימה החדשה, נשמור את האפשרות if (newWords.contains(word)) { widget.widget.tab.searchOptions[key] = value; @@ -1101,7 +1127,7 @@ class _EnhancedSearchFieldState extends State { } } _alternativeOverlays.clear(); - + for (final entry in _spacingOverlays.values) { entry.remove(); } @@ -1160,15 +1186,17 @@ class _EnhancedSearchFieldState extends State { // הצגת כל הבועות הקיימות void _showAllExistingBubbles() { - debugPrint('🎈 Showing existing bubbles - word positions: ${_wordPositions.length}'); - + debugPrint( + '🎈 Showing existing bubbles - word positions: ${_wordPositions.length}'); + // הצגת alternatives מה-SearchQuery for (int i = 0; i < _searchQuery.terms.length; i++) { for (int j = 0; j < _searchQuery.terms[i].alternatives.length; j++) { if (i < _wordPositions.length) { - _showAlternativeOverlay(i, j); + _showAlternativeOverlay(i, j, requestFocus: false); } else { - debugPrint('⚠️ Skipping SearchQuery alternative at invalid position: $i'); + debugPrint( + '⚠️ Skipping SearchQuery alternative at invalid position: $i'); } } } @@ -1178,10 +1206,11 @@ class _EnhancedSearchFieldState extends State { for (final entry in _alternativeControllers.entries) { final termIndex = entry.key; final controllers = entry.value; - + // בדיקה שהאינדקס תקין if (termIndex >= _wordPositions.length) { - debugPrint('⚠️ Marking invalid alternative controllers for removal: $termIndex'); + debugPrint( + '⚠️ Marking invalid alternative controllers for removal: $termIndex'); invalidControllerKeys.add(termIndex); // מחיקת controllers לא תקינים for (final controller in controllers) { @@ -1189,15 +1218,16 @@ class _EnhancedSearchFieldState extends State { } continue; } - + for (int j = 0; j < controllers.length; j++) { if (controllers[j].text.trim().isNotEmpty) { - debugPrint('🎈 Showing alternative bubble at position $termIndex, alt $j'); - _showAlternativeOverlay(termIndex, j); + debugPrint( + '🎈 Showing alternative bubble at position $termIndex, alt $j'); + _showAlternativeOverlay(termIndex, j, requestFocus: false); } } } - + // הסרת controllers לא תקינים for (final key in invalidControllerKeys) { _alternativeControllers.remove(key); @@ -1217,17 +1247,19 @@ class _EnhancedSearchFieldState extends State { rightIndex != null && leftIndex < _wordPositions.length && rightIndex < _wordPositions.length) { - debugPrint('🎈 Showing spacing bubble between $leftIndex and $rightIndex'); - _showSpacingOverlay(leftIndex, rightIndex); + debugPrint( + '🎈 Showing spacing bubble between $leftIndex and $rightIndex'); + _showSpacingOverlay(leftIndex, rightIndex, requestFocus: false); } else { - debugPrint('⚠️ Marking invalid spacing controller for removal: $key'); + debugPrint( + '⚠️ Marking invalid spacing controller for removal: $key'); invalidSpacingKeys.add(key); controller.dispose(); } } } } - + // הסרת spacing controllers לא תקינים for (final key in invalidSpacingKeys) { _spacingControllers.remove(key); @@ -1352,7 +1384,7 @@ class _EnhancedSearchFieldState extends State { // הוספת listener לעדכון המידע ב-tab כשהטקסט משתנה controller.addListener(() => _updateAlternativeWordsInTab()); _alternativeControllers[termIndex]!.add(controller); - _showAlternativeOverlay(termIndex, newIndex); + _showAlternativeOverlay(termIndex, newIndex, requestFocus: true); }); _updateAlternativeWordsInTab(); } @@ -1390,11 +1422,12 @@ class _EnhancedSearchFieldState extends State { } _alternativeOverlays[termIndex]!.clear(); for (int i = 0; i < _alternativeControllers[termIndex]!.length; i++) { - _showAlternativeOverlay(termIndex, i); + _showAlternativeOverlay(termIndex, i, requestFocus: false); } } - void _showAlternativeOverlay(int termIndex, int altIndex) { + void _showAlternativeOverlay(int termIndex, int altIndex, + {bool requestFocus = false}) { debugPrint( '🎈 Showing alternative overlay: term=$termIndex, alt=$altIndex'); @@ -1436,6 +1469,7 @@ class _EnhancedSearchFieldState extends State { controller: controller, onRemove: () => _removeAlternative(termIndex, altIndex), onFocusLost: () => _checkAndRemoveEmptyField(termIndex, altIndex), + requestFocus: requestFocus, // העברת הפרמטר ), ); }, @@ -1457,7 +1491,8 @@ class _EnhancedSearchFieldState extends State { ); } - void _showSpacingOverlay(int leftIndex, int rightIndex) { + void _showSpacingOverlay(int leftIndex, int rightIndex, + {bool requestFocus = false}) { final key = _spaceKey(leftIndex, rightIndex); debugPrint('🎈 Showing spacing overlay: $key'); if (_spacingOverlays.containsKey(key)) { @@ -1495,6 +1530,7 @@ class _EnhancedSearchFieldState extends State { controller: controller, onRemove: () => _removeSpacingOverlay(key), onFocusLost: () => _removeSpacingOverlayIfEmpty(key), + requestFocus: requestFocus, // העברת הפרמטר ), ), ); @@ -1535,7 +1571,7 @@ class _EnhancedSearchFieldState extends State { onEnter: (_) => setState(() => _hoveredWordIndex = i), onExit: (_) => setState(() => _hoveredWordIndex = null), child: _SpacingButton( - onTap: () => _showSpacingOverlay(i, i + 1), + onTap: () => _showSpacingOverlay(i, i + 1, requestFocus: true), ), ), ), From bcdddf9b1a59960614575c4bd842b8705da421e9 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 7 Aug 2025 19:04:33 +0300 Subject: [PATCH 091/197] Fix CMake --- windows/CMakeLists.txt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index bd64baf18..d8ce0a184 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -89,9 +89,13 @@ endif() # Copy the native assets provided by the build.dart from all packages. set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) + +if(EXISTS "${NATIVE_ASSETS_DIR}") + install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + # Fully re-copy the assets directory on each build to avoid having stale files # from a previous install. From 211360e2e561ed08e66cc6885f6b003644356377 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 7 Aug 2025 20:26:50 +0300 Subject: [PATCH 092/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=97?= =?UTF-8?q?=D7=99=D7=A4=D7=95=D7=A9=20=D7=A2=D7=9D=20=D7=A8=D7=95=D7=95?= =?UTF-8?q?=D7=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/search_repository.dart | 5 ++++- lib/search/view/enhanced_search_field.dart | 4 ++-- lib/tabs/models/searching_tab.dart | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/search/search_repository.dart b/lib/search/search_repository.dart index 636066913..5c9f18762 100644 --- a/lib/search/search_repository.dart +++ b/lib/search/search_repository.dart @@ -36,7 +36,10 @@ class SearchRepository { final hasSearchOptions = searchOptions != null && searchOptions.isNotEmpty; // המרת החיפוש לפורמט המנוע החדש - final words = query.trim().split(SearchRegexPatterns.wordSplitter); + // סינון מחרוזות ריקות שנוצרות כאשר יש רווחים בסוף השאילתה + final words = query.trim().split(SearchRegexPatterns.wordSplitter) + .where((word) => word.isNotEmpty) + .toList(); final List regexTerms; final int effectiveSlop; diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 30c8625ce..cd1fc7c66 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -1909,7 +1909,7 @@ class _EnhancedSearchFieldState extends State { context .read() .add(AddHistory(widget.widget.tab)); - context.read().add(UpdateSearchQuery(e, + context.read().add(UpdateSearchQuery(e.trim(), customSpacing: widget.widget.tab.spacingValues, alternativeWords: widget.widget.tab.alternativeWords, searchOptions: widget.widget.tab.searchOptions)); @@ -1925,7 +1925,7 @@ class _EnhancedSearchFieldState extends State { .read() .add(AddHistory(widget.widget.tab)); context.read().add(UpdateSearchQuery( - widget.widget.tab.queryController.text, + widget.widget.tab.queryController.text.trim(), customSpacing: widget.widget.tab.spacingValues, alternativeWords: widget.widget.tab.alternativeWords, diff --git a/lib/tabs/models/searching_tab.dart b/lib/tabs/models/searching_tab.dart index 2aa90c622..9a0416e4a 100644 --- a/lib/tabs/models/searching_tab.dart +++ b/lib/tabs/models/searching_tab.dart @@ -37,7 +37,7 @@ class SearchingTab extends OpenedTab { ) { if (searchText != null) { queryController.text = searchText; - searchBloc.add(UpdateSearchQuery(searchText)); + searchBloc.add(UpdateSearchQuery(searchText.trim())); } } From 803c093d3751b3dd25731b0c5e6f93bbc0e250a0 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 7 Aug 2025 22:58:02 +0300 Subject: [PATCH 093/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=9E?= =?UTF-8?q?=D7=A1=D7=A4=D7=A8=20=D7=91=D7=A1=D7=95=D7=A3=20=D7=9E=D7=99?= =?UTF-8?q?=D7=9C=D7=94,=20=D7=97=D7=96=D7=A8=D7=AA=D7=99=D7=95=D7=AA=20?= =?UTF-8?q?=D7=A1=D7=9E=D7=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/enhanced_search_field.dart | 177 +++++++++++------- .../view/full_text_settings_widgets.dart | 1 + 2 files changed, 113 insertions(+), 65 deletions(-) diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index cd1fc7c66..9ae72eca2 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:otzaria/history/bloc/history_bloc.dart'; @@ -496,6 +498,8 @@ class _EnhancedSearchFieldState extends State { final Map> _alternativeOverlays = {}; OverlayEntry? _searchOptionsOverlay; int? _hoveredWordIndex; + bool _isUpdatingText = false; // דגל למניעת לולאות אינסופיות + String _lastProcessedText = ''; // מעקב אחר הטקסט האחרון שעובד final Map _spacingOverlays = {}; final Map _spacingControllers = {}; @@ -653,7 +657,8 @@ class _EnhancedSearchFieldState extends State { // עדכון אפשרויות החיפוש לפי המיפוי החדש void _remapSearchOptions(Map wordMapping, List newWords) { // ניצור עותק של האפשרויות הישנות ונתייחס אליו כאל Map מהסוג הנכון - final oldSearchOptions = Map>.from(widget.widget.tab.searchOptions); + final oldSearchOptions = + Map>.from(widget.widget.tab.searchOptions); final newSearchOptions = >{}; // נעבור על כל האפשרויות הישנות @@ -675,10 +680,10 @@ class _EnhancedSearchFieldState extends State { // נוודא שהאינדקס החדש תקין ביחס לרשימת המילים החדשה if (newIndex < newWords.length) { final newWord = newWords[newIndex]; - + // ✅ כאן התיקון המרכזי: נייצר מפתח חדש עם המילה החדשה והאינדקס החדש final newKey = '${newWord}_$newIndex'; - + // נוסיף את האפשרויות למפה החדשה שיצרנו newSearchOptions[newKey] = optionsMap; } @@ -836,11 +841,25 @@ class _EnhancedSearchFieldState extends State { } void _onTextChanged() { - // בודקים אם המגירה הייתה פתוחה לפני השינוי - final bool drawerWasOpen = _searchOptionsOverlay != null; + // מניעת לולאות אינסופיות + if (_isUpdatingText) { + debugPrint('🚫 Skipping text change - already updating'); + return; + } final text = widget.widget.tab.queryController.text; + // מניעת עיבוד מיותר אם הטקסט לא השתנה באמת + if (text == _lastProcessedText) { + debugPrint('🚫 Skipping text change - text unchanged: "$text"'); + return; + } + + _lastProcessedText = text; + + // בודקים אם המגירה הייתה פתוחה לפני השינוי + final bool drawerWasOpen = _searchOptionsOverlay != null; + // אם שדה החיפוש התרוקן, נקה הכל ונסגור את המגירה if (text.trim().isEmpty) { _clearAllOverlays(); @@ -938,33 +957,44 @@ class _EnhancedSearchFieldState extends State { // טיפול בשינוי קטן - שמירה על כל הסימונים void _handleMinorTextChange(String text, bool drawerWasOpen) { - // מנקים רק את הבועות הריקות, שומרים על הכל - _clearAllOverlays(keepSearchDrawer: drawerWasOpen, keepFilledBubbles: true); + _isUpdatingText = true; // הגדרת הדגל למניעת לולאות - // שמירת אפשרויות החיפוש הקיימות ומילים ישנות לפני יצירת SearchQuery חדש - final oldSearchOptions = - Map.from(widget.widget.tab.searchOptions); - final oldWords = _searchQuery.terms.map((t) => t.word).toList(); + try { + // מנקים רק את הבועות הריקות, שומרים על הכל + _clearAllOverlays( + keepSearchDrawer: drawerWasOpen, keepFilledBubbles: true); - setState(() { - _searchQuery = SearchQuery.fromString(text); - // לא קוראים ל-_updateAlternativeControllers כדי לא לפגוע במיפוי הקיים - }); + // שמירת אפשרויות החיפוש הקיימות ומילים ישנות לפני יצירת SearchQuery חדש + final oldSearchOptions = + Map.from(widget.widget.tab.searchOptions); + final oldWords = _searchQuery.terms.map((t) => t.word).toList(); - // עדכון אפשרויות החיפוש לפי המילים החדשות (שמירה על אפשרויות קיימות) - _updateSearchOptionsForMinorChange(oldSearchOptions, oldWords, text); + setState(() { + _searchQuery = SearchQuery.fromString(text); + // לא קוראים ל-_updateAlternativeControllers כדי לא לפגוע במיפוי הקיים + }); - debugPrint( - '✅ After minor change - search options: ${widget.widget.tab.searchOptions.keys.toList()}'); + // עדכון אפשרויות החיפוש לפי המילים החדשות (שמירה על אפשרויות קיימות) + _updateSearchOptionsForMinorChange(oldSearchOptions, oldWords, text); - WidgetsBinding.instance.addPostFrameCallback((_) { - _calculateWordPositions(); - _showAllExistingBubbles(); + debugPrint( + '✅ After minor change - search options: ${widget.widget.tab.searchOptions.keys.toList()}'); - if (drawerWasOpen) { - _updateSearchOptionsOverlay(); - } - }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _calculateWordPositions(); + _showAllExistingBubbles(); + + if (drawerWasOpen) { + _updateSearchOptionsOverlay(); + } + } + _isUpdatingText = false; // איפוס הדגל + }); + } catch (e) { + _isUpdatingText = false; // איפוס הדגל גם במקרה של שגיאה + rethrow; + } } // עדכון אפשרויות החיפוש בשינוי קטן - שמירה על אפשרויות קיימות @@ -1035,34 +1065,44 @@ class _EnhancedSearchFieldState extends State { // טיפול בשינוי גדול - ניקוי סימונים לא רלוונטיים void _handleMajorTextChange( String text, List newWords, bool drawerWasOpen) { - // מיפוי מילים ישנות למילים חדשות לפי דמיון - final wordMapping = _mapOldWordsToNew(newWords); - debugPrint('🗺️ Word mapping: $wordMapping'); + _isUpdatingText = true; // הגדרת הדגל למניעת לולאות - // עדכון controllers ו-overlays לפי המיפוי החדש - _remapControllersAndOverlays(wordMapping); + try { + // מיפוי מילים ישנות למילים חדשות לפי דמיון + final wordMapping = _mapOldWordsToNew(newWords); + debugPrint('🗺️ Word mapping: $wordMapping'); - // עדכון אפשרויות החיפוש לפי המיפוי החדש - _remapSearchOptions(wordMapping, newWords); + // עדכון controllers ו-overlays לפי המיפוי החדש + _remapControllersAndOverlays(wordMapping); - // ניקוי נתונים לא רלוונטיים - _cleanupIrrelevantData(newWords.toSet()); + // עדכון אפשרויות החיפוש לפי המיפוי החדש + _remapSearchOptions(wordMapping, newWords); - // לא צריך לקרוא ל-_clearAllOverlays כי כבר ניקינו הכל ב-_remapControllersAndOverlays + // ניקוי נתונים לא רלוונטיים + _cleanupIrrelevantData(newWords.toSet()); - setState(() { - _searchQuery = SearchQuery.fromString(text); - }); + // לא צריך לקרוא ל-_clearAllOverlays כי כבר ניקינו הכל ב-_remapControllersAndOverlays - WidgetsBinding.instance.addPostFrameCallback((_) { - _calculateWordPositions(); - debugPrint('🎈 Showing remapped bubbles after major change'); - _showAllExistingBubbles(); + setState(() { + _searchQuery = SearchQuery.fromString(text); + }); - if (drawerWasOpen) { - _updateSearchOptionsOverlay(); - } - }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _calculateWordPositions(); + debugPrint('🎈 Showing remapped bubbles after major change'); + _showAllExistingBubbles(); + + if (drawerWasOpen) { + _updateSearchOptionsOverlay(); + } + } + _isUpdatingText = false; // איפוס הדגל + }); + } catch (e) { + _isUpdatingText = false; // איפוס הדגל גם במקרה של שגיאה + rethrow; + } } // מיפוי מילים ישנות למילים חדשות @@ -1267,36 +1307,40 @@ class _EnhancedSearchFieldState extends State { } void _onCursorPositionChanged() { - // עדכון המגירה כשהסמן זז (אם היא פתוחה) - if (_searchOptionsOverlay != null) { + // עדכון המגירה כשהסמן זז (אם היא פתוחה) - רק אם לא באמצע עדכון טקסט + if (_searchOptionsOverlay != null && !_isUpdatingText) { WidgetsBinding.instance.addPostFrameCallback((_) { - _updateSearchOptionsOverlay(); + if (mounted && !_isUpdatingText) { + _updateSearchOptionsOverlay(); + } }); } } void _updateSearchOptionsOverlay() { // עדכון המגירה אם היא פתוחה - if (_searchOptionsOverlay != null) { + if (_searchOptionsOverlay != null && !_isUpdatingText) { // שמירת מיקום הסמן לפני העדכון final currentSelection = widget.widget.tab.queryController.selection; _hideSearchOptionsOverlay(); _showSearchOptionsOverlay(); - // החזרת מיקום הסמן אחרי העדכון + // החזרת מיקום הסמן אחרי העדכון - רק אם לא באמצע עדכון טקסט WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { + if (mounted && !_isUpdatingText) { debugPrint( 'DEBUG: Restoring cursor position in update: ${currentSelection.baseOffset}'); + _isUpdatingText = true; widget.widget.tab.queryController.selection = currentSelection; + _isUpdatingText = false; } }); } } void _calculateWordPositions() { - if (_textFieldKey.currentContext == null) return; + if (_textFieldKey.currentContext == null || _isUpdatingText) return; RenderEditable? editable; void findEditable(RenderObject child) { @@ -1322,7 +1366,9 @@ class _EnhancedSearchFieldState extends State { final text = widget.widget.tab.queryController.text; if (text.isEmpty) { - setState(() {}); + if (mounted && !_isUpdatingText) { + setState(() {}); + } return; } @@ -1334,13 +1380,13 @@ class _EnhancedSearchFieldState extends State { if (start == -1) continue; final end = start + w.length; - final pts = editable!.getEndpointsForSelection( + final boxes = editable!.getBoxesForSelection( TextSelection(baseOffset: start, extentOffset: end), ); - if (pts.isEmpty) continue; + if (boxes.isEmpty) continue; - final leftLocalX = pts.first.point.dx; - final rightLocalX = pts.last.point.dx; + final leftLocalX = boxes.map((b) => b.left).reduce(math.min); + final rightLocalX = boxes.map((b) => b.right).reduce(math.max); final leftGlobal = editable!.localToGlobal(Offset(leftLocalX, 0)); final rightGlobal = editable!.localToGlobal(Offset(rightLocalX, 0)); @@ -1359,18 +1405,19 @@ class _EnhancedSearchFieldState extends State { idx = end; } - if (text.isNotEmpty && _wordPositions.isEmpty) { -// החישוב נכשל למרות שיש טקסט. ננסה שוב ב-frame הבא. + if (text.isNotEmpty && _wordPositions.isEmpty && !_isUpdatingText) { + // החישוב נכשל למרות שיש טקסט. ננסה שוב ב-frame הבא. WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - // ודא שהווידג'ט עדיין קיים + if (mounted && !_isUpdatingText) { _calculateWordPositions(); } }); - return; // צא מהפונקציה כדי לא לקרוא ל-setState עם מידע שגוי + return; } - setState(() {}); + if (mounted && !_isUpdatingText) { + setState(() {}); + } } void _addAlternative(int termIndex) { diff --git a/lib/search/view/full_text_settings_widgets.dart b/lib/search/view/full_text_settings_widgets.dart index 8747f8f1b..458c22edc 100644 --- a/lib/search/view/full_text_settings_widgets.dart +++ b/lib/search/view/full_text_settings_widgets.dart @@ -530,6 +530,7 @@ class _SearchTermsDisplayState extends State { controller: _scrollController, thumbVisibility: true, trackVisibility: true, + thickness: 3.0, // עובי דק יותר לפס הגלילה child: SingleChildScrollView( controller: _scrollController, scrollDirection: Axis.horizontal, From 1abb1895758abe3110a786e09bae43eb236b0611 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 8 Aug 2025 00:53:42 +0300 Subject: [PATCH 094/197] =?UTF-8?q?=D7=91=D7=90=D7=92=20=D7=91=D7=A9=D7=97?= =?UTF-8?q?=D7=96=D7=95=D7=A8=20=D7=97=D7=99=D7=A4=D7=95=D7=A9=20=D7=9E?= =?UTF-8?q?=D7=94=D7=94=D7=99=D7=A1=D7=98=D7=95=D7=A8=D7=99=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/history/history_screen.dart | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/lib/history/history_screen.dart b/lib/history/history_screen.dart index 73ababd53..c0d369fbd 100644 --- a/lib/history/history_screen.dart +++ b/lib/history/history_screen.dart @@ -91,15 +91,9 @@ class HistoryView extends StatelessWidget { onTap: () { if (historyItem.isSearch) { final tabsBloc = context.read(); - SearchingTab searchTab; - try { - searchTab = tabsBloc.state.tabs - .firstWhere((tab) => tab is SearchingTab) - as SearchingTab; - } catch (e) { - searchTab = SearchingTab('חיפוש', null); - tabsBloc.add(AddTab(searchTab)); - } + // Always create a new search tab instead of reusing existing one + final searchTab = SearchingTab('חיפוש', null); + tabsBloc.add(AddTab(searchTab)); // Restore search query and options searchTab.queryController.text = historyItem.book.title; From 7b0c679e9b9506755d77c72b1076753018e4fe5f Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 8 Aug 2025 01:12:53 +0300 Subject: [PATCH 095/197] =?UTF-8?q?=D7=96=D7=9B=D7=99=D7=A8=D7=AA=20=D7=94?= =?UTF-8?q?=D7=92=D7=93=D7=A8=D7=95=D7=AA=20=D7=9E=D7=A9=D7=AA=D7=9E=D7=A9?= =?UTF-8?q?=20=D7=91=D7=9C=D7=95=D7=97=20=D7=94=D7=A9=D7=A0=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CALENDAR_SETTINGS_IMPLEMENTATION.md | 61 +++++++++++++++++++++++++++ lib/navigation/calendar_cubit.dart | 52 +++++++++++++++++++++-- lib/navigation/more_screen.dart | 5 ++- lib/search/utils/regex_patterns.dart | 3 +- lib/settings/settings_repository.dart | 20 +++++++++ 5 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 CALENDAR_SETTINGS_IMPLEMENTATION.md diff --git a/CALENDAR_SETTINGS_IMPLEMENTATION.md b/CALENDAR_SETTINGS_IMPLEMENTATION.md new file mode 100644 index 000000000..8f342d048 --- /dev/null +++ b/CALENDAR_SETTINGS_IMPLEMENTATION.md @@ -0,0 +1,61 @@ +# יישום זכירת הגדרות לוח השנה + +## מה שהוסף + +### 1. הגדרות חדשות ב-SettingsRepository +- נוסף מפתח חדש: `keyCalendarType = 'key-calendar-type'` +- נוסף מפתח חדש: `keySelectedCity = 'key-selected-city'` +- נוספה פונקציה `updateCalendarType(String value)` לשמירת סוג לוח השנה +- נוספה פונקציה `updateSelectedCity(String value)` לשמירת העיר הנבחרת +- נוספה טעינת ההגדרות ב-`loadSettings()` עם ברירות מחדל +- נוספה אתחול ההגדרות ב-`_writeDefaultsToStorage()` + +### 2. עדכון CalendarCubit +- נוסף constructor parameter עבור `SettingsRepository` +- נוספה פונקציה `_initializeCalendar()` שטוענת את ההגדרות השמורות +- עודכנה `changeCalendarType()` כדי לשמור את הבחירה +- עודכנה `changeCity()` כדי לשמור את העיר הנבחרת +- נוספו פונקציות עזר להמרה בין String ל-CalendarType + +### 3. עדכון MoreScreen +- נוסף import עבור `SettingsRepository` +- נוסף instance של `SettingsRepository` +- עודכן יצירת ה-`CalendarCubit` להעביר את ה-repository + +## איך זה עובד + +1. כשהמשתמש פותח את האפליקציה, ה-`CalendarCubit` טוען את ההגדרות השמורות +2. כשהמשתמש משנה את סוג לוח השנה בדיאלוג ההגדרות, הבחירה נשמרת אוטומטית +3. כשהמשתמש משנה את העיר ב-dropdown, הבחירה נשמרת אוטומטית +4. בפעם הבאה שהמשתמש יפתח את האפליקציה, לוח השנה יוצג עם ההגדרות שנבחרו + +## הגדרות זמינות + +### סוגי לוח השנה +- `hebrew` - לוח עברי בלבד +- `gregorian` - לוח לועזי בלבד +- `combined` - לוח משולב (ברירת מחדל) + +### ערים זמינות +- ירושלים (ברירת מחדל) +- תל אביב +- חיפה +- באר שבע +- נתניה +- אשדוד +- פתח תקווה +- בני ברק +- מודיעין עילית +- צפת +- טבריה +- אילת +- רחובות +- הרצליה +- רמת גן +- חולון +- בת ים +- רמלה +- לוד +- אשקלון + +כל ההגדרות נשמרות ב-SharedPreferences ונטענות אוטומטית בכל הפעלה של האפליקציה. \ No newline at end of file diff --git a/lib/navigation/calendar_cubit.dart b/lib/navigation/calendar_cubit.dart index ae547495f..7a4de8ff6 100644 --- a/lib/navigation/calendar_cubit.dart +++ b/lib/navigation/calendar_cubit.dart @@ -1,6 +1,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:kosher_dart/kosher_dart.dart'; +import 'package:otzaria/settings/settings_repository.dart'; enum CalendarType { hebrew, gregorian, combined } @@ -39,7 +40,7 @@ class CalendarState extends Equatable { dailyTimes: const {}, currentJewishDate: jewishNow, currentGregorianDate: now, - calendarType: CalendarType.combined, + calendarType: CalendarType.combined, // ברירת מחדל, יעודכן ב-_initializeCalendar calendarView: CalendarView.month, ); } @@ -90,8 +91,25 @@ class CalendarState extends Equatable { // Calendar Cubit class CalendarCubit extends Cubit { - CalendarCubit() : super(CalendarState.initial()) { - _updateTimesForDate(state.selectedGregorianDate, state.selectedCity); + final SettingsRepository _settingsRepository; + + CalendarCubit({SettingsRepository? settingsRepository}) + : _settingsRepository = settingsRepository ?? SettingsRepository(), + super(CalendarState.initial()) { + _initializeCalendar(); + } + + Future _initializeCalendar() async { + final settings = await _settingsRepository.loadSettings(); + final calendarTypeString = settings['calendarType'] as String; + final calendarType = _stringToCalendarType(calendarTypeString); + final selectedCity = settings['selectedCity'] as String; + + emit(state.copyWith( + calendarType: calendarType, + selectedCity: selectedCity, + )); + _updateTimesForDate(state.selectedGregorianDate, selectedCity); } void _updateTimesForDate(DateTime date, String city) { @@ -114,6 +132,8 @@ class CalendarCubit extends Cubit { selectedCity: newCity, dailyTimes: newTimes, )); + // שמור את הבחירה בהגדרות + _settingsRepository.updateSelectedCity(newCity); } void previousMonth() { @@ -172,6 +192,8 @@ class CalendarCubit extends Cubit { void changeCalendarType(CalendarType type) { emit(state.copyWith(calendarType: type)); + // שמור את הבחירה בהגדרות + _settingsRepository.updateCalendarType(_calendarTypeToString(type)); } void changeCalendarView(CalendarView view) { @@ -268,3 +290,27 @@ Map _calculateDailyTimes(DateTime date, String city) { String _formatTime(DateTime dt) { return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; } + +// Helper functions for CalendarType conversion +CalendarType _stringToCalendarType(String value) { + switch (value) { + case 'hebrew': + return CalendarType.hebrew; + case 'gregorian': + return CalendarType.gregorian; + case 'combined': + default: + return CalendarType.combined; + } +} + +String _calendarTypeToString(CalendarType type) { + switch (type) { + case CalendarType.hebrew: + return 'hebrew'; + case CalendarType.gregorian: + return 'gregorian'; + case CalendarType.combined: + return 'combined'; + } +} diff --git a/lib/navigation/more_screen.dart b/lib/navigation/more_screen.dart index 8413a6d41..5d7f3f29b 100644 --- a/lib/navigation/more_screen.dart +++ b/lib/navigation/more_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:otzaria/tools/measurement_converter/measurement_converter_screen.dart'; +import 'package:otzaria/settings/settings_repository.dart'; import 'calendar_widget.dart'; import 'calendar_cubit.dart'; @@ -14,11 +15,13 @@ class MoreScreen extends StatefulWidget { class _MoreScreenState extends State { int _selectedIndex = 0; late final CalendarCubit _calendarCubit; + late final SettingsRepository _settingsRepository; @override void initState() { super.initState(); - _calendarCubit = CalendarCubit(); + _settingsRepository = SettingsRepository(); + _calendarCubit = CalendarCubit(settingsRepository: _settingsRepository); } @override diff --git a/lib/search/utils/regex_patterns.dart b/lib/search/utils/regex_patterns.dart index 83ed55a16..ff415c495 100644 --- a/lib/search/utils/regex_patterns.dart +++ b/lib/search/utils/regex_patterns.dart @@ -57,7 +57,8 @@ class SearchRegexPatterns { /// יוצר רגקס לחיפוש מילה עם קידומות דקדוקיות static String createPrefixPattern(String word) { if (word.isEmpty) return word; - return r'(ו|מ|כש|כ|ב|ש|ל|ה|ד)?(כ|ב|ש|ל|ה|ד)?(ה)?' + RegExp.escape(word); + return r'(ו|מ|דא|א|כש|כ|ב|ש|ל|ה|ד)?(כ|ב|ש|ל|ה|ד)?(ה)?' + + RegExp.escape(word); } /// יוצר רגקס לחיפוש מילה עם סיומות דקדוקיות diff --git a/lib/settings/settings_repository.dart b/lib/settings/settings_repository.dart index bb2605ad9..711ef439a 100644 --- a/lib/settings/settings_repository.dart +++ b/lib/settings/settings_repository.dart @@ -21,6 +21,8 @@ class SettingsRepository { static const String keyPinSidebar = 'key-pin-sidebar'; static const String keySidebarWidth = 'key-sidebar-width'; static const String keyFacetFilteringWidth = 'key-facet-filtering-width'; + static const String keyCalendarType = 'key-calendar-type'; + static const String keySelectedCity = 'key-selected-city'; final SettingsWrapper _settings; @@ -91,6 +93,14 @@ class SettingsRepository { _settings.getValue(keySidebarWidth, defaultValue: 300), 'facetFilteringWidth': _settings.getValue(keyFacetFilteringWidth, defaultValue: 235), + 'calendarType': _settings.getValue( + keyCalendarType, + defaultValue: 'combined', + ), + 'selectedCity': _settings.getValue( + keySelectedCity, + defaultValue: 'ירושלים', + ), }; } @@ -166,6 +176,14 @@ class SettingsRepository { await _settings.setValue(keyFacetFilteringWidth, value); } + Future updateCalendarType(String value) async { + await _settings.setValue(keyCalendarType, value); + } + + Future updateSelectedCity(String value) async { + await _settings.setValue(keySelectedCity, value); + } + /// Initialize default settings to disk if this is the first app launch Future _initializeDefaultsIfNeeded() async { if (await _checkIfDefaultsNeeded()) { @@ -200,6 +218,8 @@ class SettingsRepository { await _settings.setValue(keyPinSidebar, false); await _settings.setValue(keySidebarWidth, 300.0); await _settings.setValue(keyFacetFilteringWidth, 235.0); + await _settings.setValue(keyCalendarType, 'combined'); + await _settings.setValue(keySelectedCity, 'ירושלים'); // Mark as initialized await _settings.setValue('settings_initialized', true); From 7263a05ad90213f68dee7fea9c3bddcd25a7ec09 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 8 Aug 2025 02:28:24 +0300 Subject: [PATCH 096/197] =?UTF-8?q?=D7=A9=D7=99=D7=A4=D7=95=D7=A8=D7=99?= =?UTF-8?q?=D7=9D=20=D7=A7=D7=98=D7=A0=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pdf_book/pdf_book_screen.dart | 274 ++++++++++++------- lib/search/view/enhanced_search_field.dart | 294 +++++++++------------ 2 files changed, 303 insertions(+), 265 deletions(-) diff --git a/lib/pdf_book/pdf_book_screen.dart b/lib/pdf_book/pdf_book_screen.dart index 227f12f4b..7c9366546 100644 --- a/lib/pdf_book/pdf_book_screen.dart +++ b/lib/pdf_book/pdf_book_screen.dart @@ -24,24 +24,24 @@ import 'package:printing/printing.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:otzaria/utils/page_converter.dart'; import 'package:flutter/gestures.dart'; - + class PdfBookScreen extends StatefulWidget { final PdfBookTab tab; - + const PdfBookScreen({ super.key, required this.tab, }); - + @override State createState() => _PdfBookScreenState(); } - + class _PdfBookScreenState extends State with AutomaticKeepAliveClientMixin, TickerProviderStateMixin { @override bool get wantKeepAlive => true; - + late final PdfViewerController pdfController; late final PdfTextSearcher textSearcher; TabController? _leftPaneTabController; @@ -50,24 +50,25 @@ class _PdfBookScreenState extends State final FocusNode _navigationFieldFocusNode = FocusNode(); late final ValueNotifier _sidebarWidth; late final StreamSubscription _settingsSub; - + Future _runInitialSearchIfNeeded() async { final controller = widget.tab.searchController; final String query = controller.text.trim(); if (query.isEmpty) return; print('DEBUG: Triggering search by simulating user input for "$query"'); - + // שיטה 1: הוספה והסרה מהירה controller.text = '$query '; // הוסף תו זמני - + // המתן רגע קצרצר כדי שהשינוי יתפוס await Future.delayed(const Duration(milliseconds: 50)); - + controller.text = query; // החזר את הטקסט המקורי // הזז את הסמן לסוף הטקסט - controller.selection = TextSelection.fromPosition(TextPosition(offset: controller.text.length)); - + controller.selection = TextSelection.fromPosition( + TextPosition(offset: controller.text.length)); + //ברוב המקרים, שינוי הטקסט עצמו יפעיל את ה-listener של הספרייה. // אם לא, ייתכן שעדיין צריך לקרוא לזה ידנית: textSearcher.startTextSearch(query, goToFirstMatch: false); @@ -80,28 +81,29 @@ class _PdfBookScreenState extends State } _searchFieldFocusNode.requestFocus(); } - + late TabController _tabController; final GlobalKey> _searchViewKey = GlobalKey(); int? _lastProcessedSearchSessionId; - + void _onTextSearcherUpdated() { String currentSearchTerm = widget.tab.searchController.text; int? persistedIndexFromTab = widget.tab.pdfSearchCurrentMatchIndex; - + widget.tab.searchText = currentSearchTerm; widget.tab.pdfSearchMatches = List.from(textSearcher.matches); widget.tab.pdfSearchCurrentMatchIndex = textSearcher.currentIndex; - + if (mounted) { setState(() {}); } - - bool isNewSearchExecution = (_lastProcessedSearchSessionId != textSearcher.searchSession); + + bool isNewSearchExecution = + (_lastProcessedSearchSessionId != textSearcher.searchSession); if (isNewSearchExecution) { _lastProcessedSearchSessionId = textSearcher.searchSession; } - + if (isNewSearchExecution && currentSearchTerm.isNotEmpty && textSearcher.matches.isNotEmpty && @@ -112,37 +114,36 @@ class _PdfBookScreenState extends State textSearcher.goToMatchOfIndex(persistedIndexFromTab); } } - + void initState() { super.initState(); - + // 1. צור את הבקר (המכונית) קודם כל. pdfController = PdfViewerController(); - + // 2. צור את המחפש (השלט) וחבר אותו לבקר שיצרנו הרגע. textSearcher = PdfTextSearcher(pdfController) ..addListener(_onTextSearcherUpdated); - + // 3. שמור את הבקר בטאב כדי ששאר חלקי האפליקציה יוכלו להשתמש בו. widget.tab.pdfViewerController = pdfController; - - _sidebarWidth = ValueNotifier( + + _sidebarWidth = ValueNotifier( Settings.getValue('key-sidebar-width', defaultValue: 300)!); - _settingsSub = - context.read().stream.listen((state) { + _settingsSub = context.read().stream.listen((state) { _sidebarWidth.value = state.sidebarWidth; }); // -- שאר הקוד של initState נשאר כמעט זהה -- pdfController.addListener(_onPdfViewerControllerUpdate); - + _tabController = TabController( length: 3, vsync: this, initialIndex: widget.tab.searchText.isNotEmpty ? 1 : 0, ); - + if (widget.tab.searchText.isNotEmpty) { _currentLeftPaneTabIndex = 1; } else { @@ -181,7 +182,7 @@ class _PdfBookScreenState extends State } }); } - + void _onPdfViewerControllerUpdate() { if (widget.tab.pdfViewerController.isReady) { widget.tab.pageNumber = widget.tab.pdfViewerController.pageNumber ?? 1; @@ -193,7 +194,7 @@ class _PdfBookScreenState extends State }(); } } - + @override void dispose() { textSearcher.removeListener(_onTextSearcherUpdated); @@ -205,7 +206,7 @@ class _PdfBookScreenState extends State _settingsSub.cancel(); super.dispose(); } - + @override Widget build(BuildContext context) { super.build(context); @@ -213,7 +214,8 @@ class _PdfBookScreenState extends State final wideScreen = (MediaQuery.of(context).size.width >= 600); return CallbackShortcuts( bindings: { - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF): _ensureSearchTabIsActive, + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF): + _ensureSearchTabIsActive, LogicalKeySet(LogicalKeyboardKey.arrowRight): _goNextPage, LogicalKeySet(LogicalKeyboardKey.arrowLeft): _goPreviousPage, LogicalKeySet(LogicalKeyboardKey.arrowDown): _goNextPage, @@ -240,7 +242,8 @@ class _PdfBookScreenState extends State valueListenable: widget.tab.currentTitle, builder: (context, value, child) { String displayTitle = value; - if (value.isNotEmpty && !value.contains(widget.tab.book.title)) { + if (value.isNotEmpty && + !value.contains(widget.tab.book.title)) { displayTitle = "${widget.tab.book.title}, $value"; } return SelectionArea( @@ -256,23 +259,33 @@ class _PdfBookScreenState extends State icon: const Icon(Icons.menu), tooltip: 'חיפוש וניווט', onPressed: () { - widget.tab.showLeftPane.value = !widget.tab.showLeftPane.value; + widget.tab.showLeftPane.value = + !widget.tab.showLeftPane.value; }, ), actions: [ - _buildTextButton(context, widget.tab.book, widget.tab.pdfViewerController), + _buildTextButton( + context, widget.tab.book, widget.tab.pdfViewerController), IconButton( icon: const Icon(Icons.bookmark_add), tooltip: 'הוספת סימניה', onPressed: () { - int index = widget.tab.pdfViewerController.isReady ? widget.tab.pdfViewerController.pageNumber! : 1; - bool bookmarkAdded = Provider.of(context, listen: false) - .addBookmark(ref: '${widget.tab.title} עמוד $index', book: widget.tab.book, index: index); + int index = widget.tab.pdfViewerController.isReady + ? widget.tab.pdfViewerController.pageNumber! + : 1; + bool bookmarkAdded = + Provider.of(context, listen: false) + .addBookmark( + ref: '${widget.tab.title} עמוד $index', + book: widget.tab.book, + index: index); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(bookmarkAdded ? 'הסימניה נוספה בהצלחה' : 'הסימניה כבר קיימת'), - duration: const Duration(milliseconds: 350), + content: Text(bookmarkAdded + ? 'הסימניה נוספה בהצלחה' + : 'הסימניה כבר קיימת'), + duration: const Duration(milliseconds: 350), ), ); } @@ -298,20 +311,26 @@ class _PdfBookScreenState extends State IconButton( icon: const Icon(Icons.first_page), tooltip: 'תחילת הספר', - onPressed: () => widget.tab.pdfViewerController.goToPage(pageNumber: 1), + onPressed: () => + widget.tab.pdfViewerController.goToPage(pageNumber: 1), ), IconButton( icon: const Icon(Icons.chevron_left), tooltip: 'הקודם', onPressed: () => widget.tab.pdfViewerController.isReady - ? widget.tab.pdfViewerController.goToPage(pageNumber: max(widget.tab.pdfViewerController.pageNumber! - 1, 1)) + ? widget.tab.pdfViewerController.goToPage( + pageNumber: max( + widget.tab.pdfViewerController.pageNumber! - 1, + 1)) : null, ), PageNumberDisplay(controller: widget.tab.pdfViewerController), IconButton( onPressed: () => widget.tab.pdfViewerController.isReady ? widget.tab.pdfViewerController.goToPage( - pageNumber: min(widget.tab.pdfViewerController.pageNumber! + 1, widget.tab.pdfViewerController.pages.length)) + pageNumber: min( + widget.tab.pdfViewerController.pageNumber! + 1, + widget.tab.pdfViewerController.pages.length)) : null, icon: const Icon(Icons.chevron_right), tooltip: 'הבא', @@ -320,11 +339,13 @@ class _PdfBookScreenState extends State IconButton( icon: const Icon(Icons.last_page), tooltip: 'סוף הספר', - onPressed: () => widget.tab.pdfViewerController.goToPage(pageNumber: widget.tab.pdfViewerController.pages.length), + onPressed: () => widget.tab.pdfViewerController.goToPage( + pageNumber: + widget.tab.pdfViewerController.pages.length), ), IconButton( - icon: const Icon(Icons.share), - tooltip: 'שיתוף', + icon: const Icon(Icons.print), + tooltip: 'הדפס', onPressed: () async { await Printing.sharePdf( bytes: File(widget.tab.book.path).readAsBytesSync(), @@ -344,24 +365,27 @@ class _PdfBookScreenState extends State child: GestureDetector( behavior: HitTestBehavior.translucent, onHorizontalDragUpdate: (details) { - final newWidth = (_sidebarWidth.value - - details.delta.dx) - .clamp(200.0, 600.0); + final newWidth = + (_sidebarWidth.value - details.delta.dx) + .clamp(200.0, 600.0); _sidebarWidth.value = newWidth; }, onHorizontalDragEnd: (_) { - context.read().add( - UpdateSidebarWidth(_sidebarWidth.value)); + context + .read() + .add(UpdateSidebarWidth(_sidebarWidth.value)); }, child: const VerticalDivider(width: 4), ), ) : const SizedBox.shrink(), - ), + ), Expanded( child: NotificationListener( onNotification: (notification) { - if (!(widget.tab.pinLeftPane.value || (Settings.getValue('key-pin-sidebar') ?? false))) { + if (!(widget.tab.pinLeftPane.value || + (Settings.getValue('key-pin-sidebar') ?? + false))) { Future.microtask(() { widget.tab.showLeftPane.value = false; }); @@ -370,14 +394,19 @@ class _PdfBookScreenState extends State }, child: Listener( onPointerSignal: (event) { - if (event is PointerScrollEvent && !(widget.tab.pinLeftPane.value || (Settings.getValue('key-pin-sidebar') ?? false))) { + if (event is PointerScrollEvent && + !(widget.tab.pinLeftPane.value || + (Settings.getValue('key-pin-sidebar') ?? + false))) { widget.tab.showLeftPane.value = false; } }, child: ColorFiltered( colorFilter: ColorFilter.mode( Colors.white, - Provider.of(context, listen: true).state.isDarkMode + Provider.of(context, listen: true) + .state + .isDarkMode ? BlendMode.difference : BlendMode.dst, ), @@ -391,21 +420,27 @@ class _PdfBookScreenState extends State horizontalCacheExtent: 5, verticalCacheExtent: 5, onInteractionStart: (_) { - if (!(widget.tab.pinLeftPane.value || (Settings.getValue('key-pin-sidebar') ?? false))) { + if (!(widget.tab.pinLeftPane.value || + (Settings.getValue('key-pin-sidebar') ?? + false))) { widget.tab.showLeftPane.value = false; } }, - viewerOverlayBuilder: (context, size, handleLinkTap) => [ + viewerOverlayBuilder: + (context, size, handleLinkTap) => [ PdfViewerScrollThumb( controller: widget.tab.pdfViewerController, orientation: ScrollbarOrientation.right, thumbSize: const Size(40, 25), - thumbBuilder: (context, thumbSize, pageNumber, controller) => Container( + thumbBuilder: (context, thumbSize, pageNumber, + controller) => + Container( color: Colors.black, child: Center( child: Text( pageNumber.toString(), - style: const TextStyle(color: Colors.white), + style: + const TextStyle(color: Colors.white), ), ), ), @@ -414,7 +449,9 @@ class _PdfBookScreenState extends State controller: widget.tab.pdfViewerController, orientation: ScrollbarOrientation.bottom, thumbSize: const Size(80, 5), - thumbBuilder: (context, thumbSize, pageNumber, controller) => Container( + thumbBuilder: (context, thumbSize, pageNumber, + controller) => + Container( decoration: BoxDecoration( color: Colors.grey[300], borderRadius: BorderRadius.circular(3), @@ -422,20 +459,26 @@ class _PdfBookScreenState extends State ), ), ], - loadingBannerBuilder: (context, bytesDownloaded, totalBytes) => Center( + loadingBannerBuilder: + (context, bytesDownloaded, totalBytes) => + Center( child: CircularProgressIndicator( - value: totalBytes != null ? bytesDownloaded / totalBytes : null, + value: totalBytes != null + ? bytesDownloaded / totalBytes + : null, backgroundColor: Colors.grey, ), ), - linkWidgetBuilder: (context, link, size) => Material( + linkWidgetBuilder: (context, link, size) => + Material( color: Colors.transparent, child: InkWell( onTap: () async { if (link.url != null) { navigateToUrl(link.url!); } else if (link.dest != null) { - widget.tab.pdfViewerController.goToDest(link.dest); + widget.tab.pdfViewerController + .goToDest(link.dest); } }, hoverColor: Colors.blue.withOpacity(0.2), @@ -452,20 +495,27 @@ class _PdfBookScreenState extends State }, onViewerReady: (document, controller) async { // 1. הגדרת המידע הראשוני מהמסמך - widget.tab.documentRef.value = controller.documentRef; - widget.tab.outline.value = await document.loadOutline(); - + widget.tab.documentRef.value = + controller.documentRef; + widget.tab.outline.value = + await document.loadOutline(); + // 2. עדכון הכותרת הנוכחית - widget.tab.currentTitle.value = await refFromPageNumber( - widget.tab.pdfViewerController.pageNumber ?? 1, - widget.tab.outline.value, - widget.tab.book.title); - + widget.tab.currentTitle.value = + await refFromPageNumber( + widget.tab.pdfViewerController + .pageNumber ?? + 1, + widget.tab.outline.value, + widget.tab.book.title); + // 3. הפעלת החיפוש הראשוני (עכשיו עם מנגנון ניסיונות חוזרים) _runInitialSearchIfNeeded(); - + // 4. הצגת חלונית הצד אם צריך - if (mounted && (widget.tab.showLeftPane.value || widget.tab.searchText.isNotEmpty)) { + if (mounted && + (widget.tab.showLeftPane.value || + widget.tab.searchText.isNotEmpty)) { widget.tab.showLeftPane.value = true; } }, @@ -482,7 +532,7 @@ class _PdfBookScreenState extends State ); }); } - + AnimatedSize _buildLeftPane() { return AnimatedSize( duration: const Duration(milliseconds: 300), @@ -512,32 +562,49 @@ class _PdfBookScreenState extends State child: TabBar( controller: _leftPaneTabController, tabs: const [ - Tab(child: Center(child: Text('ניווט', textAlign: TextAlign.center))), - Tab(child: Center(child: Text('חיפוש', textAlign: TextAlign.center))), - Tab(child: Center(child: Text('דפים', textAlign: TextAlign.center))), + Tab( + child: Center( + child: Text('ניווט', + textAlign: TextAlign.center))), + Tab( + child: Center( + child: Text('חיפוש', + textAlign: TextAlign.center))), + Tab( + child: Center( + child: Text('דפים', + textAlign: TextAlign.center))), ], isScrollable: false, tabAlignment: TabAlignment.fill, padding: EdgeInsets.zero, indicatorPadding: EdgeInsets.zero, - labelPadding: const EdgeInsets.symmetric(horizontal: 2), + labelPadding: + const EdgeInsets.symmetric(horizontal: 2), ), ), ), ), ValueListenableBuilder( valueListenable: widget.tab.pinLeftPane, - builder: (context, pinLeftPanel, child) => MediaQuery.of(context).size.width < 600 - ? const SizedBox.shrink() - : IconButton( - onPressed: (Settings.getValue('key-pin-sidebar') ?? false) - ? null - : () { - widget.tab.pinLeftPane.value = !widget.tab.pinLeftPane.value; - }, - icon: const Icon(Icons.push_pin), - isSelected: pinLeftPanel || (Settings.getValue('key-pin-sidebar') ?? false), - ), + builder: (context, pinLeftPanel, child) => + MediaQuery.of(context).size.width < 600 + ? const SizedBox.shrink() + : IconButton( + onPressed: (Settings.getValue( + 'key-pin-sidebar') ?? + false) + ? null + : () { + widget.tab.pinLeftPane.value = + !widget.tab.pinLeftPane.value; + }, + icon: const Icon(Icons.push_pin), + isSelected: pinLeftPanel || + (Settings.getValue( + 'key-pin-sidebar') ?? + false), + ), ), ], ), @@ -588,27 +655,28 @@ class _PdfBookScreenState extends State ), ); } - + void _goNextPage() { if (widget.tab.pdfViewerController.isReady) { - final nextPage = min(widget.tab.pdfViewerController.pageNumber! + 1, widget.tab.pdfViewerController.pages.length); + final nextPage = min(widget.tab.pdfViewerController.pageNumber! + 1, + widget.tab.pdfViewerController.pages.length); widget.tab.pdfViewerController.goToPage(pageNumber: nextPage); } } - + void _goPreviousPage() { if (widget.tab.pdfViewerController.isReady) { final prevPage = max(widget.tab.pdfViewerController.pageNumber! - 1, 1); widget.tab.pdfViewerController.goToPage(pageNumber: prevPage); } } - + Future navigateToUrl(Uri url) async { if (await shouldOpenUrl(context, url)) { await launchUrl(url); } } - + Future shouldOpenUrl(BuildContext context, Uri url) async { final result = await showDialog( context: context, @@ -644,19 +712,25 @@ class _PdfBookScreenState extends State ); return result ?? false; } - - Widget _buildTextButton(BuildContext context, PdfBook book, PdfViewerController controller) { + + Widget _buildTextButton( + BuildContext context, PdfBook book, PdfViewerController controller) { return FutureBuilder( - future: DataRepository.instance.library.then((library) => library.findBookByTitle(book.title, TextBook)), + future: DataRepository.instance.library + .then((library) => library.findBookByTitle(book.title, TextBook)), builder: (context, snapshot) => snapshot.hasData ? IconButton( icon: const Icon(Icons.article), tooltip: 'פתח טקסט', onPressed: () async { - final index = await pdfToTextPage(book, widget.tab.outline.value ?? [], controller.pageNumber ?? 1, context); + final index = await pdfToTextPage( + book, + widget.tab.outline.value ?? [], + controller.pageNumber ?? 1, + context); openBook(context, snapshot.data!, index ?? 0, ''); }) : const SizedBox.shrink(), ); } -} \ No newline at end of file +} diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index 9ae72eca2..f98ea5de3 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; -import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:otzaria/history/bloc/history_bloc.dart'; @@ -52,35 +50,40 @@ class _PlusButtonState extends State<_PlusButton> { final primaryColor = Theme.of(context).primaryColor; // MouseRegion מזהה ריחוף עכבר - return MouseRegion( - onEnter: (_) => setState(() => _isHovering = true), - onExit: (_) => setState(() => _isHovering = false), - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: widget.onTap, - child: AnimatedContainer( - // אנימציה למעבר חלק - duration: const Duration(milliseconds: 200), - width: 20, - height: 20, - decoration: BoxDecoration( - color: isHighlighted - ? primaryColor - : primaryColor.withValues(alpha: 0.5), - shape: BoxShape.circle, - boxShadow: [ - if (isHighlighted) - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: const Icon( - Icons.add, - size: 12, - color: Colors.white, + return Tooltip( + message: 'הוסף מילה חלופית', + waitDuration: const Duration(milliseconds: 500), + showDuration: const Duration(milliseconds: 1500), + child: MouseRegion( + onEnter: (_) => setState(() => _isHovering = true), + onExit: (_) => setState(() => _isHovering = false), + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + // אנימציה למעבר חלק + duration: const Duration(milliseconds: 200), + width: 20, + height: 20, + decoration: BoxDecoration( + color: isHighlighted + ? primaryColor + : primaryColor.withValues(alpha: 0.5), + shape: BoxShape.circle, + boxShadow: [ + if (isHighlighted) + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: const Icon( + Icons.add, + size: 12, + color: Colors.white, + ), ), ), ), @@ -95,34 +98,39 @@ class _SpacingButtonState extends State<_SpacingButton> { Widget build(BuildContext context) { final primaryColor = Theme.of(context).primaryColor; - return MouseRegion( - onEnter: (_) => setState(() => _isHovering = true), - onExit: (_) => setState(() => _isHovering = false), - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: widget.onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: 20, - height: 20, - decoration: BoxDecoration( - color: _isHovering - ? primaryColor - : primaryColor.withValues(alpha: 0.7), - shape: BoxShape.circle, - boxShadow: [ - if (_isHovering) - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: const Icon( - Icons.more_horiz, - size: 12, - color: Colors.white, + return Tooltip( + message: 'הגדר ריווח בין מילים', + waitDuration: const Duration(milliseconds: 500), + showDuration: const Duration(milliseconds: 1500), + child: MouseRegion( + onEnter: (_) => setState(() => _isHovering = true), + onExit: (_) => setState(() => _isHovering = false), + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 20, + height: 20, + decoration: BoxDecoration( + color: _isHovering + ? primaryColor + : primaryColor.withValues(alpha: 0.7), + shape: BoxShape.circle, + boxShadow: [ + if (_isHovering) + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: const Icon( + Icons.swap_horiz, + size: 12, + color: Colors.white, + ), ), ), ), @@ -498,8 +506,6 @@ class _EnhancedSearchFieldState extends State { final Map> _alternativeOverlays = {}; OverlayEntry? _searchOptionsOverlay; int? _hoveredWordIndex; - bool _isUpdatingText = false; // דגל למניעת לולאות אינסופיות - String _lastProcessedText = ''; // מעקב אחר הטקסט האחרון שעובד final Map _spacingOverlays = {}; final Map _spacingControllers = {}; @@ -841,25 +847,11 @@ class _EnhancedSearchFieldState extends State { } void _onTextChanged() { - // מניעת לולאות אינסופיות - if (_isUpdatingText) { - debugPrint('🚫 Skipping text change - already updating'); - return; - } - - final text = widget.widget.tab.queryController.text; - - // מניעת עיבוד מיותר אם הטקסט לא השתנה באמת - if (text == _lastProcessedText) { - debugPrint('🚫 Skipping text change - text unchanged: "$text"'); - return; - } - - _lastProcessedText = text; - // בודקים אם המגירה הייתה פתוחה לפני השינוי final bool drawerWasOpen = _searchOptionsOverlay != null; + final text = widget.widget.tab.queryController.text; + // אם שדה החיפוש התרוקן, נקה הכל ונסגור את המגירה if (text.trim().isEmpty) { _clearAllOverlays(); @@ -957,44 +949,33 @@ class _EnhancedSearchFieldState extends State { // טיפול בשינוי קטן - שמירה על כל הסימונים void _handleMinorTextChange(String text, bool drawerWasOpen) { - _isUpdatingText = true; // הגדרת הדגל למניעת לולאות - - try { - // מנקים רק את הבועות הריקות, שומרים על הכל - _clearAllOverlays( - keepSearchDrawer: drawerWasOpen, keepFilledBubbles: true); + // מנקים רק את הבועות הריקות, שומרים על הכל + _clearAllOverlays(keepSearchDrawer: drawerWasOpen, keepFilledBubbles: true); - // שמירת אפשרויות החיפוש הקיימות ומילים ישנות לפני יצירת SearchQuery חדש - final oldSearchOptions = - Map.from(widget.widget.tab.searchOptions); - final oldWords = _searchQuery.terms.map((t) => t.word).toList(); + // שמירת אפשרויות החיפוש הקיימות ומילים ישנות לפני יצירת SearchQuery חדש + final oldSearchOptions = + Map.from(widget.widget.tab.searchOptions); + final oldWords = _searchQuery.terms.map((t) => t.word).toList(); - setState(() { - _searchQuery = SearchQuery.fromString(text); - // לא קוראים ל-_updateAlternativeControllers כדי לא לפגוע במיפוי הקיים - }); + setState(() { + _searchQuery = SearchQuery.fromString(text); + // לא קוראים ל-_updateAlternativeControllers כדי לא לפגוע במיפוי הקיים + }); - // עדכון אפשרויות החיפוש לפי המילים החדשות (שמירה על אפשרויות קיימות) - _updateSearchOptionsForMinorChange(oldSearchOptions, oldWords, text); + // עדכון אפשרויות החיפוש לפי המילים החדשות (שמירה על אפשרויות קיימות) + _updateSearchOptionsForMinorChange(oldSearchOptions, oldWords, text); - debugPrint( - '✅ After minor change - search options: ${widget.widget.tab.searchOptions.keys.toList()}'); + debugPrint( + '✅ After minor change - search options: ${widget.widget.tab.searchOptions.keys.toList()}'); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _calculateWordPositions(); - _showAllExistingBubbles(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _calculateWordPositions(); + _showAllExistingBubbles(); - if (drawerWasOpen) { - _updateSearchOptionsOverlay(); - } - } - _isUpdatingText = false; // איפוס הדגל - }); - } catch (e) { - _isUpdatingText = false; // איפוס הדגל גם במקרה של שגיאה - rethrow; - } + if (drawerWasOpen) { + _updateSearchOptionsOverlay(); + } + }); } // עדכון אפשרויות החיפוש בשינוי קטן - שמירה על אפשרויות קיימות @@ -1065,44 +1046,34 @@ class _EnhancedSearchFieldState extends State { // טיפול בשינוי גדול - ניקוי סימונים לא רלוונטיים void _handleMajorTextChange( String text, List newWords, bool drawerWasOpen) { - _isUpdatingText = true; // הגדרת הדגל למניעת לולאות - - try { - // מיפוי מילים ישנות למילים חדשות לפי דמיון - final wordMapping = _mapOldWordsToNew(newWords); - debugPrint('🗺️ Word mapping: $wordMapping'); + // מיפוי מילים ישנות למילים חדשות לפי דמיון + final wordMapping = _mapOldWordsToNew(newWords); + debugPrint('🗺️ Word mapping: $wordMapping'); - // עדכון controllers ו-overlays לפי המיפוי החדש - _remapControllersAndOverlays(wordMapping); + // עדכון controllers ו-overlays לפי המיפוי החדש + _remapControllersAndOverlays(wordMapping); - // עדכון אפשרויות החיפוש לפי המיפוי החדש - _remapSearchOptions(wordMapping, newWords); + // עדכון אפשרויות החיפוש לפי המיפוי החדש + _remapSearchOptions(wordMapping, newWords); - // ניקוי נתונים לא רלוונטיים - _cleanupIrrelevantData(newWords.toSet()); + // ניקוי נתונים לא רלוונטיים + _cleanupIrrelevantData(newWords.toSet()); - // לא צריך לקרוא ל-_clearAllOverlays כי כבר ניקינו הכל ב-_remapControllersAndOverlays + // לא צריך לקרוא ל-_clearAllOverlays כי כבר ניקינו הכל ב-_remapControllersAndOverlays - setState(() { - _searchQuery = SearchQuery.fromString(text); - }); + setState(() { + _searchQuery = SearchQuery.fromString(text); + }); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _calculateWordPositions(); - debugPrint('🎈 Showing remapped bubbles after major change'); - _showAllExistingBubbles(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _calculateWordPositions(); + debugPrint('🎈 Showing remapped bubbles after major change'); + _showAllExistingBubbles(); - if (drawerWasOpen) { - _updateSearchOptionsOverlay(); - } - } - _isUpdatingText = false; // איפוס הדגל - }); - } catch (e) { - _isUpdatingText = false; // איפוס הדגל גם במקרה של שגיאה - rethrow; - } + if (drawerWasOpen) { + _updateSearchOptionsOverlay(); + } + }); } // מיפוי מילים ישנות למילים חדשות @@ -1307,40 +1278,36 @@ class _EnhancedSearchFieldState extends State { } void _onCursorPositionChanged() { - // עדכון המגירה כשהסמן זז (אם היא פתוחה) - רק אם לא באמצע עדכון טקסט - if (_searchOptionsOverlay != null && !_isUpdatingText) { + // עדכון המגירה כשהסמן זז (אם היא פתוחה) + if (_searchOptionsOverlay != null) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && !_isUpdatingText) { - _updateSearchOptionsOverlay(); - } + _updateSearchOptionsOverlay(); }); } } void _updateSearchOptionsOverlay() { // עדכון המגירה אם היא פתוחה - if (_searchOptionsOverlay != null && !_isUpdatingText) { + if (_searchOptionsOverlay != null) { // שמירת מיקום הסמן לפני העדכון final currentSelection = widget.widget.tab.queryController.selection; _hideSearchOptionsOverlay(); _showSearchOptionsOverlay(); - // החזרת מיקום הסמן אחרי העדכון - רק אם לא באמצע עדכון טקסט + // החזרת מיקום הסמן אחרי העדכון WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && !_isUpdatingText) { + if (mounted) { debugPrint( 'DEBUG: Restoring cursor position in update: ${currentSelection.baseOffset}'); - _isUpdatingText = true; widget.widget.tab.queryController.selection = currentSelection; - _isUpdatingText = false; } }); } } void _calculateWordPositions() { - if (_textFieldKey.currentContext == null || _isUpdatingText) return; + if (_textFieldKey.currentContext == null) return; RenderEditable? editable; void findEditable(RenderObject child) { @@ -1366,9 +1333,7 @@ class _EnhancedSearchFieldState extends State { final text = widget.widget.tab.queryController.text; if (text.isEmpty) { - if (mounted && !_isUpdatingText) { - setState(() {}); - } + setState(() {}); return; } @@ -1380,13 +1345,13 @@ class _EnhancedSearchFieldState extends State { if (start == -1) continue; final end = start + w.length; - final boxes = editable!.getBoxesForSelection( + final pts = editable!.getEndpointsForSelection( TextSelection(baseOffset: start, extentOffset: end), ); - if (boxes.isEmpty) continue; + if (pts.isEmpty) continue; - final leftLocalX = boxes.map((b) => b.left).reduce(math.min); - final rightLocalX = boxes.map((b) => b.right).reduce(math.max); + final leftLocalX = pts.first.point.dx; + final rightLocalX = pts.last.point.dx; final leftGlobal = editable!.localToGlobal(Offset(leftLocalX, 0)); final rightGlobal = editable!.localToGlobal(Offset(rightLocalX, 0)); @@ -1405,19 +1370,18 @@ class _EnhancedSearchFieldState extends State { idx = end; } - if (text.isNotEmpty && _wordPositions.isEmpty && !_isUpdatingText) { - // החישוב נכשל למרות שיש טקסט. ננסה שוב ב-frame הבא. + if (text.isNotEmpty && _wordPositions.isEmpty) { +// החישוב נכשל למרות שיש טקסט. ננסה שוב ב-frame הבא. WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && !_isUpdatingText) { + if (mounted) { + // ודא שהווידג'ט עדיין קיים _calculateWordPositions(); } }); - return; + return; // צא מהפונקציה כדי לא לקרוא ל-setState עם מידע שגוי } - if (mounted && !_isUpdatingText) { - setState(() {}); - } + setState(() {}); } void _addAlternative(int termIndex) { From f02bcaef17996faca6ea720a1d31509f885bca61 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 8 Aug 2025 11:13:41 +0300 Subject: [PATCH 097/197] =?UTF-8?q?=D7=A9=D7=99=D7=A0=D7=95=D7=99=20=D7=92?= =?UTF-8?q?=D7=A8=D7=A1=D7=94=20=D7=95=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20?= =?UTF-8?q?=D7=92=D7=A8=D7=A1=D7=94=20=D7=90=D7=95=D7=98=D7=95=D7=9E=D7=98?= =?UTF-8?q?=D7=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +-- installer/otzaria.iss | 4 +-- installer/otzaria_full.iss | 4 +-- pubspec.yaml | 4 +-- update_version.bat | 9 +++++ update_version.ps1 | 68 ++++++++++++++++++++++++++++++++++++++ version.json | 3 ++ 7 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 update_version.bat create mode 100644 update_version.ps1 create mode 100644 version.json diff --git a/.gitignore b/.gitignore index 3ab6714ac..217823cb2 100644 --- a/.gitignore +++ b/.gitignore @@ -60,7 +60,7 @@ android/ build/ fonts/ -installer/otzaria-0.2.7-windows.exe -installer/otzaria-0.2.7-windows-full.exe +installer/otzaria-0.9.1-windows.exe +installer/otzaria-0.9.1-windows-full.exe pubspec.lock flutter/ diff --git a/installer/otzaria.iss b/installer/otzaria.iss index 4e231e1e2..48f7ba24b 100644 --- a/installer/otzaria.iss +++ b/installer/otzaria.iss @@ -1,8 +1,8 @@ -; Script generated by the Inno Setup Script Wizard. +; Script generated by the Inno Setup Script Wizard. ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.2.7" +#define MyAppVersion "0.9.1" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/installer/otzaria_full.iss b/installer/otzaria_full.iss index dfc892751..ca46cd9dd 100644 --- a/installer/otzaria_full.iss +++ b/installer/otzaria_full.iss @@ -1,8 +1,8 @@ -; Script generated by the Inno Setup Script Wizard. +; Script generated by the Inno Setup Script Wizard. ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.2.7" +#define MyAppVersion "0.9.1" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/pubspec.yaml b/pubspec.yaml index 8fb056bc4..cc1329b96 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,7 @@ msix_config: publisher_display_name: sivan22 identity_name: sivan22.Otzaria description: "ספריית ספרים יהודיים עם אפשרויות חיפוש חכם" - msix_version: 0.2.7.2 + msix_version: 0.9.1.0 logo_path: assets/icon/icon.png publisher: CN=sivan22, O=sivan22, C=IL certificate_path: sivan22.pfx @@ -36,7 +36,7 @@ msix_config: # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.2.7 +version: 0.9.1 environment: sdk: ">=3.2.6 <4.0.0" diff --git a/update_version.bat b/update_version.bat new file mode 100644 index 000000000..39b1dbd8e --- /dev/null +++ b/update_version.bat @@ -0,0 +1,9 @@ +@echo off +echo Running version update script... +powershell -ExecutionPolicy Bypass -File update_version.ps1 +if %ERRORLEVEL% EQU 0 ( + echo Version update completed successfully! +) else ( + echo Version update failed! +) +pause \ No newline at end of file diff --git a/update_version.ps1 b/update_version.ps1 new file mode 100644 index 000000000..0a4f0a460 --- /dev/null +++ b/update_version.ps1 @@ -0,0 +1,68 @@ +# PowerShell script to update version across all files +param( + [string]$VersionFile = "version.json" +) + +# Read version from JSON file +if (-not (Test-Path $VersionFile)) { + Write-Error "Version file '$VersionFile' not found!" + exit 1 +} + +$versionData = Get-Content $VersionFile | ConvertFrom-Json +$newVersion = $versionData.version + +# Create different version formats for different files +$msixVersion = "$newVersion.0" # MSIX needs 4 parts + +Write-Host "Updating version to: $newVersion" +Write-Host "MSIX version will be: $msixVersion" + +# Update .gitignore (lines 63-64) +$gitignoreContent = Get-Content ".gitignore" +for ($i = 0; $i -lt $gitignoreContent.Length; $i++) { + if ($gitignoreContent[$i] -match "installer/otzaria-.*-windows\.exe") { + $gitignoreContent[$i] = "installer/otzaria-$newVersion-windows.exe" + } + if ($gitignoreContent[$i] -match "installer/otzaria-.*-windows-full\.exe") { + $gitignoreContent[$i] = "installer/otzaria-$newVersion-windows-full.exe" + } +} +$gitignoreContent | Set-Content ".gitignore" +Write-Host "Updated .gitignore" + +# Update pubspec.yaml (lines 13 and 39) +$pubspecContent = Get-Content "pubspec.yaml" +for ($i = 0; $i -lt $pubspecContent.Length; $i++) { + if ($pubspecContent[$i] -match "^\s*msix_version:\s*") { + $pubspecContent[$i] = " msix_version: $msixVersion" + } + if ($pubspecContent[$i] -match "^\s*version:\s*") { + $pubspecContent[$i] = "version: $newVersion" + } +} +$pubspecContent | Set-Content "pubspec.yaml" +Write-Host "Updated pubspec.yaml" + +# Update installer/otzaria_full.iss (line 5) +$fullIssContent = Get-Content "installer/otzaria_full.iss" +for ($i = 0; $i -lt $fullIssContent.Length; $i++) { + if ($fullIssContent[$i] -match '^#define MyAppVersion\s+') { + $fullIssContent[$i] = "#define MyAppVersion `"$newVersion`"" + } +} +$fullIssContent | Set-Content "installer/otzaria_full.iss" +Write-Host "Updated installer/otzaria_full.iss" + +# Update installer/otzaria.iss (line 5) +$issContent = Get-Content "installer/otzaria.iss" +for ($i = 0; $i -lt $issContent.Length; $i++) { + if ($issContent[$i] -match '^#define MyAppVersion\s+') { + $issContent[$i] = "#define MyAppVersion `"$newVersion`"" + } +} +$issContent | Set-Content "installer/otzaria.iss" +Write-Host "Updated installer/otzaria.iss" + +Write-Host "Version update completed successfully!" +Write-Host "All files have been updated to version: $newVersion" \ No newline at end of file diff --git a/version.json b/version.json new file mode 100644 index 000000000..35db7aa63 --- /dev/null +++ b/version.json @@ -0,0 +1,3 @@ +{ + "version": "0.9.1" +} \ No newline at end of file From 0185c92a7ce9656613a026f873ff9514bcaa0cfe Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 10 Aug 2025 11:12:22 +0300 Subject: [PATCH 098/197] =?UTF-8?q?=D7=92=D7=9C=D7=99=D7=9C=D7=94=20=D7=91?= =?UTF-8?q?=D7=99=D7=9F=20=D7=AA=D7=95=D7=A6=D7=90=D7=95=D7=AA=20=D7=91?= =?UTF-8?q?=D7=9E=D7=A4=D7=A8=D7=A9=D7=99=D7=9D=20-=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../combined_view/commentary_content.dart | 26 +++- .../commentary_list_for_splited_view.dart | 136 +++++++++++++++++- lib/utils/text_manipulation.dart | 34 ++++- 3 files changed, 184 insertions(+), 12 deletions(-) diff --git a/lib/text_book/view/combined_view/commentary_content.dart b/lib/text_book/view/combined_view/commentary_content.dart index db62b82ef..828d1c28a 100644 --- a/lib/text_book/view/combined_view/commentary_content.dart +++ b/lib/text_book/view/combined_view/commentary_content.dart @@ -17,12 +17,16 @@ class CommentaryContent extends StatefulWidget { required this.openBookCallback, required this.removeNikud, this.searchQuery = '', + this.currentSearchIndex = 0, + this.onSearchResultsCountChanged, }); final bool removeNikud; final Link link; final double fontSize; final Function(TextBookTab) openBookCallback; final String searchQuery; + final int currentSearchIndex; + final Function(int)? onSearchResultsCountChanged; @override State createState() => _CommentaryContentState(); @@ -37,6 +41,17 @@ class _CommentaryContentState extends State { content = widget.link.content; } + int _countSearchMatches(String text, String searchQuery) { + if (searchQuery.isEmpty) return 0; + + final RegExp regex = RegExp( + RegExp.escape(searchQuery), + caseSensitive: false, + ); + + return regex.allMatches(text).length; + } + @override Widget build(BuildContext context) { return GestureDetector( @@ -57,7 +72,16 @@ class _CommentaryContentState extends State { if (widget.removeNikud) { text = utils.removeVolwels(text); } - text = utils.highLight(text, widget.searchQuery); + + // ספירת תוצאות החיפוש ועדכון הרכיב האב + if (widget.searchQuery.isNotEmpty) { + final searchCount = _countSearchMatches(text, widget.searchQuery); + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onSearchResultsCountChanged?.call(searchCount); + }); + } + + text = utils.highLight(text, widget.searchQuery, currentIndex: widget.currentSearchIndex); return BlocBuilder( builder: (context, settingsState) { return Html(data: text, style: { diff --git a/lib/text_book/view/splited_view/commentary_list_for_splited_view.dart b/lib/text_book/view/splited_view/commentary_list_for_splited_view.dart index cb3b5c8ae..1f693eac5 100644 --- a/lib/text_book/view/splited_view/commentary_list_for_splited_view.dart +++ b/lib/text_book/view/splited_view/commentary_list_for_splited_view.dart @@ -34,6 +34,24 @@ class _CommentaryListState extends State { late Future> thisLinks; late List indexes; final ScrollOffsetController scrollController = ScrollOffsetController(); + int _currentSearchIndex = 0; + int _totalSearchResults = 0; + final Map _searchResultsPerItem = {}; + + int _getItemSearchIndex(int itemIndex) { + int cumulativeIndex = 0; + for (int i = 0; i < itemIndex; i++) { + cumulativeIndex += _searchResultsPerItem[i] ?? 0; + } + + final itemResults = _searchResultsPerItem[itemIndex] ?? 0; + if (itemResults == 0) return -1; + + final relativeIndex = _currentSearchIndex - cumulativeIndex; + return (relativeIndex >= 0 && relativeIndex < itemResults) + ? relativeIndex + : -1; + } @override void dispose() { @@ -41,6 +59,47 @@ class _CommentaryListState extends State { super.dispose(); } + void _scrollToSearchResult() { + if (_totalSearchResults == 0) return; + + // מחשבים באיזה פריט נמצאת התוצאה הנוכחית + int cumulativeIndex = 0; + int targetItemIndex = 0; + + for (int i = 0; i < _searchResultsPerItem.length; i++) { + final itemResults = _searchResultsPerItem[i] ?? 0; + if (_currentSearchIndex < cumulativeIndex + itemResults) { + targetItemIndex = i; + break; + } + cumulativeIndex += itemResults; + } + + // גוללים לפריט הרלוונטי + try { + scrollController.animateScroll( + offset: targetItemIndex * 100.0, // הערכה גסה של גובה פריט + duration: const Duration(milliseconds: 300), + ); + } catch (e) { + // אם יש בעיה עם הגלילה, נתעלם מהשגיאה + } + } + + void _updateSearchResultsCount(int itemIndex, int count) { + if (mounted) { + setState(() { + _searchResultsPerItem[itemIndex] = count; + _totalSearchResults = + _searchResultsPerItem.values.fold(0, (sum, count) => sum + count); + if (_currentSearchIndex >= _totalSearchResults && + _totalSearchResults > 0) { + _currentSearchIndex = _totalSearchResults - 1; + } + }); + } + } + @override Widget build(BuildContext context) { return BlocBuilder(builder: (context, state) { @@ -62,12 +121,65 @@ class _CommentaryListState extends State { hintText: 'חפש בתוך המפרשים המוצגים...', prefixIcon: const Icon(Icons.search), suffixIcon: _searchQuery.isNotEmpty - ? IconButton( - icon: const Icon(Icons.close), - onPressed: () { - _searchController.clear(); - setState(() => _searchQuery = ''); - }, + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_totalSearchResults > 1) ...[ + Text( + '${_currentSearchIndex + 1}/$_totalSearchResults', + style: + Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.keyboard_arrow_up), + iconSize: 20, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 24, + minHeight: 24, + ), + onPressed: _currentSearchIndex > 0 + ? () { + setState(() { + _currentSearchIndex--; + }); + _scrollToSearchResult(); + } + : null, + ), + IconButton( + icon: const Icon(Icons.keyboard_arrow_down), + iconSize: 20, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 24, + minHeight: 24, + ), + onPressed: _currentSearchIndex < + _totalSearchResults - 1 + ? () { + setState(() { + _currentSearchIndex++; + }); + _scrollToSearchResult(); + } + : null, + ), + ], + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + _currentSearchIndex = 0; + _totalSearchResults = 0; + _searchResultsPerItem.clear(); + }); + }, + ), + ], ) : null, isDense: true, @@ -76,7 +188,14 @@ class _CommentaryListState extends State { ), ), onChanged: (value) { - setState(() => _searchQuery = value); + setState(() { + _searchQuery = value; + _currentSearchIndex = 0; + if (value.isEmpty) { + _totalSearchResults = 0; + _searchResultsPerItem.clear(); + } + }); }, ), ), @@ -142,6 +261,9 @@ class _CommentaryListState extends State { openBookCallback: widget.openBookCallback, removeNikud: state.removeNikud, searchQuery: _searchQuery, // העברת החיפוש + currentSearchIndex: _getItemSearchIndex(index1), + onSearchResultsCountChanged: (count) => + _updateSearchResultsCount(index1, count), ), ), ), diff --git a/lib/utils/text_manipulation.dart b/lib/utils/text_manipulation.dart index b7bbc03b1..aed7fbcf8 100644 --- a/lib/utils/text_manipulation.dart +++ b/lib/utils/text_manipulation.dart @@ -17,11 +17,37 @@ String removeVolwels(String s) { return s.replaceAll(SearchRegexPatterns.vowelsAndCantillation, ''); } -String highLight(String data, String searchQuery) { - if (searchQuery.isNotEmpty) { - return data.replaceAll(searchQuery, '$searchQuery'); +String highLight(String data, String searchQuery, {int currentIndex = -1}) { + if (searchQuery.isEmpty) return data; + + final regex = RegExp(RegExp.escape(searchQuery), caseSensitive: false); + final matches = regex.allMatches(data).toList(); + + if (matches.isEmpty) return data; + + // אם לא צוין אינדקס נוכחי, נדגיש את כל התוצאות באדום + if (currentIndex == -1) { + return data.replaceAll(regex, '$searchQuery'); + } + + // נדגיש את התוצאה הנוכחית בכחול ואת השאר באדום + String result = data; + int offset = 0; + + for (int i = 0; i < matches.length; i++) { + final match = matches[i]; + final color = i == currentIndex ? 'blue' : 'red'; + final backgroundColor = i == currentIndex ? ' style="background-color: yellow;"' : ''; + final replacement = '${match.group(0)}'; + + final start = match.start + offset; + final end = match.end + offset; + + result = result.substring(0, start) + replacement + result.substring(end); + offset += replacement.length - match.group(0)!.length; } - return data; + + return result; } String getTitleFromPath(String path) { From 9dac3c36ea2971bad1951843a6a12f218089a1c5 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 10 Aug 2025 11:56:55 +0300 Subject: [PATCH 099/197] =?UTF-8?q?=D7=97=D7=9C=D7=A7=D7=95=D7=A7=D7=AA=20?= =?UTF-8?q?=D7=94=D7=9B=D7=A4=D7=AA=D7=95=D7=A8=D7=99=D7=9D=20=D7=9C=D7=A7?= =?UTF-8?q?=D7=91=D7=95=D7=A6=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/library/view/library_browser.dart | 66 +++++++++++++++++---------- lib/tabs/reading_screen.dart | 28 +++++++++--- 2 files changed, 63 insertions(+), 31 deletions(-) diff --git a/lib/library/view/library_browser.dart b/lib/library/view/library_browser.dart index f15591b75..1a17cfdc8 100644 --- a/lib/library/view/library_browser.dart +++ b/lib/library/view/library_browser.dart @@ -92,20 +92,7 @@ class _LibraryBrowserState extends State alignment: Alignment.centerRight, child: Row( children: [ - IconButton( - icon: const Icon(Icons.home), - tooltip: 'חזרה לתיקיה הראשית', - onPressed: () { - setState(() => _depth = 0); - context.read().add(LoadLibrary()); - context - .read() - .librarySearchController - .clear(); - _update(context, state, settingsState); - _refocusSearchBar(selectAll: true); - }, - ), + // קבוצה 1: סינכרון BlocProvider( create: (context) => FileSyncBloc( repository: FileSyncRepository( @@ -116,6 +103,14 @@ class _LibraryBrowserState extends State ), child: const SyncIconButton(), ), + // קו מפריד + Container( + height: 24, + width: 1, + color: Colors.grey.shade400, + margin: const EdgeInsets.symmetric(horizontal: 2), + ), + // קבוצה 2: שולחן עבודה, היסטוריה ומועדפים IconButton( icon: const Icon(Icons.add_to_queue), tooltip: 'החלף שולחן עבודה', @@ -154,17 +149,38 @@ class _LibraryBrowserState extends State ), ], ), - leading: IconButton( - icon: const Icon(Icons.arrow_upward), - tooltip: 'חזרה לתיקיה הקודמת', - onPressed: () { - if (state.currentCategory?.parent != null) { - setState(() => _depth = _depth > 0 ? _depth - 1 : 0); - context.read().add(NavigateUp()); - context.read().add(const SearchBooks()); - _refocusSearchBar(selectAll: true); - } - }, + leadingWidth: 100, + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // קבוצה 1: חזור ובית (צמודים) + IconButton( + icon: const Icon(Icons.arrow_upward), + tooltip: 'חזרה לתיקיה הקודמת', + onPressed: () { + if (state.currentCategory?.parent != null) { + setState(() => _depth = _depth > 0 ? _depth - 1 : 0); + context.read().add(NavigateUp()); + context.read().add(const SearchBooks()); + _refocusSearchBar(selectAll: true); + } + }, + ), + IconButton( + icon: const Icon(Icons.home), + tooltip: 'חזרה לתיקיה הראשית', + onPressed: () { + setState(() => _depth = 0); + context.read().add(LoadLibrary()); + context + .read() + .librarySearchController + .clear(); + _update(context, state, settingsState); + _refocusSearchBar(selectAll: true); + }, + ), + ], ), ), body: Column( diff --git a/lib/tabs/reading_screen.dart b/lib/tabs/reading_screen.dart index 42c29ee53..52ea020eb 100644 --- a/lib/tabs/reading_screen.dart +++ b/lib/tabs/reading_screen.dart @@ -92,27 +92,34 @@ class _ReadingScreenState extends State padding: const EdgeInsets.all(8.0), child: TextButton( onPressed: () { - _showHistoryDialog(context); + _showSaveWorkspaceDialog(context); }, - child: const Text('הצג היסטוריה'), + child: const Text('החלף שולחן עבודה'), ), ), + // קו מפריד + Container( + height: 1, + width: 200, + color: Colors.grey.shade400, + margin: const EdgeInsets.symmetric(vertical: 8), + ), Padding( padding: const EdgeInsets.all(8.0), child: TextButton( onPressed: () { - _showBookmarksDialog(context); + _showHistoryDialog(context); }, - child: const Text('הצג מועדפים'), + child: const Text('הצג היסטוריה'), ), ), Padding( padding: const EdgeInsets.all(8.0), child: TextButton( onPressed: () { - _showSaveWorkspaceDialog(context); + _showBookmarksDialog(context); }, - child: const Text('החלף שולחן עבודה'), + child: const Text('הצג מועדפים'), ), ) ], @@ -156,11 +163,20 @@ class _ReadingScreenState extends State leading: Row( mainAxisSize: MainAxisSize.min, children: [ + // קבוצה 1: שולחן עבודה IconButton( icon: const Icon(Icons.add_to_queue), tooltip: 'החלף שולחן עבודה', onPressed: () => _showSaveWorkspaceDialog(context), ), + // קו מפריד + Container( + height: 24, + width: 1, + color: Colors.grey.shade400, + margin: const EdgeInsets.symmetric(horizontal: 2), + ), + // קבוצה 2: היסטוריה ומועדפים IconButton( icon: const Icon(Icons.history), tooltip: 'הצג היסטוריה', From 2b1172b3a0c75a5ccb6901efc27fc361d13e874f Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 10 Aug 2025 12:05:03 +0300 Subject: [PATCH 100/197] =?UTF-8?q?=D7=94=D7=9E=D7=A8=D7=AA=20=D7=A7=D7=91?= =?UTF-8?q?=D7=A6=D7=99=D7=9D=20=D7=9CUTF-8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fix_encoding.ps1 | 26 ++++++++++++++++++++++++++ installer/otzaria.iss | 6 +++++- installer/otzaria_full.iss | 6 +++++- 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 fix_encoding.ps1 diff --git a/fix_encoding.ps1 b/fix_encoding.ps1 new file mode 100644 index 000000000..6bb301a4c --- /dev/null +++ b/fix_encoding.ps1 @@ -0,0 +1,26 @@ +# Fix encoding for Inno Setup files to properly display Hebrew text +# This script converts the installer files to UTF-8 with BOM + +$files = @( + "installer\otzaria.iss", + "installer\otzaria_full.iss" +) + +foreach ($file in $files) { + if (Test-Path $file) { + Write-Host "Processing $file..." + + # Read the file content + $content = Get-Content -Path $file -Raw -Encoding UTF8 + + # Write it back with UTF-8 BOM + $utf8BOM = New-Object System.Text.UTF8Encoding $true + [System.IO.File]::WriteAllText((Resolve-Path $file), $content, $utf8BOM) + + Write-Host "Fixed encoding for $file" + } else { + Write-Host "File not found: $file" + } +} + +Write-Host "Encoding fix completed. Please rebuild the installer." \ No newline at end of file diff --git a/installer/otzaria.iss b/installer/otzaria.iss index 48f7ba24b..0f68bdc64 100644 --- a/installer/otzaria.iss +++ b/installer/otzaria.iss @@ -1,4 +1,4 @@ -; Script generated by the Inno Setup Script Wizard. +; Script generated by the Inno Setup Script Wizard. ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" @@ -30,6 +30,10 @@ Compression=lzma SolidCompression=yes WizardStyle=modern DisableDirPage=auto +; Force UTF-8 encoding for Hebrew text +CodePage=65001 +; Enable right-to-left reading for Hebrew +RightToLeft=yes [InstallDelete] Type: filesandordirs; Name: "{app}\index"; diff --git a/installer/otzaria_full.iss b/installer/otzaria_full.iss index ca46cd9dd..f66e6654e 100644 --- a/installer/otzaria_full.iss +++ b/installer/otzaria_full.iss @@ -1,4 +1,4 @@ -; Script generated by the Inno Setup Script Wizard. +; Script generated by the Inno Setup Script Wizard. ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" @@ -30,6 +30,10 @@ Compression=lzma SolidCompression=yes WizardStyle=modern DisableDirPage=auto +; Force UTF-8 encoding for Hebrew text +CodePage=65001 +; Enable right-to-left reading for Hebrew +RightToLeft=yes [InstallDelete] Type: filesandordirs; Name: "{app}\אוצריא\בית שני"; From b51c6bbe053815219438a50c2179dbd88a846612 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 10 Aug 2025 12:30:19 +0300 Subject: [PATCH 101/197] =?UTF-8?q?3=20=D7=9E=D7=99=D7=9C=D7=99=D7=9D=20?= =?UTF-8?q?=D7=97=D7=9C=D7=95=D7=A4=D7=99=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/enhanced_search_field.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index f98ea5de3..dd9c7362f 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -1387,7 +1387,7 @@ class _EnhancedSearchFieldState extends State { void _addAlternative(int termIndex) { setState(() { _alternativeControllers.putIfAbsent(termIndex, () => []); - if (_alternativeControllers[termIndex]!.length >= 2) { + if (_alternativeControllers[termIndex]!.length >= 3) { return; } final newIndex = _alternativeControllers[termIndex]!.length; From 24c79e7037cd11ba45da0b93b476751d179e142f Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 10 Aug 2025 13:06:15 +0300 Subject: [PATCH 102/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=91?= =?UTF-8?q?=D7=9E=D7=A8=D7=AA=20=D7=A7=D7=91=D7=A6=D7=99=D7=9D=20=D7=9CUTF?= =?UTF-8?q?-8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- installer/otzaria.iss | 4 ---- installer/otzaria_full.iss | 4 ---- 2 files changed, 8 deletions(-) diff --git a/installer/otzaria.iss b/installer/otzaria.iss index 0f68bdc64..c8ccc9f9d 100644 --- a/installer/otzaria.iss +++ b/installer/otzaria.iss @@ -30,10 +30,6 @@ Compression=lzma SolidCompression=yes WizardStyle=modern DisableDirPage=auto -; Force UTF-8 encoding for Hebrew text -CodePage=65001 -; Enable right-to-left reading for Hebrew -RightToLeft=yes [InstallDelete] Type: filesandordirs; Name: "{app}\index"; diff --git a/installer/otzaria_full.iss b/installer/otzaria_full.iss index f66e6654e..a95145b11 100644 --- a/installer/otzaria_full.iss +++ b/installer/otzaria_full.iss @@ -30,10 +30,6 @@ Compression=lzma SolidCompression=yes WizardStyle=modern DisableDirPage=auto -; Force UTF-8 encoding for Hebrew text -CodePage=65001 -; Enable right-to-left reading for Hebrew -RightToLeft=yes [InstallDelete] Type: filesandordirs; Name: "{app}\אוצריא\בית שני"; From 6c69990e3d7129e4b37a1227bc99f4a7138b4757 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 10 Aug 2025 13:27:19 +0300 Subject: [PATCH 103/197] =?UTF-8?q?=D7=91=D7=99=D7=98=D7=95=D7=9C=20=D7=9E?= =?UTF-8?q?=D7=A8=D7=95=D7=95=D7=97=D7=99=D7=9D=20=D7=91=D7=9B=D7=A8=D7=98?= =?UTF-8?q?=D7=99=D7=A1=D7=99=D7=95=D7=AA=20=D7=94=D7=A6=D7=93=20=D7=91?= =?UTF-8?q?=D7=A1=D7=A4=D7=A8=D7=99=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pdf_book/pdf_book_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pdf_book/pdf_book_screen.dart b/lib/pdf_book/pdf_book_screen.dart index 7c9366546..655efef60 100644 --- a/lib/pdf_book/pdf_book_screen.dart +++ b/lib/pdf_book/pdf_book_screen.dart @@ -550,7 +550,7 @@ class _PdfBookScreenState extends State child: Container( color: Theme.of(context).colorScheme.surface, child: Padding( - padding: const EdgeInsets.fromLTRB(1, 0, 4, 0), + padding: const EdgeInsets.fromLTRB(0, 0, 0, 0), child: Column( children: [ Row( From 7cd32597e1e2147326d2865312abb01a256dcecf Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 10 Aug 2025 13:31:06 +0300 Subject: [PATCH 104/197] =?UTF-8?q?=D7=A2=D7=95=D7=93=20=D7=94=D7=97=D7=9C?= =?UTF-8?q?=D7=A4=D7=95=D7=AA=20=D7=95=D7=AA=D7=99=D7=A7=D7=95=D7=A0=D7=99?= =?UTF-8?q?=D7=9D=20=D7=91=D7=90=D7=99=D7=AA=D7=95=D7=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/utils/text_manipulation.dart | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/lib/utils/text_manipulation.dart b/lib/utils/text_manipulation.dart index aed7fbcf8..4fa4e3d9c 100644 --- a/lib/utils/text_manipulation.dart +++ b/lib/utils/text_manipulation.dart @@ -263,6 +263,10 @@ String replaceParaphrases(String s) { .replaceAll(' הלכ', ' הלכות') .replaceAll(' הלכה', ' הלכות') .replaceAll(' המשנה', ' המשניות') + .replaceAll(' הרב', ' ר') + .replaceAll(' הרב', ' רבי') + .replaceAll(' הרב', ' רבינו') + .replaceAll(' הרב', ' רבנו') .replaceAll(' ויקר', ' ויקרא רבה') .replaceAll(' ויר', ' ויקרא רבה') .replaceAll(' זהח', ' זוהר חדש') @@ -315,6 +319,9 @@ String replaceParaphrases(String s) { .replaceAll(' מדר', ' מדרש') .replaceAll(' מדרש רבא', ' מדרש רבה') .replaceAll(' מדת', ' מדרש תהלים') + .replaceAll(' מהדורא תנינא', ' מהדות') + .replaceAll(' מהדורא', ' מהדורה') + .replaceAll(' מהדורה', ' מהדורא') .replaceAll(' מהרשא', ' חדושי אגדות') .replaceAll(' מהרשא', ' חדושי הלכות') .replaceAll(' מונ', ' מורה נבוכים') @@ -348,7 +355,7 @@ String replaceParaphrases(String s) { .replaceAll(' ספהמצ', ' ספר המצוות') .replaceAll(' ספר המצות', ' ספר המצוות') .replaceAll(' ספרא', ' תורת כהנים') - .replaceAll(' ע"מ', ' עמוד') + .replaceAll(' עמ', ' עמוד') .replaceAll(' עא', ' עמוד א') .replaceAll(' עב', ' עמוד ב') .replaceAll(' עהש', ' ערוך השולחן') @@ -364,12 +371,15 @@ String replaceParaphrases(String s) { .replaceAll(' פירו', ' פירוש') .replaceAll(' פירוש המשנה', ' פירוש המשניות') .replaceAll(' פמג', ' פרי מגדים') + .replaceAll(' פני', ' פני יהושע') .replaceAll(' פסז', ' פסיקתא זוטרתא') .replaceAll(' פסיקתא זוטא', ' פסיקתא זוטרתא') .replaceAll(' פסיקתא רבה', ' פסיקתא רבתי') .replaceAll(' פסר', ' פסיקתא רבתי') .replaceAll(' פעח', ' פרי עץ חיים') .replaceAll(' פרח', ' פרי חדש') + .replaceAll(' פרמג', ' פרי מגדים') + .replaceAll(' פתש', ' פתחי תשובה') .replaceAll(' צפנפ', ' צפנת פענח') .replaceAll(' קדושל', ' קדושת לוי') .replaceAll(' קוא', ' קול אליהו') @@ -381,7 +391,11 @@ String replaceParaphrases(String s) { .replaceAll(' קצשוע', ' קיצור שולחן ערוך') .replaceAll(' קשוע', ' קיצור שולחן ערוך') .replaceAll(' ר חיים', ' הגרח') + .replaceAll(' ר', ' הרב') + .replaceAll(' ר', ' ר') .replaceAll(' ר', ' רבי') + .replaceAll(' ר', ' רבינו') + .replaceAll(' ר', ' רבנו') .replaceAll(' רא בהרמ', ' רבי אברהם בן הרמבם') .replaceAll(' ראבע', ' אבן עזרא') .replaceAll(' ראשיח', ' ראשית חכמה') @@ -390,8 +404,16 @@ String replaceParaphrases(String s) { .replaceAll(' רבי חיים', ' הגרח') .replaceAll(' רבי נחמן', ' מוהרן') .replaceAll(' רבי נתן', ' מוהרנת') + .replaceAll(' רבי', ' הרב') + .replaceAll(' רבי', ' רבינו') + .replaceAll(' רבי', ' רבנו') .replaceAll(' רבינו חיים', ' הגרח') + .replaceAll(' רבינו', ' הרב') + .replaceAll(' רבינו', ' ר') .replaceAll(' רבינו', ' רבי') + .replaceAll(' רבינו', ' רבנו') + .replaceAll(' רבנו', ' הרב') + .replaceAll(' רבנו', ' ר') .replaceAll(' רבנו', ' רבי') .replaceAll(' רבנו', ' רבינו') .replaceAll(' רח', ' רבנו חננאל') @@ -440,6 +462,8 @@ String replaceParaphrases(String s) { .replaceAll(' תנדא', ' תנא דבי אליהו') .replaceAll(' תנדבא', ' תנא דבי אליהו') .replaceAll(' תנח', ' תנחומא') + .replaceAll(' תניינא', ' תנינא') + .replaceAll(' תנינא', ' תניינא') .replaceAll(' תקוז', ' תיקוני זוהר') .replaceAll(' תשו', ' שות') .replaceAll(' תשו', ' תשובה') @@ -453,11 +477,7 @@ String replaceParaphrases(String s) { .replaceAll(' תשובת', ' שות') .replaceAll(' תשובת', ' תשו') .replaceAll(' תשובת', ' תשובה') - .replaceAll(' תשובת', ' תשובות') - .replaceAll('משנב', ' משנה ברורה ') - .replaceAll('פרמג', ' פרי מגדים ') - .replaceAll('פתש', ' פתחי תשובה ') - .replaceAll('שטמק', ' שיטה מקובצת '); + .replaceAll(' תשובת', ' תשובות'); if (s.startsWith("טז")) { s = s.replaceFirst("טז", "טורי זהב"); From 8050af930c1bfc420778d1660df4819c7562ce16 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 10 Aug 2025 13:32:22 +0300 Subject: [PATCH 105/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=94=20=D7=A7?= =?UTF-8?q?=D7=98=D7=A0=D7=94=20=D7=A9=D7=9C=20=D7=A9=D7=95=D7=A8=D7=94=20?= =?UTF-8?q?=D7=97=D7=93=D7=A9=D7=94=20=D7=91=D7=A7=D7=95=D7=91=D7=A5=20?= =?UTF-8?q?=D7=94=D7=98=D7=A7=D7=A1=D7=98=20=D7=A9=D7=9C=20=D7=94=D7=AA?= =?UTF-8?q?=D7=99=D7=A7=D7=95=D7=A0=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/text_book/view/text_book_screen.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index e82f1c206..d76162afd 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -706,7 +706,8 @@ class _TextBookViewerBlocState extends State הטקסט שבו נמצאה הטעות: $selectedText - פירוט הטעות:$detailsSection + פירוט הטעות: + $detailsSection '''; } From 82879ac1666fd58d773ac70e947efbd2efc4e0a8 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 10 Aug 2025 13:48:42 +0300 Subject: [PATCH 106/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=9B?= =?UTF-8?q?=D7=AA=D7=95=D7=91=D7=AA=20=D7=94=D7=9E=D7=99=D7=99=D7=9C=20?= =?UTF-8?q?=D7=A9=D7=9C=D7=A9=20=D7=9C=D7=99=D7=97=D7=AA=20=D7=94=D7=AA?= =?UTF-8?q?=D7=99=D7=A7=D7=95=D7=A0=D7=99=D7=9D=20=D7=91=D7=A8=D7=90=D7=A9?= =?UTF-8?q?=20=D7=A7=D7=95=D7=91=D7=A5=20=D7=94=D7=98=D7=A7=D7=A1=D7=98=20?= =?UTF-8?q?=D7=A9=D7=9E=D7=9B=D7=99=D7=9C=20=D7=90=D7=AA=20=D7=94=D7=AA?= =?UTF-8?q?=D7=99=D7=A7=D7=95=D7=A0=D7=99=D7=9D=20=D7=A9=D7=9C=20=D7=94?= =?UTF-8?q?=D7=9E=D7=A9=D7=AA=D7=9E=D7=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/text_book/view/text_book_screen.dart | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index d76162afd..3b65bc124 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -69,6 +69,7 @@ class _TextBookViewerBlocState extends State late final StreamSubscription _settingsSub; static const String _reportFileName = 'דיווח שגיאות בספרים.txt'; static const String _reportSeparator = '=============================='; + static const String _reportSeparator2 = '------------------------------'; static const String _fallbackMail = 'otzaria.200@gmail.com'; String? encodeQueryParameters(Map params) { @@ -732,6 +733,13 @@ class _TextBookViewerBlocState extends State encoding: utf8, ); + // אם זה קובץ חדש, כתוב את השורה הראשונה עם הוראות השליחה + if (!exists) { + sink.writeln('יש לשלוח קובץ זה למייל: $_fallbackMail'); + sink.writeln(_reportSeparator2); + sink.writeln(''); // שורת רווח + } + // אם יש כבר תוכן קודם בקובץ קיים -> הוסף מפריד לפני הרשומה החדשה if (exists && (await file.length()) > 0) { sink.writeln(''); // שורת רווח @@ -783,7 +791,8 @@ class _TextBookViewerBlocState extends State final message = "הדיווח נשמר בהצלחה לקובץ '$_reportFileName', הנמצא בתיקייה הראשית של אוצריא.\n" - "יש לך כבר $count דיווחים, וכעת תוכל לשלוח את הקובץ למייל: $_fallbackMail!"; + "יש לך כבר $count דיווחים!\n" + "כעת תוכל לשלוח את הקובץ למייל: $_fallbackMail"; ScaffoldMessenger.of(context).showSnackBar( SnackBar( From b777ae80f0e7e7f3f32767b9cc0cd9568a991e14 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 10 Aug 2025 13:57:07 +0300 Subject: [PATCH 107/197] =?UTF-8?q?=D7=A4=D7=99=D7=A9=D7=95=D7=98=20=D7=94?= =?UTF-8?q?=D7=A9=D7=9D=20=D7=A9=D7=9C=20=D7=94=D7=94=D7=92=D7=93=D7=A8?= =?UTF-8?q?=D7=94=20'=D7=A1=D7=A0=D7=9B=D7=A8=D7=95=D7=9F'=20=D7=95=D7=94?= =?UTF-8?q?=D7=91=D7=94=D7=A8=D7=AA=20=D7=94=D7=94=D7=92=D7=93=D7=A8=D7=94?= =?UTF-8?q?=20'=D7=9E=D7=99=D7=A7=D7=95=D7=9D=20=D7=A1=D7=A4=D7=A8=D7=99?= =?UTF-8?q?=20=D7=94=D7=99=D7=91=D7=A8=D7=95=D7=91=D7=95=D7=A7=D7=A1'=20?= =?UTF-8?q?=D7=A2"=D7=99=20=D7=98=D7=95=D7=9C=D7=98=D7=99=D7=A4,=20=D7=95?= =?UTF-8?q?=D7=9B=D7=9F=20=D7=9B=D7=AA=D7=99=D7=91=D7=AA=20=D7=94=D7=9E?= =?UTF-8?q?=D7=99=D7=9C=D7=94=20=D7=92=D7=9D=20=D7=91=D7=A2=D7=91=D7=A8?= =?UTF-8?q?=D7=99=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/settings/settings_screen.dart | 37 +++++++++++++++++-------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index 1fb020ce1..3aec2a9de 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -442,11 +442,11 @@ class _MySettingsScreenState extends State titleTextStyle: const TextStyle(fontSize: 25), children: [ SwitchSettingsTile( - title: 'סינכרון אוטומטי', + title: 'סינכרון הספרייה באופן אוטומטי', leading: Icon(Icons.sync), settingKey: 'key-auto-sync', defaultValue: true, - enabledLabel: 'מאגר הספרים יתעדכן אוטומטית', + enabledLabel: 'מאגר הספרים המובנה יתעדכן אוטומטית מאתר אוצריא', disabledLabel: 'מאגר הספרים לא יתעדכן אוטומטית.', activeColor: Theme.of(context).cardColor, ), @@ -565,21 +565,24 @@ class _MySettingsScreenState extends State } }, ), - SimpleSettingsTile( - title: 'מיקום ספרי HebrewBooks', - subtitle: Settings.getValue( - 'key-hebrew-books-path') ?? - 'לא קיים', - leading: const Icon(Icons.folder), - onTap: () async { - String? path = - await FilePicker.platform.getDirectoryPath(); - if (path != null) { - context - .read() - .add(UpdateHebrewBooksPath(path)); - } - }, + Tooltip( + message: 'במידה וקיימים ברשותכם ספרים ממאגר זה', + child: SimpleSettingsTile( + title: 'מיקום ספרי HebrewBooks (היברובוקס)', + subtitle: Settings.getValue( + 'key-hebrew-books-path') ?? + 'לא קיים', + leading: const Icon(Icons.folder), + onTap: () async { + String? path = + await FilePicker.platform.getDirectoryPath(); + if (path != null) { + context + .read() + .add(UpdateHebrewBooksPath(path)); + } + }, + ), ), ]), SwitchSettingsTile( From fb34a5cc2ec063b9e5ddbc83fcbd80d432dd2e7d Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 10 Aug 2025 15:00:12 +0300 Subject: [PATCH 108/197] =?UTF-8?q?=D7=94=D7=97=D7=9C=D7=A4=D7=AA=20=D7=94?= =?UTF-8?q?=D7=A9=D7=93=D7=94=20=D7=94=D7=90=D7=A4=D7=95=D7=A8=20=D7=91?= =?UTF-8?q?=D7=AA=D7=A6=D7=95=D7=92=D7=AA=20=D7=A7=D7=91=D7=A6=D7=99=20PDF?= =?UTF-8?q?=20=D7=91=D7=A6=D7=91=D7=A2=20=D7=94=D7=A0=D7=91=D7=97=D7=A8=20?= =?UTF-8?q?=D7=91=D7=A1=D7=9B=D7=9E=D7=AA=20=D7=94=D7=A6=D7=91=D7=A2=D7=99?= =?UTF-8?q?=D7=9D.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pdf_book/pdf_book_screen.dart | 1 + lib/pdf_book/pdf_thumbnails_screen.dart | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/pdf_book/pdf_book_screen.dart b/lib/pdf_book/pdf_book_screen.dart index 655efef60..9696c44d6 100644 --- a/lib/pdf_book/pdf_book_screen.dart +++ b/lib/pdf_book/pdf_book_screen.dart @@ -416,6 +416,7 @@ class _PdfBookScreenState extends State passwordProvider: () => passwordDialog(context), controller: widget.tab.pdfViewerController, params: PdfViewerParams( + backgroundColor: Theme.of(context).colorScheme.surface, // צבע רקע המסך, בתצוגת ספרי PDF maxScale: 10, horizontalCacheExtent: 5, verticalCacheExtent: 5, diff --git a/lib/pdf_book/pdf_thumbnails_screen.dart b/lib/pdf_book/pdf_thumbnails_screen.dart index 20b675989..52e0da061 100644 --- a/lib/pdf_book/pdf_thumbnails_screen.dart +++ b/lib/pdf_book/pdf_thumbnails_screen.dart @@ -63,7 +63,7 @@ class _ThumbnailsViewState extends State if (currentPage == null || _lastScrolledPage == currentPage) return; if (!_scrollController.hasClients) return; - const itemExtent = 256.0; // container height + margin + const itemExtent = 266.0; // container height + margin final viewportHeight = _scrollController.position.viewportDimension; final target = itemExtent * (currentPage - 1) - (viewportHeight / 2) + (itemExtent / 2); @@ -79,7 +79,7 @@ class _ThumbnailsViewState extends State Widget build(BuildContext context) { super.build(context); return Container( - color: Colors.grey, + color: Theme.of(context).colorScheme.surface, // צבע הרקע בכרטיסיית 'דפים' בתפריט הצידי child: widget.documentRef == null ? null : PdfDocumentViewBuilder( @@ -109,7 +109,7 @@ class _ThumbnailsViewState extends State widget.controller!.pageNumber == index + 1; return Container( margin: const EdgeInsets.all(8), - height: 240, + height: 250, decoration: isSelected ? BoxDecoration( color: Theme.of(context) From 3db6685304f5f2469b27f07d7bdcbd445eb1f2d1 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 10 Aug 2025 20:29:38 +0300 Subject: [PATCH 109/197] =?UTF-8?q?=D7=9E=D7=97=D7=99=D7=A7=D7=AA=20=D7=A8?= =?UTF-8?q?=D7=95=D7=95=D7=97=D7=99=D7=9D=20=D7=9E=D7=99=D7=95=D7=AA=D7=A8?= =?UTF-8?q?=D7=99=D7=9D=20=D7=91=D7=9E=D7=99=D7=99=D7=9C=20=D7=94=D7=AA?= =?UTF-8?q?=D7=99=D7=A7=D7=95=D7=A0=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/text_book/view/text_book_screen.dart | 26 ++++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index 3b65bc124..4f1724e0e 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -698,19 +698,19 @@ class _TextBookViewerBlocState extends State ) { final detailsSection = errorDetails.isEmpty ? '' : '\n$errorDetails'; - return '''שם הספר: $bookTitle - מיקום: $currentRef - שם הקובץ: ${bookDetails['שם הקובץ']} - נתיב הקובץ: ${bookDetails['נתיב הקובץ']} - תיקיית המקור: ${bookDetails['תיקיית המקור']} - - הטקסט שבו נמצאה הטעות: - $selectedText - - פירוט הטעות: - $detailsSection - - '''; + return ''' +שם הספר: $bookTitle +מיקום: $currentRef +שם הקובץ: ${bookDetails['שם הקובץ']} +נתיב הקובץ: ${bookDetails['נתיב הקובץ']} +תיקיית המקור: ${bookDetails['תיקיית המקור']} + +הטקסט שבו נמצאה הטעות: +$selectedText + +פירוט הטעות: +$detailsSection +'''; } /// שמירת דיווח לקובץ בתיקייה הראשית של הספרייה (libraryPath). From d11747c6ba8ea872195ddf3bfb0b7639b126942d Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 10 Aug 2025 21:38:17 +0300 Subject: [PATCH 110/197] =?UTF-8?q?=D7=94=D7=A6=D7=92=D7=AA=20=D7=A9=D7=9D?= =?UTF-8?q?=20=D7=A9=D7=95=D7=9C=D7=97=D7=9F=20=D7=94=D7=A2=D7=91=D7=95?= =?UTF-8?q?=D7=93=D7=94=20=D7=94=D7=A4=D7=A2=D7=99=D7=9C=20=D7=91=D7=A8?= =?UTF-8?q?=D7=99=D7=97=D7=95=D7=A3=20=D7=A2=D7=9C=20=D7=90=D7=99=D7=99?= =?UTF-8?q?=D7=A7=D7=95=D7=9F=20=D7=A9=D7=95=D7=9C=D7=97=D7=9F=20=D7=94?= =?UTF-8?q?=D7=A2=D7=91=D7=95=D7=93=D7=94=20=D7=91=D7=9E=D7=A1=D7=9A=20?= =?UTF-8?q?=D7=94=D7=A8=D7=90=D7=A9=D7=99=20=D7=91=D7=9C=D7=91=D7=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/library/view/library_browser.dart | 5 +- lib/widgets/workspace_icon_button.dart | 156 +++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 lib/widgets/workspace_icon_button.dart diff --git a/lib/library/view/library_browser.dart b/lib/library/view/library_browser.dart index 1a17cfdc8..3180ddfd5 100644 --- a/lib/library/view/library_browser.dart +++ b/lib/library/view/library_browser.dart @@ -31,6 +31,7 @@ import 'package:otzaria/history/history_dialog.dart'; import 'package:otzaria/history/bloc/history_bloc.dart'; import 'package:otzaria/history/bloc/history_event.dart'; import 'package:otzaria/bookmarks/bookmarks_dialog.dart'; +import 'package:otzaria/widgets/workspace_icon_button.dart'; class LibraryBrowser extends StatefulWidget { const LibraryBrowser({Key? key}) : super(key: key); @@ -111,9 +112,7 @@ class _LibraryBrowserState extends State margin: const EdgeInsets.symmetric(horizontal: 2), ), // קבוצה 2: שולחן עבודה, היסטוריה ומועדפים - IconButton( - icon: const Icon(Icons.add_to_queue), - tooltip: 'החלף שולחן עבודה', + WorkspaceIconButton( onPressed: () => _showSwitchWorkspaceDialog(context), ), diff --git a/lib/widgets/workspace_icon_button.dart b/lib/widgets/workspace_icon_button.dart new file mode 100644 index 000000000..40b229dcb --- /dev/null +++ b/lib/widgets/workspace_icon_button.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:otzaria/workspaces/bloc/workspace_bloc.dart'; +import 'package:otzaria/workspaces/bloc/workspace_state.dart'; +import 'package:otzaria/workspaces/bloc/workspace_event.dart'; + +class WorkspaceIconButton extends StatefulWidget { + final VoidCallback onPressed; + + const WorkspaceIconButton({ + Key? key, + required this.onPressed, + }) : super(key: key); + + @override + State createState() => _WorkspaceIconButtonState(); +} + +class _WorkspaceIconButtonState extends State + with SingleTickerProviderStateMixin { + bool _isHovered = false; + late AnimationController _animationController; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 200), + vsync: this, + ); + _scaleAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeOutCubic, + )); + + // טוען את workspaces כשהwidget נוצר + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + context.read().add(LoadWorkspaces()); + } + }); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + double _calculateTextWidth(String text, TextStyle style) { + final TextPainter textPainter = TextPainter( + text: TextSpan(text: text, style: style), + maxLines: 1, + textDirection: TextDirection.rtl, + ); + textPainter.layout(); + return textPainter.size.width; + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, workspaceState) { + final currentWorkspaceName = workspaceState.workspaces.isNotEmpty && + workspaceState.currentWorkspace != null + ? workspaceState.workspaces[workspaceState.currentWorkspace!].name + : 'ברירת מחדל'; + + return _buildButtonWidget(context, currentWorkspaceName); + }, + ); + } + + Widget _buildButtonWidget(BuildContext context, String workspaceName) { + const textStyle = TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ); + + // חישוב רוחב הטקסט + final textWidth = _calculateTextWidth(workspaceName, textStyle); + + // חישוב הרוחב הכולל: אייקון (20) + רווח (8) + טקסט + padding (24) + final expandedWidth = (20 + 8 + textWidth + 24 + 8).clamp(40.0, 180.0); + + return Tooltip( + message: 'החלף שולחן עבודה', + child: MouseRegion( + onEnter: (_) { + setState(() { + _isHovered = true; + }); + _animationController.forward(); + }, + onExit: (_) { + setState(() { + _isHovered = false; + }); + _animationController.reverse(); + }, + child: AnimatedBuilder( + animation: _scaleAnimation, + builder: (context, child) { + final currentWidth = 48.0 + (expandedWidth - 48.0) * _scaleAnimation.value; + + return Container( + width: currentWidth, + height: 48.0, + decoration: BoxDecoration( + color: _isHovered + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(24.0), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(24.0), + onTap: widget.onPressed, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.add_to_queue), + if (_isHovered && _scaleAnimation.value > 0.3) + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Opacity( + opacity: _scaleAnimation.value, + child: Text( + workspaceName, + style: textStyle, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ); + } +} \ No newline at end of file From 306ab95ebb67450fd2a240c65702d65e9ac84ba8 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 11 Aug 2025 11:33:19 +0300 Subject: [PATCH 111/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=94?= =?UTF-8?q?=D7=97=D7=9C=D7=95=D7=A7=D7=94=20=D7=9C=D7=A7=D7=91=D7=95=D7=A6?= =?UTF-8?q?=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../view/splited_view/simple_book_view.dart | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/lib/text_book/view/splited_view/simple_book_view.dart b/lib/text_book/view/splited_view/simple_book_view.dart index 0cfcea929..8b0fb3917 100644 --- a/lib/text_book/view/splited_view/simple_book_view.dart +++ b/lib/text_book/view/splited_view/simple_book_view.dart @@ -17,7 +17,7 @@ import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:otzaria/models/books.dart'; class SimpleBookView extends StatefulWidget { - SimpleBookView({ + const SimpleBookView({ super.key, required this.data, required this.openBookCallback, @@ -55,7 +55,7 @@ class _SimpleBookViewState extends State { return [ ctx.MenuItem( - label: 'הצג את כל ${groupName}', + label: 'הצג את כל $groupName', icon: groupActive ? Icons.check : null, onSelected: () { final current = List.from(st.activeCommentators); @@ -92,6 +92,8 @@ class _SimpleBookViewState extends State { // 2. זיהוי פרשנים שכבר שויכו לקבוצה final Set alreadyListed = { + ...state.torahShebichtav, + ...state.chazal, ...state.rishonim, ...state.acharonim, ...state.modernCommentators, @@ -132,6 +134,20 @@ class _SimpleBookViewState extends State { }, ), const ctx.MenuDivider(), + // תורה שבכתב + ..._buildGroup('תורה שבכתב', state.torahShebichtav, state), + + // מוסיפים קו הפרדה רק אם יש גם תורה שבכתב וגם חזל + if (state.torahShebichtav.isNotEmpty && state.chazal.isNotEmpty) + const ctx.MenuDivider(), + + // חזל + ..._buildGroup('חז"ל', state.chazal, state), + + // מוסיפים קו הפרדה רק אם יש גם חזל וגם ראשונים + if (state.chazal.isNotEmpty && state.rishonim.isNotEmpty) + const ctx.MenuDivider(), + // ראשונים ..._buildGroup('ראשונים', state.rishonim, state), @@ -151,7 +167,9 @@ class _SimpleBookViewState extends State { ..._buildGroup('מחברי זמננו', state.modernCommentators, state), // הוסף קו הפרדה רק אם יש קבוצות אחרות וגם פרשנים לא-משויכים - if ((state.rishonim.isNotEmpty || + if ((state.torahShebichtav.isNotEmpty || + state.chazal.isNotEmpty || + state.rishonim.isNotEmpty || state.acharonim.isNotEmpty || state.modernCommentators.isNotEmpty) && ungrouped.isNotEmpty) From 87cf8cf9518798dbe51698181b08dc412d11b484 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 13 Aug 2025 11:43:56 +0300 Subject: [PATCH 112/197] =?UTF-8?q?=D7=9E=D7=A0=D7=99=D7=A2=D7=AA=20=D7=9E?= =?UTF-8?q?=D7=97=D7=99=D7=A7=D7=AA=20=D7=90=D7=99=D7=A0=D7=93=D7=A7=D7=A1?= =?UTF-8?q?=20=D7=91=D7=A2=D7=AA=20=D7=94=D7=94=D7=AA=D7=A7=D7=A0=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- installer/otzaria.iss | 1 - installer/otzaria_full.iss | 1 - 2 files changed, 2 deletions(-) diff --git a/installer/otzaria.iss b/installer/otzaria.iss index c8ccc9f9d..0d0a9310b 100644 --- a/installer/otzaria.iss +++ b/installer/otzaria.iss @@ -32,7 +32,6 @@ WizardStyle=modern DisableDirPage=auto [InstallDelete] -Type: filesandordirs; Name: "{app}\index"; Type: filesandordirs; Name: "{app}\default.isar"; [Tasks] diff --git a/installer/otzaria_full.iss b/installer/otzaria_full.iss index a95145b11..1fd8d4d33 100644 --- a/installer/otzaria_full.iss +++ b/installer/otzaria_full.iss @@ -3305,7 +3305,6 @@ Type: filesandordirs; Name: "{app}\אוצריא\שות\ראשונים\רמבם\ Type: filesandordirs; Name: "{app}\אוצריא\תלמוד בבלי\אחרונים\עין איה.txt"; Type: filesandordirs; Name: "{app}\אוצריא\תלמוד בבלי\ראשונים\ראה על ברכות.txt"; Type: filesandordirs; Name: "{app}\אוצריא\תנך\ראשונים\מנחת שי\כתובים\מנחת שי על אסתר.txt"; -Type: filesandordirs; Name: "{app}\index"; Type: filesandordirs; Name: "{app}\default.isar"; [Run] From 97b43e7b6d57d02379fa3ff2af8f49a6381f6eb4 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 13 Aug 2025 12:04:47 +0300 Subject: [PATCH 113/197] =?UTF-8?q?=D7=94=D7=A2=D7=9C=D7=90=D7=AA=20=D7=92?= =?UTF-8?q?=D7=A8=D7=A1=D7=94=20=D7=9C093?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- installer/otzaria.iss | 2 +- installer/otzaria_full.iss | 2 +- pubspec.yaml | 4 +- repomix-new-search.xml | 2934 ------------------------------------ test_search_logic.dart | 97 -- update_version.ps1 | 13 +- version.json | 2 +- 8 files changed, 16 insertions(+), 3042 deletions(-) delete mode 100644 repomix-new-search.xml delete mode 100644 test_search_logic.dart diff --git a/.gitignore b/.gitignore index 217823cb2..b898afa0d 100644 --- a/.gitignore +++ b/.gitignore @@ -60,7 +60,7 @@ android/ build/ fonts/ -installer/otzaria-0.9.1-windows.exe -installer/otzaria-0.9.1-windows-full.exe +installer/otzaria-0.9.3-windows.exe +installer/otzaria-0.9.3-windows-full.exe pubspec.lock flutter/ diff --git a/installer/otzaria.iss b/installer/otzaria.iss index 0d0a9310b..cbad35f97 100644 --- a/installer/otzaria.iss +++ b/installer/otzaria.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.1" +#define MyAppVersion "0.9.3" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/installer/otzaria_full.iss b/installer/otzaria_full.iss index 1fd8d4d33..945b11897 100644 --- a/installer/otzaria_full.iss +++ b/installer/otzaria_full.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.1" +#define MyAppVersion "0.9.3" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/pubspec.yaml b/pubspec.yaml index cc1329b96..352113995 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,7 @@ msix_config: publisher_display_name: sivan22 identity_name: sivan22.Otzaria description: "ספריית ספרים יהודיים עם אפשרויות חיפוש חכם" - msix_version: 0.9.1.0 + msix_version: 0.9.3.0 logo_path: assets/icon/icon.png publisher: CN=sivan22, O=sivan22, C=IL certificate_path: sivan22.pfx @@ -36,7 +36,7 @@ msix_config: # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.9.1 +version: 0.9.3 environment: sdk: ">=3.2.6 <4.0.0" diff --git a/repomix-new-search.xml b/repomix-new-search.xml deleted file mode 100644 index 82c88a470..000000000 --- a/repomix-new-search.xml +++ /dev/null @@ -1,2934 +0,0 @@ -This file is a merged representation of the entire codebase, combined into a single document by Repomix. - - -This section contains a summary of this file. - - -This file contains a packed representation of the entire repository's contents. -It is designed to be easily consumable by AI systems for analysis, code review, -or other automated processes. - - - -The content is organized as follows: -1. This summary section -2. Repository information -3. Directory structure -4. Repository files (if enabled) -5. Multiple file entries, each consisting of: - - File path as an attribute - - Full contents of the file - - - -- This file should be treated as read-only. Any changes should be made to the - original repository files, not this packed version. -- When processing this file, use the file path to distinguish - between different files in the repository. -- Be aware that this file may contain sensitive information. Handle it with - the same level of security as you would the original repository. - - - -- Some files may have been excluded based on .gitignore rules and Repomix's configuration -- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files -- Files matching patterns in .gitignore are excluded -- Files matching default ignore patterns are excluded -- Files are sorted by Git change count (files with more changes are at the bottom) - - - - - -search_engine.dart -src/rust/api/reference_search_engine.dart -src/rust/api/search_engine.dart -src/rust/frb_generated.dart -src/rust/frb_generated.io.dart -src/rust/frb_generated.web.dart - - - -This section contains the contents of the repository's files. - - -library search_engine; - -export 'src/rust/api/search_engine.dart'; -export 'src/rust/api/reference_search_engine.dart'; -export 'src/rust/frb_generated.dart' show RustLib; - - - -// This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.11.1. - -// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import - -import '../frb_generated.dart'; -import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; -import 'search_engine.dart'; - -// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone` - -// Rust type: RustOpaqueMoi>> -abstract class BoxQuery implements RustOpaqueInterface {} - -// Rust type: RustOpaqueMoi> -abstract class Index implements RustOpaqueInterface {} - -// Rust type: RustOpaqueMoi> -abstract class ReferenceSearchEngine implements RustOpaqueInterface { - Future addDocument( - {required BigInt id, - required String title, - required String reference, - required String shortRef, - required BigInt segment, - required bool isPdf, - required String filePath}); - - Future clear(); - - Future commit(); - - Future count({required String query, required bool fuzzy}); - - static Future createSearchQuery( - {required Index index, - required String searchTerm, - required bool fuzzy}) => - RustLib.instance.api - .crateApiReferenceSearchEngineReferenceSearchEngineCreateSearchQuery( - index: index, searchTerm: searchTerm, fuzzy: fuzzy); - - factory ReferenceSearchEngine({required String path}) => RustLib.instance.api - .crateApiReferenceSearchEngineReferenceSearchEngineNew(path: path); - - Future> search( - {required String query, - required int limit, - required bool fuzzy, - required ResultsOrder order}); -} - -class ReferenceSearchResult { - final String title; - final String reference; - final String shortRef; - final BigInt id; - final BigInt segment; - final bool isPdf; - final String filePath; - - const ReferenceSearchResult({ - required this.title, - required this.reference, - required this.shortRef, - required this.id, - required this.segment, - required this.isPdf, - required this.filePath, - }); - - @override - int get hashCode => - title.hashCode ^ - reference.hashCode ^ - shortRef.hashCode ^ - id.hashCode ^ - segment.hashCode ^ - isPdf.hashCode ^ - filePath.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is ReferenceSearchResult && - runtimeType == other.runtimeType && - title == other.title && - reference == other.reference && - shortRef == other.shortRef && - id == other.id && - segment == other.segment && - isPdf == other.isPdf && - filePath == other.filePath; -} - - - -// This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.11.1. - -// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import - -import '../frb_generated.dart'; -import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; -import 'reference_search_engine.dart'; - -// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone` - -// Rust type: RustOpaqueMoi> -abstract class SearchEngine implements RustOpaqueInterface { - Future addDocument( - {required BigInt id, - required String title, - required String reference, - required String topics, - required String text, - required BigInt segment, - required bool isPdf, - required String filePath}); - - Future clear(); - - Future commit(); - - Future count( - {required List regexTerms, - required List facets, - required int slop, - required int maxExpansions}); - - static Future createQuery( - {required Index index, - required List regexTerms, - required List facets, - required int slop, - required int maxExpansions}) => - RustLib.instance.api.crateApiSearchEngineSearchEngineCreateQuery( - index: index, - regexTerms: regexTerms, - facets: facets, - slop: slop, - maxExpansions: maxExpansions); - - factory SearchEngine({required String path}) => - RustLib.instance.api.crateApiSearchEngineSearchEngineNew(path: path); - - Future removeDocumentsByTitle({required String title}); - - Future> search( - {required List regexTerms, - required List facets, - required int limit, - required int slop, - required int maxExpansions, - required ResultsOrder order}); -} - -enum ResultsOrder { - catalogue, - relevance, - ; -} - -class SearchResult { - final String title; - final String reference; - final String text; - final BigInt id; - final BigInt segment; - final bool isPdf; - final String filePath; - - const SearchResult({ - required this.title, - required this.reference, - required this.text, - required this.id, - required this.segment, - required this.isPdf, - required this.filePath, - }); - - @override - int get hashCode => - title.hashCode ^ - reference.hashCode ^ - text.hashCode ^ - id.hashCode ^ - segment.hashCode ^ - isPdf.hashCode ^ - filePath.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SearchResult && - runtimeType == other.runtimeType && - title == other.title && - reference == other.reference && - text == other.text && - id == other.id && - segment == other.segment && - isPdf == other.isPdf && - filePath == other.filePath; -} - - - -// This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.11.1. - -// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field - -import 'api/reference_search_engine.dart'; -import 'api/search_engine.dart'; -import 'dart:async'; -import 'dart:convert'; -import 'frb_generated.dart'; -import 'frb_generated.io.dart' - if (dart.library.js_interop) 'frb_generated.web.dart'; -import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; - -/// Main entrypoint of the Rust API -class RustLib extends BaseEntrypoint { - @internal - static final instance = RustLib._(); - - RustLib._(); - - /// Initialize flutter_rust_bridge - static Future init({ - RustLibApi? api, - BaseHandler? handler, - ExternalLibrary? externalLibrary, - bool forceSameCodegenVersion = true, - }) async { - await instance.initImpl( - api: api, - handler: handler, - externalLibrary: externalLibrary, - forceSameCodegenVersion: forceSameCodegenVersion, - ); - } - - /// Initialize flutter_rust_bridge in mock mode. - /// No libraries for FFI are loaded. - static void initMock({ - required RustLibApi api, - }) { - instance.initMockImpl( - api: api, - ); - } - - /// Dispose flutter_rust_bridge - /// - /// The call to this function is optional, since flutter_rust_bridge (and everything else) - /// is automatically disposed when the app stops. - static void dispose() => instance.disposeImpl(); - - @override - ApiImplConstructor get apiImplConstructor => - RustLibApiImpl.new; - - @override - WireConstructor get wireConstructor => - RustLibWire.fromExternalLibrary; - - @override - Future executeRustInitializers() async {} - - @override - ExternalLibraryLoaderConfig get defaultExternalLibraryLoaderConfig => - kDefaultExternalLibraryLoaderConfig; - - @override - String get codegenVersion => '2.11.1'; - - @override - int get rustContentHash => 271381323; - - static const kDefaultExternalLibraryLoaderConfig = - ExternalLibraryLoaderConfig( - stem: 'search_engine', - ioDirectory: 'rust/target/release/', - webPrefix: 'pkg/', - ); -} - -abstract class RustLibApi extends BaseApi { - Future crateApiReferenceSearchEngineReferenceSearchEngineAddDocument( - {required ReferenceSearchEngine that, - required BigInt id, - required String title, - required String reference, - required String shortRef, - required BigInt segment, - required bool isPdf, - required String filePath}); - - Future crateApiReferenceSearchEngineReferenceSearchEngineClear( - {required ReferenceSearchEngine that}); - - Future crateApiReferenceSearchEngineReferenceSearchEngineCommit( - {required ReferenceSearchEngine that}); - - Future crateApiReferenceSearchEngineReferenceSearchEngineCount( - {required ReferenceSearchEngine that, - required String query, - required bool fuzzy}); - - Future - crateApiReferenceSearchEngineReferenceSearchEngineCreateSearchQuery( - {required Index index, - required String searchTerm, - required bool fuzzy}); - - ReferenceSearchEngine crateApiReferenceSearchEngineReferenceSearchEngineNew( - {required String path}); - - Future> - crateApiReferenceSearchEngineReferenceSearchEngineSearch( - {required ReferenceSearchEngine that, - required String query, - required int limit, - required bool fuzzy, - required ResultsOrder order}); - - Future crateApiSearchEngineSearchEngineAddDocument( - {required SearchEngine that, - required BigInt id, - required String title, - required String reference, - required String topics, - required String text, - required BigInt segment, - required bool isPdf, - required String filePath}); - - Future crateApiSearchEngineSearchEngineClear( - {required SearchEngine that}); - - Future crateApiSearchEngineSearchEngineCommit( - {required SearchEngine that}); - - Future crateApiSearchEngineSearchEngineCount( - {required SearchEngine that, - required List regexTerms, - required List facets, - required int slop, - required int maxExpansions}); - - Future crateApiSearchEngineSearchEngineCreateQuery( - {required Index index, - required List regexTerms, - required List facets, - required int slop, - required int maxExpansions}); - - SearchEngine crateApiSearchEngineSearchEngineNew({required String path}); - - Future crateApiSearchEngineSearchEngineRemoveDocumentsByTitle( - {required SearchEngine that, required String title}); - - Future> crateApiSearchEngineSearchEngineSearch( - {required SearchEngine that, - required List regexTerms, - required List facets, - required int limit, - required int slop, - required int maxExpansions, - required ResultsOrder order}); - - RustArcIncrementStrongCountFnType - get rust_arc_increment_strong_count_BoxQuery; - - RustArcDecrementStrongCountFnType - get rust_arc_decrement_strong_count_BoxQuery; - - CrossPlatformFinalizerArg get rust_arc_decrement_strong_count_BoxQueryPtr; - - RustArcIncrementStrongCountFnType get rust_arc_increment_strong_count_Index; - - RustArcDecrementStrongCountFnType get rust_arc_decrement_strong_count_Index; - - CrossPlatformFinalizerArg get rust_arc_decrement_strong_count_IndexPtr; - - RustArcIncrementStrongCountFnType - get rust_arc_increment_strong_count_ReferenceSearchEngine; - - RustArcDecrementStrongCountFnType - get rust_arc_decrement_strong_count_ReferenceSearchEngine; - - CrossPlatformFinalizerArg - get rust_arc_decrement_strong_count_ReferenceSearchEnginePtr; - - RustArcIncrementStrongCountFnType - get rust_arc_increment_strong_count_SearchEngine; - - RustArcDecrementStrongCountFnType - get rust_arc_decrement_strong_count_SearchEngine; - - CrossPlatformFinalizerArg get rust_arc_decrement_strong_count_SearchEnginePtr; -} - -class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { - RustLibApiImpl({ - required super.handler, - required super.wire, - required super.generalizedFrbRustBinding, - required super.portManager, - }); - - @override - Future crateApiReferenceSearchEngineReferenceSearchEngineAddDocument( - {required ReferenceSearchEngine that, - required BigInt id, - required String title, - required String reference, - required String shortRef, - required BigInt segment, - required bool isPdf, - required String filePath}) { - return handler.executeNormal(NormalTask( - callFfi: (port_) { - final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - that, serializer); - sse_encode_u_64(id, serializer); - sse_encode_String(title, serializer); - sse_encode_String(reference, serializer); - sse_encode_String(shortRef, serializer); - sse_encode_u_64(segment, serializer); - sse_encode_bool(isPdf, serializer); - sse_encode_String(filePath, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 1, port: port_); - }, - codec: SseCodec( - decodeSuccessData: sse_decode_unit, - decodeErrorData: sse_decode_AnyhowException, - ), - constMeta: - kCrateApiReferenceSearchEngineReferenceSearchEngineAddDocumentConstMeta, - argValues: [ - that, - id, - title, - reference, - shortRef, - segment, - isPdf, - filePath - ], - apiImpl: this, - )); - } - - TaskConstMeta - get kCrateApiReferenceSearchEngineReferenceSearchEngineAddDocumentConstMeta => - const TaskConstMeta( - debugName: "ReferenceSearchEngine_add_document", - argNames: [ - "that", - "id", - "title", - "reference", - "shortRef", - "segment", - "isPdf", - "filePath" - ], - ); - - @override - Future crateApiReferenceSearchEngineReferenceSearchEngineClear( - {required ReferenceSearchEngine that}) { - return handler.executeNormal(NormalTask( - callFfi: (port_) { - final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - that, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 2, port: port_); - }, - codec: SseCodec( - decodeSuccessData: sse_decode_unit, - decodeErrorData: sse_decode_AnyhowException, - ), - constMeta: - kCrateApiReferenceSearchEngineReferenceSearchEngineClearConstMeta, - argValues: [that], - apiImpl: this, - )); - } - - TaskConstMeta - get kCrateApiReferenceSearchEngineReferenceSearchEngineClearConstMeta => - const TaskConstMeta( - debugName: "ReferenceSearchEngine_clear", - argNames: ["that"], - ); - - @override - Future crateApiReferenceSearchEngineReferenceSearchEngineCommit( - {required ReferenceSearchEngine that}) { - return handler.executeNormal(NormalTask( - callFfi: (port_) { - final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - that, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 3, port: port_); - }, - codec: SseCodec( - decodeSuccessData: sse_decode_unit, - decodeErrorData: sse_decode_AnyhowException, - ), - constMeta: - kCrateApiReferenceSearchEngineReferenceSearchEngineCommitConstMeta, - argValues: [that], - apiImpl: this, - )); - } - - TaskConstMeta - get kCrateApiReferenceSearchEngineReferenceSearchEngineCommitConstMeta => - const TaskConstMeta( - debugName: "ReferenceSearchEngine_commit", - argNames: ["that"], - ); - - @override - Future crateApiReferenceSearchEngineReferenceSearchEngineCount( - {required ReferenceSearchEngine that, - required String query, - required bool fuzzy}) { - return handler.executeNormal(NormalTask( - callFfi: (port_) { - final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - that, serializer); - sse_encode_String(query, serializer); - sse_encode_bool(fuzzy, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 4, port: port_); - }, - codec: SseCodec( - decodeSuccessData: sse_decode_u_32, - decodeErrorData: sse_decode_AnyhowException, - ), - constMeta: - kCrateApiReferenceSearchEngineReferenceSearchEngineCountConstMeta, - argValues: [that, query, fuzzy], - apiImpl: this, - )); - } - - TaskConstMeta - get kCrateApiReferenceSearchEngineReferenceSearchEngineCountConstMeta => - const TaskConstMeta( - debugName: "ReferenceSearchEngine_count", - argNames: ["that", "query", "fuzzy"], - ); - - @override - Future - crateApiReferenceSearchEngineReferenceSearchEngineCreateSearchQuery( - {required Index index, - required String searchTerm, - required bool fuzzy}) { - return handler.executeNormal(NormalTask( - callFfi: (port_) { - final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - index, serializer); - sse_encode_String(searchTerm, serializer); - sse_encode_bool(fuzzy, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 5, port: port_); - }, - codec: SseCodec( - decodeSuccessData: - sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery, - decodeErrorData: sse_decode_AnyhowException, - ), - constMeta: - kCrateApiReferenceSearchEngineReferenceSearchEngineCreateSearchQueryConstMeta, - argValues: [index, searchTerm, fuzzy], - apiImpl: this, - )); - } - - TaskConstMeta - get kCrateApiReferenceSearchEngineReferenceSearchEngineCreateSearchQueryConstMeta => - const TaskConstMeta( - debugName: "ReferenceSearchEngine_create_search_query", - argNames: ["index", "searchTerm", "fuzzy"], - ); - - @override - ReferenceSearchEngine crateApiReferenceSearchEngineReferenceSearchEngineNew( - {required String path}) { - return handler.executeSync(SyncTask( - callFfi: () { - final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_String(path, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 6)!; - }, - codec: SseCodec( - decodeSuccessData: - sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine, - decodeErrorData: null, - ), - constMeta: - kCrateApiReferenceSearchEngineReferenceSearchEngineNewConstMeta, - argValues: [path], - apiImpl: this, - )); - } - - TaskConstMeta - get kCrateApiReferenceSearchEngineReferenceSearchEngineNewConstMeta => - const TaskConstMeta( - debugName: "ReferenceSearchEngine_new", - argNames: ["path"], - ); - - @override - Future> - crateApiReferenceSearchEngineReferenceSearchEngineSearch( - {required ReferenceSearchEngine that, - required String query, - required int limit, - required bool fuzzy, - required ResultsOrder order}) { - return handler.executeNormal(NormalTask( - callFfi: (port_) { - final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - that, serializer); - sse_encode_String(query, serializer); - sse_encode_u_32(limit, serializer); - sse_encode_bool(fuzzy, serializer); - sse_encode_results_order(order, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 7, port: port_); - }, - codec: SseCodec( - decodeSuccessData: sse_decode_list_reference_search_result, - decodeErrorData: sse_decode_AnyhowException, - ), - constMeta: - kCrateApiReferenceSearchEngineReferenceSearchEngineSearchConstMeta, - argValues: [that, query, limit, fuzzy, order], - apiImpl: this, - )); - } - - TaskConstMeta - get kCrateApiReferenceSearchEngineReferenceSearchEngineSearchConstMeta => - const TaskConstMeta( - debugName: "ReferenceSearchEngine_search", - argNames: ["that", "query", "limit", "fuzzy", "order"], - ); - - @override - Future crateApiSearchEngineSearchEngineAddDocument( - {required SearchEngine that, - required BigInt id, - required String title, - required String reference, - required String topics, - required String text, - required BigInt segment, - required bool isPdf, - required String filePath}) { - return handler.executeNormal(NormalTask( - callFfi: (port_) { - final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - that, serializer); - sse_encode_u_64(id, serializer); - sse_encode_String(title, serializer); - sse_encode_String(reference, serializer); - sse_encode_String(topics, serializer); - sse_encode_String(text, serializer); - sse_encode_u_64(segment, serializer); - sse_encode_bool(isPdf, serializer); - sse_encode_String(filePath, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 8, port: port_); - }, - codec: SseCodec( - decodeSuccessData: sse_decode_unit, - decodeErrorData: sse_decode_AnyhowException, - ), - constMeta: kCrateApiSearchEngineSearchEngineAddDocumentConstMeta, - argValues: [ - that, - id, - title, - reference, - topics, - text, - segment, - isPdf, - filePath - ], - apiImpl: this, - )); - } - - TaskConstMeta get kCrateApiSearchEngineSearchEngineAddDocumentConstMeta => - const TaskConstMeta( - debugName: "SearchEngine_add_document", - argNames: [ - "that", - "id", - "title", - "reference", - "topics", - "text", - "segment", - "isPdf", - "filePath" - ], - ); - - @override - Future crateApiSearchEngineSearchEngineClear( - {required SearchEngine that}) { - return handler.executeNormal(NormalTask( - callFfi: (port_) { - final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - that, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 9, port: port_); - }, - codec: SseCodec( - decodeSuccessData: sse_decode_unit, - decodeErrorData: sse_decode_AnyhowException, - ), - constMeta: kCrateApiSearchEngineSearchEngineClearConstMeta, - argValues: [that], - apiImpl: this, - )); - } - - TaskConstMeta get kCrateApiSearchEngineSearchEngineClearConstMeta => - const TaskConstMeta( - debugName: "SearchEngine_clear", - argNames: ["that"], - ); - - @override - Future crateApiSearchEngineSearchEngineCommit( - {required SearchEngine that}) { - return handler.executeNormal(NormalTask( - callFfi: (port_) { - final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - that, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 10, port: port_); - }, - codec: SseCodec( - decodeSuccessData: sse_decode_unit, - decodeErrorData: sse_decode_AnyhowException, - ), - constMeta: kCrateApiSearchEngineSearchEngineCommitConstMeta, - argValues: [that], - apiImpl: this, - )); - } - - TaskConstMeta get kCrateApiSearchEngineSearchEngineCommitConstMeta => - const TaskConstMeta( - debugName: "SearchEngine_commit", - argNames: ["that"], - ); - - @override - Future crateApiSearchEngineSearchEngineCount( - {required SearchEngine that, - required List regexTerms, - required List facets, - required int slop, - required int maxExpansions}) { - return handler.executeNormal(NormalTask( - callFfi: (port_) { - final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - that, serializer); - sse_encode_list_String(regexTerms, serializer); - sse_encode_list_String(facets, serializer); - sse_encode_u_32(slop, serializer); - sse_encode_u_32(maxExpansions, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 11, port: port_); - }, - codec: SseCodec( - decodeSuccessData: sse_decode_u_32, - decodeErrorData: sse_decode_AnyhowException, - ), - constMeta: kCrateApiSearchEngineSearchEngineCountConstMeta, - argValues: [that, regexTerms, facets, slop, maxExpansions], - apiImpl: this, - )); - } - - TaskConstMeta get kCrateApiSearchEngineSearchEngineCountConstMeta => - const TaskConstMeta( - debugName: "SearchEngine_count", - argNames: ["that", "regexTerms", "facets", "slop", "maxExpansions"], - ); - - @override - Future crateApiSearchEngineSearchEngineCreateQuery( - {required Index index, - required List regexTerms, - required List facets, - required int slop, - required int maxExpansions}) { - return handler.executeNormal(NormalTask( - callFfi: (port_) { - final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - index, serializer); - sse_encode_list_String(regexTerms, serializer); - sse_encode_list_String(facets, serializer); - sse_encode_u_32(slop, serializer); - sse_encode_u_32(maxExpansions, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 12, port: port_); - }, - codec: SseCodec( - decodeSuccessData: - sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery, - decodeErrorData: sse_decode_AnyhowException, - ), - constMeta: kCrateApiSearchEngineSearchEngineCreateQueryConstMeta, - argValues: [index, regexTerms, facets, slop, maxExpansions], - apiImpl: this, - )); - } - - TaskConstMeta get kCrateApiSearchEngineSearchEngineCreateQueryConstMeta => - const TaskConstMeta( - debugName: "SearchEngine_create_query", - argNames: ["index", "regexTerms", "facets", "slop", "maxExpansions"], - ); - - @override - SearchEngine crateApiSearchEngineSearchEngineNew({required String path}) { - return handler.executeSync(SyncTask( - callFfi: () { - final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_String(path, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 13)!; - }, - codec: SseCodec( - decodeSuccessData: - sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine, - decodeErrorData: null, - ), - constMeta: kCrateApiSearchEngineSearchEngineNewConstMeta, - argValues: [path], - apiImpl: this, - )); - } - - TaskConstMeta get kCrateApiSearchEngineSearchEngineNewConstMeta => - const TaskConstMeta( - debugName: "SearchEngine_new", - argNames: ["path"], - ); - - @override - Future crateApiSearchEngineSearchEngineRemoveDocumentsByTitle( - {required SearchEngine that, required String title}) { - return handler.executeNormal(NormalTask( - callFfi: (port_) { - final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - that, serializer); - sse_encode_String(title, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 14, port: port_); - }, - codec: SseCodec( - decodeSuccessData: sse_decode_unit, - decodeErrorData: sse_decode_AnyhowException, - ), - constMeta: - kCrateApiSearchEngineSearchEngineRemoveDocumentsByTitleConstMeta, - argValues: [that, title], - apiImpl: this, - )); - } - - TaskConstMeta - get kCrateApiSearchEngineSearchEngineRemoveDocumentsByTitleConstMeta => - const TaskConstMeta( - debugName: "SearchEngine_remove_documents_by_title", - argNames: ["that", "title"], - ); - - @override - Future> crateApiSearchEngineSearchEngineSearch( - {required SearchEngine that, - required List regexTerms, - required List facets, - required int limit, - required int slop, - required int maxExpansions, - required ResultsOrder order}) { - return handler.executeNormal(NormalTask( - callFfi: (port_) { - final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - that, serializer); - sse_encode_list_String(regexTerms, serializer); - sse_encode_list_String(facets, serializer); - sse_encode_u_32(limit, serializer); - sse_encode_u_32(slop, serializer); - sse_encode_u_32(maxExpansions, serializer); - sse_encode_results_order(order, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 15, port: port_); - }, - codec: SseCodec( - decodeSuccessData: sse_decode_list_search_result, - decodeErrorData: sse_decode_AnyhowException, - ), - constMeta: kCrateApiSearchEngineSearchEngineSearchConstMeta, - argValues: [that, regexTerms, facets, limit, slop, maxExpansions, order], - apiImpl: this, - )); - } - - TaskConstMeta get kCrateApiSearchEngineSearchEngineSearchConstMeta => - const TaskConstMeta( - debugName: "SearchEngine_search", - argNames: [ - "that", - "regexTerms", - "facets", - "limit", - "slop", - "maxExpansions", - "order" - ], - ); - - RustArcIncrementStrongCountFnType - get rust_arc_increment_strong_count_BoxQuery => wire - .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery; - - RustArcDecrementStrongCountFnType - get rust_arc_decrement_strong_count_BoxQuery => wire - .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery; - - RustArcIncrementStrongCountFnType get rust_arc_increment_strong_count_Index => - wire.rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex; - - RustArcDecrementStrongCountFnType get rust_arc_decrement_strong_count_Index => - wire.rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex; - - RustArcIncrementStrongCountFnType - get rust_arc_increment_strong_count_ReferenceSearchEngine => wire - .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine; - - RustArcDecrementStrongCountFnType - get rust_arc_decrement_strong_count_ReferenceSearchEngine => wire - .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine; - - RustArcIncrementStrongCountFnType - get rust_arc_increment_strong_count_SearchEngine => wire - .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine; - - RustArcDecrementStrongCountFnType - get rust_arc_decrement_strong_count_SearchEngine => wire - .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine; - - @protected - AnyhowException dco_decode_AnyhowException(dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return AnyhowException(raw as String); - } - - @protected - BoxQuery - dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return BoxQueryImpl.frbInternalDcoDecode(raw as List); - } - - @protected - ReferenceSearchEngine - dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return ReferenceSearchEngineImpl.frbInternalDcoDecode(raw as List); - } - - @protected - SearchEngine - dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return SearchEngineImpl.frbInternalDcoDecode(raw as List); - } - - @protected - ReferenceSearchEngine - dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return ReferenceSearchEngineImpl.frbInternalDcoDecode(raw as List); - } - - @protected - SearchEngine - dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return SearchEngineImpl.frbInternalDcoDecode(raw as List); - } - - @protected - Index - dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return IndexImpl.frbInternalDcoDecode(raw as List); - } - - @protected - ReferenceSearchEngine - dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return ReferenceSearchEngineImpl.frbInternalDcoDecode(raw as List); - } - - @protected - SearchEngine - dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return SearchEngineImpl.frbInternalDcoDecode(raw as List); - } - - @protected - BoxQuery - dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return BoxQueryImpl.frbInternalDcoDecode(raw as List); - } - - @protected - Index - dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return IndexImpl.frbInternalDcoDecode(raw as List); - } - - @protected - ReferenceSearchEngine - dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return ReferenceSearchEngineImpl.frbInternalDcoDecode(raw as List); - } - - @protected - SearchEngine - dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return SearchEngineImpl.frbInternalDcoDecode(raw as List); - } - - @protected - String dco_decode_String(dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return raw as String; - } - - @protected - bool dco_decode_bool(dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return raw as bool; - } - - @protected - int dco_decode_i_32(dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return raw as int; - } - - @protected - List dco_decode_list_String(dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return (raw as List).map(dco_decode_String).toList(); - } - - @protected - Uint8List dco_decode_list_prim_u_8_strict(dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return raw as Uint8List; - } - - @protected - List dco_decode_list_reference_search_result( - dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return (raw as List) - .map(dco_decode_reference_search_result) - .toList(); - } - - @protected - List dco_decode_list_search_result(dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return (raw as List).map(dco_decode_search_result).toList(); - } - - @protected - ReferenceSearchResult dco_decode_reference_search_result(dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - final arr = raw as List; - if (arr.length != 7) - throw Exception('unexpected arr length: expect 7 but see ${arr.length}'); - return ReferenceSearchResult( - title: dco_decode_String(arr[0]), - reference: dco_decode_String(arr[1]), - shortRef: dco_decode_String(arr[2]), - id: dco_decode_u_64(arr[3]), - segment: dco_decode_u_64(arr[4]), - isPdf: dco_decode_bool(arr[5]), - filePath: dco_decode_String(arr[6]), - ); - } - - @protected - ResultsOrder dco_decode_results_order(dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return ResultsOrder.values[raw as int]; - } - - @protected - SearchResult dco_decode_search_result(dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - final arr = raw as List; - if (arr.length != 7) - throw Exception('unexpected arr length: expect 7 but see ${arr.length}'); - return SearchResult( - title: dco_decode_String(arr[0]), - reference: dco_decode_String(arr[1]), - text: dco_decode_String(arr[2]), - id: dco_decode_u_64(arr[3]), - segment: dco_decode_u_64(arr[4]), - isPdf: dco_decode_bool(arr[5]), - filePath: dco_decode_String(arr[6]), - ); - } - - @protected - int dco_decode_u_32(dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return raw as int; - } - - @protected - BigInt dco_decode_u_64(dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return dcoDecodeU64(raw); - } - - @protected - int dco_decode_u_8(dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return raw as int; - } - - @protected - void dco_decode_unit(dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return; - } - - @protected - BigInt dco_decode_usize(dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - return dcoDecodeU64(raw); - } - - @protected - AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - var inner = sse_decode_String(deserializer); - return AnyhowException(inner); - } - - @protected - BoxQuery - sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return BoxQueryImpl.frbInternalSseDecode( - sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); - } - - @protected - ReferenceSearchEngine - sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return ReferenceSearchEngineImpl.frbInternalSseDecode( - sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); - } - - @protected - SearchEngine - sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return SearchEngineImpl.frbInternalSseDecode( - sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); - } - - @protected - ReferenceSearchEngine - sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return ReferenceSearchEngineImpl.frbInternalSseDecode( - sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); - } - - @protected - SearchEngine - sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return SearchEngineImpl.frbInternalSseDecode( - sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); - } - - @protected - Index - sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return IndexImpl.frbInternalSseDecode( - sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); - } - - @protected - ReferenceSearchEngine - sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return ReferenceSearchEngineImpl.frbInternalSseDecode( - sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); - } - - @protected - SearchEngine - sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return SearchEngineImpl.frbInternalSseDecode( - sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); - } - - @protected - BoxQuery - sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return BoxQueryImpl.frbInternalSseDecode( - sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); - } - - @protected - Index - sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return IndexImpl.frbInternalSseDecode( - sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); - } - - @protected - ReferenceSearchEngine - sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return ReferenceSearchEngineImpl.frbInternalSseDecode( - sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); - } - - @protected - SearchEngine - sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return SearchEngineImpl.frbInternalSseDecode( - sse_decode_usize(deserializer), sse_decode_i_32(deserializer)); - } - - @protected - String sse_decode_String(SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - var inner = sse_decode_list_prim_u_8_strict(deserializer); - return utf8.decoder.convert(inner); - } - - @protected - bool sse_decode_bool(SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return deserializer.buffer.getUint8() != 0; - } - - @protected - int sse_decode_i_32(SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return deserializer.buffer.getInt32(); - } - - @protected - List sse_decode_list_String(SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - - var len_ = sse_decode_i_32(deserializer); - var ans_ = []; - for (var idx_ = 0; idx_ < len_; ++idx_) { - ans_.add(sse_decode_String(deserializer)); - } - return ans_; - } - - @protected - Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - var len_ = sse_decode_i_32(deserializer); - return deserializer.buffer.getUint8List(len_); - } - - @protected - List sse_decode_list_reference_search_result( - SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - - var len_ = sse_decode_i_32(deserializer); - var ans_ = []; - for (var idx_ = 0; idx_ < len_; ++idx_) { - ans_.add(sse_decode_reference_search_result(deserializer)); - } - return ans_; - } - - @protected - List sse_decode_list_search_result( - SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - - var len_ = sse_decode_i_32(deserializer); - var ans_ = []; - for (var idx_ = 0; idx_ < len_; ++idx_) { - ans_.add(sse_decode_search_result(deserializer)); - } - return ans_; - } - - @protected - ReferenceSearchResult sse_decode_reference_search_result( - SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - var var_title = sse_decode_String(deserializer); - var var_reference = sse_decode_String(deserializer); - var var_shortRef = sse_decode_String(deserializer); - var var_id = sse_decode_u_64(deserializer); - var var_segment = sse_decode_u_64(deserializer); - var var_isPdf = sse_decode_bool(deserializer); - var var_filePath = sse_decode_String(deserializer); - return ReferenceSearchResult( - title: var_title, - reference: var_reference, - shortRef: var_shortRef, - id: var_id, - segment: var_segment, - isPdf: var_isPdf, - filePath: var_filePath); - } - - @protected - ResultsOrder sse_decode_results_order(SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - var inner = sse_decode_i_32(deserializer); - return ResultsOrder.values[inner]; - } - - @protected - SearchResult sse_decode_search_result(SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - var var_title = sse_decode_String(deserializer); - var var_reference = sse_decode_String(deserializer); - var var_text = sse_decode_String(deserializer); - var var_id = sse_decode_u_64(deserializer); - var var_segment = sse_decode_u_64(deserializer); - var var_isPdf = sse_decode_bool(deserializer); - var var_filePath = sse_decode_String(deserializer); - return SearchResult( - title: var_title, - reference: var_reference, - text: var_text, - id: var_id, - segment: var_segment, - isPdf: var_isPdf, - filePath: var_filePath); - } - - @protected - int sse_decode_u_32(SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return deserializer.buffer.getUint32(); - } - - @protected - BigInt sse_decode_u_64(SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return deserializer.buffer.getBigUint64(); - } - - @protected - int sse_decode_u_8(SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return deserializer.buffer.getUint8(); - } - - @protected - void sse_decode_unit(SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - } - - @protected - BigInt sse_decode_usize(SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return deserializer.buffer.getBigUint64(); - } - - @protected - void sse_encode_AnyhowException( - AnyhowException self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_String(self.message, serializer); - } - - @protected - void - sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - BoxQuery self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_usize( - (self as BoxQueryImpl).frbInternalSseEncode(move: true), serializer); - } - - @protected - void - sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - ReferenceSearchEngine self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_usize( - (self as ReferenceSearchEngineImpl).frbInternalSseEncode(move: true), - serializer); - } - - @protected - void - sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SearchEngine self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_usize( - (self as SearchEngineImpl).frbInternalSseEncode(move: true), - serializer); - } - - @protected - void - sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - ReferenceSearchEngine self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_usize( - (self as ReferenceSearchEngineImpl).frbInternalSseEncode(move: false), - serializer); - } - - @protected - void - sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SearchEngine self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_usize( - (self as SearchEngineImpl).frbInternalSseEncode(move: false), - serializer); - } - - @protected - void - sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - Index self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_usize( - (self as IndexImpl).frbInternalSseEncode(move: false), serializer); - } - - @protected - void - sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - ReferenceSearchEngine self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_usize( - (self as ReferenceSearchEngineImpl).frbInternalSseEncode(move: false), - serializer); - } - - @protected - void - sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SearchEngine self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_usize( - (self as SearchEngineImpl).frbInternalSseEncode(move: false), - serializer); - } - - @protected - void - sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - BoxQuery self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_usize( - (self as BoxQueryImpl).frbInternalSseEncode(move: null), serializer); - } - - @protected - void - sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - Index self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_usize( - (self as IndexImpl).frbInternalSseEncode(move: null), serializer); - } - - @protected - void - sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - ReferenceSearchEngine self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_usize( - (self as ReferenceSearchEngineImpl).frbInternalSseEncode(move: null), - serializer); - } - - @protected - void - sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SearchEngine self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_usize( - (self as SearchEngineImpl).frbInternalSseEncode(move: null), - serializer); - } - - @protected - void sse_encode_String(String self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_list_prim_u_8_strict(utf8.encoder.convert(self), serializer); - } - - @protected - void sse_encode_bool(bool self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - serializer.buffer.putUint8(self ? 1 : 0); - } - - @protected - void sse_encode_i_32(int self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - serializer.buffer.putInt32(self); - } - - @protected - void sse_encode_list_String(List self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_i_32(self.length, serializer); - for (final item in self) { - sse_encode_String(item, serializer); - } - } - - @protected - void sse_encode_list_prim_u_8_strict( - Uint8List self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_i_32(self.length, serializer); - serializer.buffer.putUint8List(self); - } - - @protected - void sse_encode_list_reference_search_result( - List self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_i_32(self.length, serializer); - for (final item in self) { - sse_encode_reference_search_result(item, serializer); - } - } - - @protected - void sse_encode_list_search_result( - List self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_i_32(self.length, serializer); - for (final item in self) { - sse_encode_search_result(item, serializer); - } - } - - @protected - void sse_encode_reference_search_result( - ReferenceSearchResult self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_String(self.title, serializer); - sse_encode_String(self.reference, serializer); - sse_encode_String(self.shortRef, serializer); - sse_encode_u_64(self.id, serializer); - sse_encode_u_64(self.segment, serializer); - sse_encode_bool(self.isPdf, serializer); - sse_encode_String(self.filePath, serializer); - } - - @protected - void sse_encode_results_order(ResultsOrder self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_i_32(self.index, serializer); - } - - @protected - void sse_encode_search_result(SearchResult self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_String(self.title, serializer); - sse_encode_String(self.reference, serializer); - sse_encode_String(self.text, serializer); - sse_encode_u_64(self.id, serializer); - sse_encode_u_64(self.segment, serializer); - sse_encode_bool(self.isPdf, serializer); - sse_encode_String(self.filePath, serializer); - } - - @protected - void sse_encode_u_32(int self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - serializer.buffer.putUint32(self); - } - - @protected - void sse_encode_u_64(BigInt self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - serializer.buffer.putBigUint64(self); - } - - @protected - void sse_encode_u_8(int self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - serializer.buffer.putUint8(self); - } - - @protected - void sse_encode_unit(void self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - } - - @protected - void sse_encode_usize(BigInt self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - serializer.buffer.putBigUint64(self); - } -} - -@sealed -class BoxQueryImpl extends RustOpaque implements BoxQuery { - // Not to be used by end users - BoxQueryImpl.frbInternalDcoDecode(List wire) - : super.frbInternalDcoDecode(wire, _kStaticData); - - // Not to be used by end users - BoxQueryImpl.frbInternalSseDecode(BigInt ptr, int externalSizeOnNative) - : super.frbInternalSseDecode(ptr, externalSizeOnNative, _kStaticData); - - static final _kStaticData = RustArcStaticData( - rustArcIncrementStrongCount: - RustLib.instance.api.rust_arc_increment_strong_count_BoxQuery, - rustArcDecrementStrongCount: - RustLib.instance.api.rust_arc_decrement_strong_count_BoxQuery, - rustArcDecrementStrongCountPtr: - RustLib.instance.api.rust_arc_decrement_strong_count_BoxQueryPtr, - ); -} - -@sealed -class IndexImpl extends RustOpaque implements Index { - // Not to be used by end users - IndexImpl.frbInternalDcoDecode(List wire) - : super.frbInternalDcoDecode(wire, _kStaticData); - - // Not to be used by end users - IndexImpl.frbInternalSseDecode(BigInt ptr, int externalSizeOnNative) - : super.frbInternalSseDecode(ptr, externalSizeOnNative, _kStaticData); - - static final _kStaticData = RustArcStaticData( - rustArcIncrementStrongCount: - RustLib.instance.api.rust_arc_increment_strong_count_Index, - rustArcDecrementStrongCount: - RustLib.instance.api.rust_arc_decrement_strong_count_Index, - rustArcDecrementStrongCountPtr: - RustLib.instance.api.rust_arc_decrement_strong_count_IndexPtr, - ); -} - -@sealed -class ReferenceSearchEngineImpl extends RustOpaque - implements ReferenceSearchEngine { - // Not to be used by end users - ReferenceSearchEngineImpl.frbInternalDcoDecode(List wire) - : super.frbInternalDcoDecode(wire, _kStaticData); - - // Not to be used by end users - ReferenceSearchEngineImpl.frbInternalSseDecode( - BigInt ptr, int externalSizeOnNative) - : super.frbInternalSseDecode(ptr, externalSizeOnNative, _kStaticData); - - static final _kStaticData = RustArcStaticData( - rustArcIncrementStrongCount: RustLib - .instance.api.rust_arc_increment_strong_count_ReferenceSearchEngine, - rustArcDecrementStrongCount: RustLib - .instance.api.rust_arc_decrement_strong_count_ReferenceSearchEngine, - rustArcDecrementStrongCountPtr: RustLib - .instance.api.rust_arc_decrement_strong_count_ReferenceSearchEnginePtr, - ); - - Future addDocument( - {required BigInt id, - required String title, - required String reference, - required String shortRef, - required BigInt segment, - required bool isPdf, - required String filePath}) => - RustLib.instance.api - .crateApiReferenceSearchEngineReferenceSearchEngineAddDocument( - that: this, - id: id, - title: title, - reference: reference, - shortRef: shortRef, - segment: segment, - isPdf: isPdf, - filePath: filePath); - - Future clear() => RustLib.instance.api - .crateApiReferenceSearchEngineReferenceSearchEngineClear( - that: this, - ); - - Future commit() => RustLib.instance.api - .crateApiReferenceSearchEngineReferenceSearchEngineCommit( - that: this, - ); - - Future count({required String query, required bool fuzzy}) => - RustLib.instance.api - .crateApiReferenceSearchEngineReferenceSearchEngineCount( - that: this, query: query, fuzzy: fuzzy); - - Future> search( - {required String query, - required int limit, - required bool fuzzy, - required ResultsOrder order}) => - RustLib.instance.api - .crateApiReferenceSearchEngineReferenceSearchEngineSearch( - that: this, - query: query, - limit: limit, - fuzzy: fuzzy, - order: order); -} - -@sealed -class SearchEngineImpl extends RustOpaque implements SearchEngine { - // Not to be used by end users - SearchEngineImpl.frbInternalDcoDecode(List wire) - : super.frbInternalDcoDecode(wire, _kStaticData); - - // Not to be used by end users - SearchEngineImpl.frbInternalSseDecode(BigInt ptr, int externalSizeOnNative) - : super.frbInternalSseDecode(ptr, externalSizeOnNative, _kStaticData); - - static final _kStaticData = RustArcStaticData( - rustArcIncrementStrongCount: - RustLib.instance.api.rust_arc_increment_strong_count_SearchEngine, - rustArcDecrementStrongCount: - RustLib.instance.api.rust_arc_decrement_strong_count_SearchEngine, - rustArcDecrementStrongCountPtr: - RustLib.instance.api.rust_arc_decrement_strong_count_SearchEnginePtr, - ); - - Future addDocument( - {required BigInt id, - required String title, - required String reference, - required String topics, - required String text, - required BigInt segment, - required bool isPdf, - required String filePath}) => - RustLib.instance.api.crateApiSearchEngineSearchEngineAddDocument( - that: this, - id: id, - title: title, - reference: reference, - topics: topics, - text: text, - segment: segment, - isPdf: isPdf, - filePath: filePath); - - Future clear() => - RustLib.instance.api.crateApiSearchEngineSearchEngineClear( - that: this, - ); - - Future commit() => - RustLib.instance.api.crateApiSearchEngineSearchEngineCommit( - that: this, - ); - - Future count( - {required List regexTerms, - required List facets, - required int slop, - required int maxExpansions}) => - RustLib.instance.api.crateApiSearchEngineSearchEngineCount( - that: this, - regexTerms: regexTerms, - facets: facets, - slop: slop, - maxExpansions: maxExpansions); - - Future removeDocumentsByTitle({required String title}) => - RustLib.instance.api - .crateApiSearchEngineSearchEngineRemoveDocumentsByTitle( - that: this, title: title); - - Future> search( - {required List regexTerms, - required List facets, - required int limit, - required int slop, - required int maxExpansions, - required ResultsOrder order}) => - RustLib.instance.api.crateApiSearchEngineSearchEngineSearch( - that: this, - regexTerms: regexTerms, - facets: facets, - limit: limit, - slop: slop, - maxExpansions: maxExpansions, - order: order); -} - - - -// This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.11.1. - -// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field - -import 'api/reference_search_engine.dart'; -import 'api/search_engine.dart'; -import 'dart:async'; -import 'dart:convert'; -import 'dart:ffi' as ffi; -import 'frb_generated.dart'; -import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart'; - -abstract class RustLibApiImplPlatform extends BaseApiImpl { - RustLibApiImplPlatform({ - required super.handler, - required super.wire, - required super.generalizedFrbRustBinding, - required super.portManager, - }); - - CrossPlatformFinalizerArg get rust_arc_decrement_strong_count_BoxQueryPtr => wire - ._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQueryPtr; - - CrossPlatformFinalizerArg get rust_arc_decrement_strong_count_IndexPtr => wire - ._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndexPtr; - - CrossPlatformFinalizerArg - get rust_arc_decrement_strong_count_ReferenceSearchEnginePtr => wire - ._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEnginePtr; - - CrossPlatformFinalizerArg - get rust_arc_decrement_strong_count_SearchEnginePtr => wire - ._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEnginePtr; - - @protected - AnyhowException dco_decode_AnyhowException(dynamic raw); - - @protected - BoxQuery - dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - dynamic raw); - - @protected - ReferenceSearchEngine - dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - dynamic raw); - - @protected - SearchEngine - dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - dynamic raw); - - @protected - ReferenceSearchEngine - dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - dynamic raw); - - @protected - SearchEngine - dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - dynamic raw); - - @protected - Index - dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - dynamic raw); - - @protected - ReferenceSearchEngine - dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - dynamic raw); - - @protected - SearchEngine - dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - dynamic raw); - - @protected - BoxQuery - dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - dynamic raw); - - @protected - Index - dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - dynamic raw); - - @protected - ReferenceSearchEngine - dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - dynamic raw); - - @protected - SearchEngine - dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - dynamic raw); - - @protected - String dco_decode_String(dynamic raw); - - @protected - bool dco_decode_bool(dynamic raw); - - @protected - int dco_decode_i_32(dynamic raw); - - @protected - List dco_decode_list_String(dynamic raw); - - @protected - Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); - - @protected - List dco_decode_list_reference_search_result( - dynamic raw); - - @protected - List dco_decode_list_search_result(dynamic raw); - - @protected - ReferenceSearchResult dco_decode_reference_search_result(dynamic raw); - - @protected - ResultsOrder dco_decode_results_order(dynamic raw); - - @protected - SearchResult dco_decode_search_result(dynamic raw); - - @protected - int dco_decode_u_32(dynamic raw); - - @protected - BigInt dco_decode_u_64(dynamic raw); - - @protected - int dco_decode_u_8(dynamic raw); - - @protected - void dco_decode_unit(dynamic raw); - - @protected - BigInt dco_decode_usize(dynamic raw); - - @protected - AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer); - - @protected - BoxQuery - sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - SseDeserializer deserializer); - - @protected - ReferenceSearchEngine - sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - SseDeserializer deserializer); - - @protected - SearchEngine - sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SseDeserializer deserializer); - - @protected - ReferenceSearchEngine - sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - SseDeserializer deserializer); - - @protected - SearchEngine - sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SseDeserializer deserializer); - - @protected - Index - sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - SseDeserializer deserializer); - - @protected - ReferenceSearchEngine - sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - SseDeserializer deserializer); - - @protected - SearchEngine - sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SseDeserializer deserializer); - - @protected - BoxQuery - sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - SseDeserializer deserializer); - - @protected - Index - sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - SseDeserializer deserializer); - - @protected - ReferenceSearchEngine - sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - SseDeserializer deserializer); - - @protected - SearchEngine - sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SseDeserializer deserializer); - - @protected - String sse_decode_String(SseDeserializer deserializer); - - @protected - bool sse_decode_bool(SseDeserializer deserializer); - - @protected - int sse_decode_i_32(SseDeserializer deserializer); - - @protected - List sse_decode_list_String(SseDeserializer deserializer); - - @protected - Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); - - @protected - List sse_decode_list_reference_search_result( - SseDeserializer deserializer); - - @protected - List sse_decode_list_search_result( - SseDeserializer deserializer); - - @protected - ReferenceSearchResult sse_decode_reference_search_result( - SseDeserializer deserializer); - - @protected - ResultsOrder sse_decode_results_order(SseDeserializer deserializer); - - @protected - SearchResult sse_decode_search_result(SseDeserializer deserializer); - - @protected - int sse_decode_u_32(SseDeserializer deserializer); - - @protected - BigInt sse_decode_u_64(SseDeserializer deserializer); - - @protected - int sse_decode_u_8(SseDeserializer deserializer); - - @protected - void sse_decode_unit(SseDeserializer deserializer); - - @protected - BigInt sse_decode_usize(SseDeserializer deserializer); - - @protected - void sse_encode_AnyhowException( - AnyhowException self, SseSerializer serializer); - - @protected - void - sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - BoxQuery self, SseSerializer serializer); - - @protected - void - sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - ReferenceSearchEngine self, SseSerializer serializer); - - @protected - void - sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SearchEngine self, SseSerializer serializer); - - @protected - void - sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - ReferenceSearchEngine self, SseSerializer serializer); - - @protected - void - sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SearchEngine self, SseSerializer serializer); - - @protected - void - sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - Index self, SseSerializer serializer); - - @protected - void - sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - ReferenceSearchEngine self, SseSerializer serializer); - - @protected - void - sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SearchEngine self, SseSerializer serializer); - - @protected - void - sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - BoxQuery self, SseSerializer serializer); - - @protected - void - sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - Index self, SseSerializer serializer); - - @protected - void - sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - ReferenceSearchEngine self, SseSerializer serializer); - - @protected - void - sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SearchEngine self, SseSerializer serializer); - - @protected - void sse_encode_String(String self, SseSerializer serializer); - - @protected - void sse_encode_bool(bool self, SseSerializer serializer); - - @protected - void sse_encode_i_32(int self, SseSerializer serializer); - - @protected - void sse_encode_list_String(List self, SseSerializer serializer); - - @protected - void sse_encode_list_prim_u_8_strict( - Uint8List self, SseSerializer serializer); - - @protected - void sse_encode_list_reference_search_result( - List self, SseSerializer serializer); - - @protected - void sse_encode_list_search_result( - List self, SseSerializer serializer); - - @protected - void sse_encode_reference_search_result( - ReferenceSearchResult self, SseSerializer serializer); - - @protected - void sse_encode_results_order(ResultsOrder self, SseSerializer serializer); - - @protected - void sse_encode_search_result(SearchResult self, SseSerializer serializer); - - @protected - void sse_encode_u_32(int self, SseSerializer serializer); - - @protected - void sse_encode_u_64(BigInt self, SseSerializer serializer); - - @protected - void sse_encode_u_8(int self, SseSerializer serializer); - - @protected - void sse_encode_unit(void self, SseSerializer serializer); - - @protected - void sse_encode_usize(BigInt self, SseSerializer serializer); -} - -// Section: wire_class - -class RustLibWire implements BaseWire { - factory RustLibWire.fromExternalLibrary(ExternalLibrary lib) => - RustLibWire(lib.ffiDynamicLibrary); - - /// Holds the symbol lookup function. - final ffi.Pointer Function(String symbolName) - _lookup; - - /// The symbols are looked up in [dynamicLibrary]. - RustLibWire(ffi.DynamicLibrary dynamicLibrary) - : _lookup = dynamicLibrary.lookup; - - void - rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - ffi.Pointer ptr, - ) { - return _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - ptr, - ); - } - - late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQueryPtr = - _lookup)>>( - 'frbgen_search_engine_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery'); - late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery = - _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQueryPtr - .asFunction)>(); - - void - rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - ffi.Pointer ptr, - ) { - return _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - ptr, - ); - } - - late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQueryPtr = - _lookup)>>( - 'frbgen_search_engine_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery'); - late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery = - _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQueryPtr - .asFunction)>(); - - void - rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - ffi.Pointer ptr, - ) { - return _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - ptr, - ); - } - - late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndexPtr = - _lookup)>>( - 'frbgen_search_engine_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex'); - late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex = - _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndexPtr - .asFunction)>(); - - void - rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - ffi.Pointer ptr, - ) { - return _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - ptr, - ); - } - - late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndexPtr = - _lookup)>>( - 'frbgen_search_engine_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex'); - late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex = - _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndexPtr - .asFunction)>(); - - void - rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - ffi.Pointer ptr, - ) { - return _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - ptr, - ); - } - - late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEnginePtr = - _lookup)>>( - 'frbgen_search_engine_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine'); - late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine = - _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEnginePtr - .asFunction)>(); - - void - rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - ffi.Pointer ptr, - ) { - return _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - ptr, - ); - } - - late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEnginePtr = - _lookup)>>( - 'frbgen_search_engine_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine'); - late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine = - _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEnginePtr - .asFunction)>(); - - void - rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - ffi.Pointer ptr, - ) { - return _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - ptr, - ); - } - - late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEnginePtr = - _lookup)>>( - 'frbgen_search_engine_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine'); - late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine = - _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEnginePtr - .asFunction)>(); - - void - rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - ffi.Pointer ptr, - ) { - return _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - ptr, - ); - } - - late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEnginePtr = - _lookup)>>( - 'frbgen_search_engine_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine'); - late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine = - _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEnginePtr - .asFunction)>(); -} - - - -// This file is automatically generated, so please do not edit it. -// @generated by `flutter_rust_bridge`@ 2.11.1. - -// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field - -// Static analysis wrongly picks the IO variant, thus ignore this -// ignore_for_file: argument_type_not_assignable - -import 'api/reference_search_engine.dart'; -import 'api/search_engine.dart'; -import 'dart:async'; -import 'dart:convert'; -import 'frb_generated.dart'; -import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart'; - -abstract class RustLibApiImplPlatform extends BaseApiImpl { - RustLibApiImplPlatform({ - required super.handler, - required super.wire, - required super.generalizedFrbRustBinding, - required super.portManager, - }); - - CrossPlatformFinalizerArg get rust_arc_decrement_strong_count_BoxQueryPtr => wire - .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery; - - CrossPlatformFinalizerArg get rust_arc_decrement_strong_count_IndexPtr => wire - .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex; - - CrossPlatformFinalizerArg - get rust_arc_decrement_strong_count_ReferenceSearchEnginePtr => wire - .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine; - - CrossPlatformFinalizerArg - get rust_arc_decrement_strong_count_SearchEnginePtr => wire - .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine; - - @protected - AnyhowException dco_decode_AnyhowException(dynamic raw); - - @protected - BoxQuery - dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - dynamic raw); - - @protected - ReferenceSearchEngine - dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - dynamic raw); - - @protected - SearchEngine - dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - dynamic raw); - - @protected - ReferenceSearchEngine - dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - dynamic raw); - - @protected - SearchEngine - dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - dynamic raw); - - @protected - Index - dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - dynamic raw); - - @protected - ReferenceSearchEngine - dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - dynamic raw); - - @protected - SearchEngine - dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - dynamic raw); - - @protected - BoxQuery - dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - dynamic raw); - - @protected - Index - dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - dynamic raw); - - @protected - ReferenceSearchEngine - dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - dynamic raw); - - @protected - SearchEngine - dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - dynamic raw); - - @protected - String dco_decode_String(dynamic raw); - - @protected - bool dco_decode_bool(dynamic raw); - - @protected - int dco_decode_i_32(dynamic raw); - - @protected - List dco_decode_list_String(dynamic raw); - - @protected - Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); - - @protected - List dco_decode_list_reference_search_result( - dynamic raw); - - @protected - List dco_decode_list_search_result(dynamic raw); - - @protected - ReferenceSearchResult dco_decode_reference_search_result(dynamic raw); - - @protected - ResultsOrder dco_decode_results_order(dynamic raw); - - @protected - SearchResult dco_decode_search_result(dynamic raw); - - @protected - int dco_decode_u_32(dynamic raw); - - @protected - BigInt dco_decode_u_64(dynamic raw); - - @protected - int dco_decode_u_8(dynamic raw); - - @protected - void dco_decode_unit(dynamic raw); - - @protected - BigInt dco_decode_usize(dynamic raw); - - @protected - AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer); - - @protected - BoxQuery - sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - SseDeserializer deserializer); - - @protected - ReferenceSearchEngine - sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - SseDeserializer deserializer); - - @protected - SearchEngine - sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SseDeserializer deserializer); - - @protected - ReferenceSearchEngine - sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - SseDeserializer deserializer); - - @protected - SearchEngine - sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SseDeserializer deserializer); - - @protected - Index - sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - SseDeserializer deserializer); - - @protected - ReferenceSearchEngine - sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - SseDeserializer deserializer); - - @protected - SearchEngine - sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SseDeserializer deserializer); - - @protected - BoxQuery - sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - SseDeserializer deserializer); - - @protected - Index - sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - SseDeserializer deserializer); - - @protected - ReferenceSearchEngine - sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - SseDeserializer deserializer); - - @protected - SearchEngine - sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SseDeserializer deserializer); - - @protected - String sse_decode_String(SseDeserializer deserializer); - - @protected - bool sse_decode_bool(SseDeserializer deserializer); - - @protected - int sse_decode_i_32(SseDeserializer deserializer); - - @protected - List sse_decode_list_String(SseDeserializer deserializer); - - @protected - Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); - - @protected - List sse_decode_list_reference_search_result( - SseDeserializer deserializer); - - @protected - List sse_decode_list_search_result( - SseDeserializer deserializer); - - @protected - ReferenceSearchResult sse_decode_reference_search_result( - SseDeserializer deserializer); - - @protected - ResultsOrder sse_decode_results_order(SseDeserializer deserializer); - - @protected - SearchResult sse_decode_search_result(SseDeserializer deserializer); - - @protected - int sse_decode_u_32(SseDeserializer deserializer); - - @protected - BigInt sse_decode_u_64(SseDeserializer deserializer); - - @protected - int sse_decode_u_8(SseDeserializer deserializer); - - @protected - void sse_decode_unit(SseDeserializer deserializer); - - @protected - BigInt sse_decode_usize(SseDeserializer deserializer); - - @protected - void sse_encode_AnyhowException( - AnyhowException self, SseSerializer serializer); - - @protected - void - sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - BoxQuery self, SseSerializer serializer); - - @protected - void - sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - ReferenceSearchEngine self, SseSerializer serializer); - - @protected - void - sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SearchEngine self, SseSerializer serializer); - - @protected - void - sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - ReferenceSearchEngine self, SseSerializer serializer); - - @protected - void - sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SearchEngine self, SseSerializer serializer); - - @protected - void - sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - Index self, SseSerializer serializer); - - @protected - void - sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - ReferenceSearchEngine self, SseSerializer serializer); - - @protected - void - sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SearchEngine self, SseSerializer serializer); - - @protected - void - sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - BoxQuery self, SseSerializer serializer); - - @protected - void - sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - Index self, SseSerializer serializer); - - @protected - void - sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - ReferenceSearchEngine self, SseSerializer serializer); - - @protected - void - sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - SearchEngine self, SseSerializer serializer); - - @protected - void sse_encode_String(String self, SseSerializer serializer); - - @protected - void sse_encode_bool(bool self, SseSerializer serializer); - - @protected - void sse_encode_i_32(int self, SseSerializer serializer); - - @protected - void sse_encode_list_String(List self, SseSerializer serializer); - - @protected - void sse_encode_list_prim_u_8_strict( - Uint8List self, SseSerializer serializer); - - @protected - void sse_encode_list_reference_search_result( - List self, SseSerializer serializer); - - @protected - void sse_encode_list_search_result( - List self, SseSerializer serializer); - - @protected - void sse_encode_reference_search_result( - ReferenceSearchResult self, SseSerializer serializer); - - @protected - void sse_encode_results_order(ResultsOrder self, SseSerializer serializer); - - @protected - void sse_encode_search_result(SearchResult self, SseSerializer serializer); - - @protected - void sse_encode_u_32(int self, SseSerializer serializer); - - @protected - void sse_encode_u_64(BigInt self, SseSerializer serializer); - - @protected - void sse_encode_u_8(int self, SseSerializer serializer); - - @protected - void sse_encode_unit(void self, SseSerializer serializer); - - @protected - void sse_encode_usize(BigInt self, SseSerializer serializer); -} - -// Section: wire_class - -class RustLibWire implements BaseWire { - RustLibWire.fromExternalLibrary(ExternalLibrary lib); - - void rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - int ptr) => - wasmModule - .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - ptr); - - void rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - int ptr) => - wasmModule - .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - ptr); - - void rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - int ptr) => - wasmModule - .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - ptr); - - void rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - int ptr) => - wasmModule - .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - ptr); - - void rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - int ptr) => - wasmModule - .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - ptr); - - void rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - int ptr) => - wasmModule - .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - ptr); - - void rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - int ptr) => - wasmModule - .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - ptr); - - void rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - int ptr) => - wasmModule - .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - ptr); -} - -@JS('wasm_bindgen') -external RustLibWasmModule get wasmModule; - -@JS() -@anonymous -extension type RustLibWasmModule._(JSObject _) implements JSObject { - external void - rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - int ptr); - - external void - rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerBoxdynQuery( - int ptr); - - external void - rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - int ptr); - - external void - rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerIndex( - int ptr); - - external void - rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - int ptr); - - external void - rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerReferenceSearchEngine( - int ptr); - - external void - rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - int ptr); - - external void - rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSearchEngine( - int ptr); -} - - - diff --git a/test_search_logic.dart b/test_search_logic.dart deleted file mode 100644 index f895bebb3..000000000 --- a/test_search_logic.dart +++ /dev/null @@ -1,97 +0,0 @@ -// בדיקה מהירה של הלוגיקה החדשה - -void main() { - // סימולציה של הבדיקה - print('Testing search logic...'); - - // בדיקה 1: מחיקת אות אחת - List oldWords1 = ['בראשית', 'ברא']; - List newWords1 = ['בראשי', 'ברא']; // מחקנו ת' מהמילה הראשונה - bool isMinor1 = isMinorTextChange(oldWords1, newWords1); - print('Test 1 - Delete one letter: $isMinor1 (should be true)'); - - // בדיקה 2: מחיקת מילה שלמה - List oldWords2 = ['בראשית', 'ברא']; - List newWords2 = ['ברא']; // מחקנו את המילה הראשונה - bool isMinor2 = isMinorTextChange(oldWords2, newWords2); - print('Test 2 - Delete whole word: $isMinor2 (should be false)'); - - // בדיקה 3: הוספת אות אחת - List oldWords3 = ['בראשי', 'ברא']; - List newWords3 = ['בראשית', 'ברא']; // הוספנו ת' למילה הראשונה - bool isMinor3 = isMinorTextChange(oldWords3, newWords3); - print('Test 3 - Add one letter: $isMinor3 (should be true)'); - - // בדיקה 4: שינוי מילה לחלוטין - List oldWords4 = ['בראשית', 'ברא']; - List newWords4 = ['שלום', 'ברא']; // שינינו את המילה הראשונה לחלוטין - bool isMinor4 = isMinorTextChange(oldWords4, newWords4); - print('Test 4 - Complete word change: $isMinor4 (should be false)'); -} - -bool isMinorTextChange(List oldWords, List newWords) { - // אם מספר המילים השתנה, זה תמיד שינוי גדול - // (מחיקת או הוספת מילה שלמה) - if (oldWords.length != newWords.length) { - return false; - } - - // אם מספר המילים זהה, בדוק שינויים בתוך המילים - for (int i = 0; i < oldWords.length && i < newWords.length; i++) { - final oldWord = oldWords[i]; - final newWord = newWords[i]; - - // אם המילים זהות, זה בסדר - if (oldWord == newWord) continue; - - // בדיקה אם זה שינוי קטן (הוספה/הסרה של אות אחת או שתיים) - final lengthDiff = (oldWord.length - newWord.length).abs(); - if (lengthDiff > 2) { - return false; // שינוי גדול מדי - } - - // בדיקה אם המילה החדשה מכילה את רוב האותיות של המילה הישנה - final similarity = calculateWordSimilarity(oldWord, newWord); - if (similarity < 0.7) { - return false; // המילים שונות מדי - } - } - - return true; -} - -bool areWordsSubset(List smaller, List larger) { - if (smaller.length > larger.length) return false; - - int smallerIndex = 0; - for (int largerIndex = 0; - largerIndex < larger.length && smallerIndex < smaller.length; - largerIndex++) { - if (smaller[smallerIndex] == larger[largerIndex]) { - smallerIndex++; - } - } - - return smallerIndex == smaller.length; -} - -double calculateWordSimilarity(String word1, String word2) { - if (word1.isEmpty && word2.isEmpty) return 1.0; - if (word1.isEmpty || word2.isEmpty) return 0.0; - if (word1 == word2) return 1.0; - - // חישוב מרחק עריכה פשוט - final maxLength = word1.length > word2.length ? word1.length : word2.length; - int distance = (word1.length - word2.length).abs(); - - // ספירת תווים שונים באותו מיקום - final minLength = word1.length < word2.length ? word1.length : word2.length; - for (int i = 0; i < minLength; i++) { - if (word1[i] != word2[i]) { - distance++; - } - } - - // החזרת ציון דמיון (1.0 = זהות מלאה, 0.0 = שונות מלאה) - return 1.0 - (distance / maxLength); -} diff --git a/update_version.ps1 b/update_version.ps1 index 0a4f0a460..79a6bdc80 100644 --- a/update_version.ps1 +++ b/update_version.ps1 @@ -3,6 +3,11 @@ param( [string]$VersionFile = "version.json" ) +# ---- Encoding helpers ---- +# Explicit UTF-8 encodings to avoid PS version differences (PS5 adds BOM by default) +$Utf8NoBom = New-Object System.Text.UTF8Encoding($false) # for .gitignore, yaml, etc. +$Utf8Bom = New-Object System.Text.UTF8Encoding($true) # for Inno Setup .iss files + # Read version from JSON file if (-not (Test-Path $VersionFile)) { Write-Error "Version file '$VersionFile' not found!" @@ -28,7 +33,7 @@ for ($i = 0; $i -lt $gitignoreContent.Length; $i++) { $gitignoreContent[$i] = "installer/otzaria-$newVersion-windows-full.exe" } } -$gitignoreContent | Set-Content ".gitignore" +$gitignoreContent | Set-Content ".gitignore" -Encoding $Utf8NoBom Write-Host "Updated .gitignore" # Update pubspec.yaml (lines 13 and 39) @@ -41,7 +46,7 @@ for ($i = 0; $i -lt $pubspecContent.Length; $i++) { $pubspecContent[$i] = "version: $newVersion" } } -$pubspecContent | Set-Content "pubspec.yaml" +$pubspecContent | Set-Content "pubspec.yaml" -Encoding $Utf8NoBom Write-Host "Updated pubspec.yaml" # Update installer/otzaria_full.iss (line 5) @@ -51,7 +56,7 @@ for ($i = 0; $i -lt $fullIssContent.Length; $i++) { $fullIssContent[$i] = "#define MyAppVersion `"$newVersion`"" } } -$fullIssContent | Set-Content "installer/otzaria_full.iss" +$fullIssContent | Set-Content "installer/otzaria_full.iss" -Encoding $Utf8Bom Write-Host "Updated installer/otzaria_full.iss" # Update installer/otzaria.iss (line 5) @@ -61,7 +66,7 @@ for ($i = 0; $i -lt $issContent.Length; $i++) { $issContent[$i] = "#define MyAppVersion `"$newVersion`"" } } -$issContent | Set-Content "installer/otzaria.iss" +$issContent | Set-Content "installer/otzaria.iss" -Encoding $Utf8Bom Write-Host "Updated installer/otzaria.iss" Write-Host "Version update completed successfully!" diff --git a/version.json b/version.json index 35db7aa63..65f353836 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "0.9.1" + "version": "0.9.3" } \ No newline at end of file From c7cec5704fe10cbff72c377e1b31299fabfc4ba0 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 13 Aug 2025 12:42:50 +0300 Subject: [PATCH 114/197] =?UTF-8?q?=D7=A9=D7=99=D7=A4=D7=95=D7=A8=20=D7=9C?= =?UTF-8?q?=D7=97=D7=A6=D7=9F=20=D7=94=D7=97=D7=9C=D7=A3=20=D7=A9=D7=95?= =?UTF-8?q?=D7=9C=D7=97=D7=9F=20=D7=A2=D7=91=D7=95=D7=93=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle | 2 +- lib/library/view/library_browser.dart | 95 ++++++++++++++++----------- lib/tabs/reading_screen.dart | 34 +++++----- 3 files changed, 74 insertions(+), 57 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index d7c7c85a1..4cbdb669b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -26,7 +26,7 @@ if (flutterVersionName == null) { android { namespace "com.example.otzaria" compileSdkVersion 35 - ndkVersion flutter.ndkVersion + ndkVersion "27.0.12077973" compileOptions { sourceCompatibility JavaVersion.VERSION_17 diff --git a/lib/library/view/library_browser.dart b/lib/library/view/library_browser.dart index 3180ddfd5..f978248ae 100644 --- a/lib/library/view/library_browser.dart +++ b/lib/library/view/library_browser.dart @@ -93,7 +93,44 @@ class _LibraryBrowserState extends State alignment: Alignment.centerRight, child: Row( children: [ - // קבוצה 1: סינכרון + // קבוצת חזור ובית + IconButton( + icon: const Icon(Icons.arrow_upward), + tooltip: 'חזרה לתיקיה הקודמת', + onPressed: () { + if (state.currentCategory?.parent != null) { + setState( + () => _depth = _depth > 0 ? _depth - 1 : 0); + context.read().add(NavigateUp()); + context + .read() + .add(const SearchBooks()); + _refocusSearchBar(selectAll: true); + } + }, + ), + IconButton( + icon: const Icon(Icons.home), + tooltip: 'חזרה לתיקיה הראשית', + onPressed: () { + setState(() => _depth = 0); + context.read().add(LoadLibrary()); + context + .read() + .librarySearchController + .clear(); + _update(context, state, settingsState); + _refocusSearchBar(selectAll: true); + }, + ), + // קו מפריד + Container( + height: 24, + width: 1, + color: Colors.grey.shade400, + margin: const EdgeInsets.symmetric(horizontal: 2), + ), + // קבוצת סינכרון BlocProvider( create: (context) => FileSyncBloc( repository: FileSyncRepository( @@ -111,11 +148,7 @@ class _LibraryBrowserState extends State color: Colors.grey.shade400, margin: const EdgeInsets.symmetric(horizontal: 2), ), - // קבוצה 2: שולחן עבודה, היסטוריה ומועדפים - WorkspaceIconButton( - onPressed: () => - _showSwitchWorkspaceDialog(context), - ), + // קבוצת שולחן עבודה, היסטוריה ומועדפים IconButton( icon: const Icon(Icons.history), tooltip: 'הצג היסטוריה', @@ -123,9 +156,24 @@ class _LibraryBrowserState extends State ), IconButton( icon: const Icon(Icons.bookmark), - tooltip: 'הצג מועדפים', + tooltip: 'הצג סימניות', onPressed: () => _showBookmarksDialog(context), ), + // קו מפריד + Container( + height: 24, + width: 1, + color: Colors.grey.shade400, + margin: const EdgeInsets.symmetric(horizontal: 2), + ), + SizedBox( + width: 180, // רוחב קבוע למניעת הזזת הטקסט + child: WorkspaceIconButton( + // שולחנות עבודה + onPressed: () => + _showSwitchWorkspaceDialog(context), + ), + ), ], ), ), @@ -148,39 +196,6 @@ class _LibraryBrowserState extends State ), ], ), - leadingWidth: 100, - leading: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // קבוצה 1: חזור ובית (צמודים) - IconButton( - icon: const Icon(Icons.arrow_upward), - tooltip: 'חזרה לתיקיה הקודמת', - onPressed: () { - if (state.currentCategory?.parent != null) { - setState(() => _depth = _depth > 0 ? _depth - 1 : 0); - context.read().add(NavigateUp()); - context.read().add(const SearchBooks()); - _refocusSearchBar(selectAll: true); - } - }, - ), - IconButton( - icon: const Icon(Icons.home), - tooltip: 'חזרה לתיקיה הראשית', - onPressed: () { - setState(() => _depth = 0); - context.read().add(LoadLibrary()); - context - .read() - .librarySearchController - .clear(); - _update(context, state, settingsState); - _refocusSearchBar(selectAll: true); - }, - ), - ], - ), ), body: Column( children: [ diff --git a/lib/tabs/reading_screen.dart b/lib/tabs/reading_screen.dart index 52ea020eb..61d8195a6 100644 --- a/lib/tabs/reading_screen.dart +++ b/lib/tabs/reading_screen.dart @@ -21,6 +21,7 @@ import 'package:otzaria/workspaces/view/workspace_switcher_dialog.dart'; import 'package:otzaria/history/history_dialog.dart'; import 'package:otzaria/bookmarks/bookmarks_dialog.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; +import 'package:otzaria/widgets/workspace_icon_button.dart'; class ReadingScreen extends StatefulWidget { const ReadingScreen({Key? key}) : super(key: key); @@ -119,7 +120,7 @@ class _ReadingScreenState extends State onPressed: () { _showBookmarksDialog(context); }, - child: const Text('הצג מועדפים'), + child: const Text('הצג סימניות'), ), ) ], @@ -159,15 +160,20 @@ class _ReadingScreenState extends State .toList(), ), ), - leadingWidth: 150, + leadingWidth: 280, leading: Row( mainAxisSize: MainAxisSize.min, children: [ - // קבוצה 1: שולחן עבודה + // קבוצת היסטוריה וסימניות IconButton( - icon: const Icon(Icons.add_to_queue), - tooltip: 'החלף שולחן עבודה', - onPressed: () => _showSaveWorkspaceDialog(context), + icon: const Icon(Icons.history), + tooltip: 'הצג היסטוריה', + onPressed: () => _showHistoryDialog(context), + ), + IconButton( + icon: const Icon(Icons.bookmark), + tooltip: 'הצג סימניות', + onPressed: () => _showBookmarksDialog(context), ), // קו מפריד Container( @@ -176,16 +182,12 @@ class _ReadingScreenState extends State color: Colors.grey.shade400, margin: const EdgeInsets.symmetric(horizontal: 2), ), - // קבוצה 2: היסטוריה ומועדפים - IconButton( - icon: const Icon(Icons.history), - tooltip: 'הצג היסטוריה', - onPressed: () => _showHistoryDialog(context), - ), - IconButton( - icon: const Icon(Icons.bookmark), - tooltip: 'הצג מועדפים', - onPressed: () => _showBookmarksDialog(context), + // קבוצת שולחן עבודה עם אנימציה + SizedBox( + width: 180, // רוחב קבוע למניעת הזזת הטאבים + child: WorkspaceIconButton( + onPressed: () => _showSaveWorkspaceDialog(context), + ), ), ], ), From 64d2f55dadd33b6676b59a66c4d1d79e97ce83e0 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 13 Aug 2025 12:47:27 +0300 Subject: [PATCH 115/197] =?UTF-8?q?=D7=A7=D7=95=20=D7=9E=D7=A4=D7=A8=D7=99?= =?UTF-8?q?=D7=93=20=D7=91=D7=99=D7=9F=20=D7=9B=D7=A8=D7=98=D7=99=D7=A1?= =?UTF-8?q?=D7=99=D7=95=D7=AA=20=D7=91=D7=A1=D7=A8=D7=92=D7=9C=20=D7=94?= =?UTF-8?q?=D7=A6=D7=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pdf_book/pdf_book_screen.dart | 95 ++++++++++++++++----- lib/text_book/view/text_book_screen.dart | 102 ++++++++++++++++------- 2 files changed, 145 insertions(+), 52 deletions(-) diff --git a/lib/pdf_book/pdf_book_screen.dart b/lib/pdf_book/pdf_book_screen.dart index 9696c44d6..1cbf3b1f7 100644 --- a/lib/pdf_book/pdf_book_screen.dart +++ b/lib/pdf_book/pdf_book_screen.dart @@ -560,28 +560,22 @@ class _PdfBookScreenState extends State child: Material( color: Colors.transparent, child: ClipRect( - child: TabBar( - controller: _leftPaneTabController, - tabs: const [ - Tab( - child: Center( - child: Text('ניווט', - textAlign: TextAlign.center))), - Tab( - child: Center( - child: Text('חיפוש', - textAlign: TextAlign.center))), - Tab( - child: Center( - child: Text('דפים', - textAlign: TextAlign.center))), + child: Column( + children: [ + Row( + children: [ + Expanded(child: _buildCustomTab('ניווט', 0)), + Container(height: 24, width: 1, color: Colors.grey.shade400, margin: const EdgeInsets.symmetric(horizontal: 2)), + Expanded(child: _buildCustomTab('חיפוש', 1)), + Container(height: 24, width: 1, color: Colors.grey.shade400, margin: const EdgeInsets.symmetric(horizontal: 2)), + Expanded(child: _buildCustomTab('דפים', 2)), + ], + ), + Container( + height: 1, + color: Theme.of(context).dividerColor, + ), ], - isScrollable: false, - tabAlignment: TabAlignment.fill, - padding: EdgeInsets.zero, - indicatorPadding: EdgeInsets.zero, - labelPadding: - const EdgeInsets.symmetric(horizontal: 2), ), ), ), @@ -714,6 +708,65 @@ class _PdfBookScreenState extends State return result ?? false; } + Widget _buildCustomTab(String text, int index) { + final controller = _leftPaneTabController; + if (controller == null) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + child: Text( + text, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14), + ), + ); + } + + return AnimatedBuilder( + animation: controller, + builder: (context, child) { + final isSelected = controller.index == index; + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + controller.animateTo(index); + }, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + controller.animateTo(index); + }, + child: Container( + padding: + const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + decoration: BoxDecoration( + border: isSelected + ? Border( + bottom: BorderSide( + color: Theme.of(context).primaryColor, + width: 2)) + : null, + ), + child: Text( + text, + textAlign: TextAlign.center, + style: TextStyle( + color: isSelected ? Theme.of(context).primaryColor : null, + fontWeight: + isSelected ? FontWeight.bold : FontWeight.normal, + fontSize: 14, + ), + ), + ), + ), + ), + ), + ); + }, + ); + } + Widget _buildTextButton( BuildContext context, PdfBook book, PdfViewerController controller) { return FutureBuilder( diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index 4f1724e0e..8a4a056f5 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -989,6 +989,60 @@ $detailsSection ); } + Widget _buildCustomTab(String text, int index, TextBookLoaded state) { + return AnimatedBuilder( + animation: tabController, + builder: (context, child) { + final isSelected = tabController.index == index; + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + tabController.animateTo(index); + if (index == 1 && !Platform.isAndroid) { + textSearchFocusNode.requestFocus(); + } else if (index == 0 && !Platform.isAndroid) { + navigationSearchFocusNode.requestFocus(); + } + }, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + tabController.animateTo(index); + if (index == 1 && !Platform.isAndroid) { + textSearchFocusNode.requestFocus(); + } else if (index == 0 && !Platform.isAndroid) { + navigationSearchFocusNode.requestFocus(); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + decoration: BoxDecoration( + border: isSelected + ? Border( + bottom: BorderSide( + color: Theme.of(context).primaryColor, width: 2)) + : null, + ), + child: Text( + text, + textAlign: TextAlign.center, + style: TextStyle( + color: isSelected ? Theme.of(context).primaryColor : null, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + fontSize: 14, + ), + ), + ), + ), + ), + ), + ); + }, + ); + } + Widget _buildCombinedView(TextBookLoaded state) { return CombinedView( data: state.content, @@ -1023,38 +1077,24 @@ $detailsSection Row( children: [ Expanded( - child: TabBar( - tabs: const [ - Tab( - child: Center( - child: Text('ניווט', - textAlign: TextAlign.center))), - Tab( - child: Center( - child: Text('חיפוש', - textAlign: TextAlign.center))), - Tab( - child: Center( - child: Text('מפרשים', - textAlign: TextAlign.center))), - Tab( - child: Center( - child: Text('קישורים', - textAlign: TextAlign.center))), + child: Column( + children: [ + Row( + children: [ + Expanded(child: _buildCustomTab('ניווט', 0, state)), + Container(height: 24, width: 1, color: Colors.grey.shade400, margin: const EdgeInsets.symmetric(horizontal: 2)), + Expanded(child: _buildCustomTab('חיפוש', 1, state)), + Container(height: 24, width: 1, color: Colors.grey.shade400, margin: const EdgeInsets.symmetric(horizontal: 2)), + Expanded(child: _buildCustomTab('מפרשים', 2, state)), + Container(height: 24, width: 1, color: Colors.grey.shade400, margin: const EdgeInsets.symmetric(horizontal: 2)), + Expanded(child: _buildCustomTab('קישורים', 3, state)), + ], + ), + Container( + height: 1, + color: Theme.of(context).dividerColor, + ), ], - controller: tabController, - isScrollable: false, - tabAlignment: TabAlignment.fill, - padding: EdgeInsets.zero, - indicatorPadding: EdgeInsets.zero, - labelPadding: const EdgeInsets.symmetric(horizontal: 2), - onTap: (value) { - if (value == 1 && !Platform.isAndroid) { - textSearchFocusNode.requestFocus(); - } else if (value == 0 && !Platform.isAndroid) { - navigationSearchFocusNode.requestFocus(); - } - }, ), ), if (MediaQuery.of(context).size.width >= 600) From 70ccf84afa38af3c1f3d78dc0b1d3d9de2255b2d Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 13 Aug 2025 13:17:58 +0300 Subject: [PATCH 116/197] =?UTF-8?q?=D7=A4=D7=A1=D7=99=20=D7=94=D7=A4=D7=A8?= =?UTF-8?q?=D7=93=D7=94=20=D7=91=D7=99=D7=9F=20=D7=A7=D7=91=D7=95=D7=A6?= =?UTF-8?q?=D7=95=D7=AA=20=D7=9E=D7=A4=D7=A8=D7=A9=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../combined_view/combined_book_screen.dart | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/lib/text_book/view/combined_view/combined_book_screen.dart b/lib/text_book/view/combined_view/combined_book_screen.dart index 685484497..982465ae1 100644 --- a/lib/text_book/view/combined_view/combined_book_screen.dart +++ b/lib/text_book/view/combined_view/combined_book_screen.dart @@ -147,23 +147,45 @@ class _CombinedViewState extends State { // חזל ..._buildGroup('חז"ל', state.chazal, state), - // מוסיפים קו הפרדה רק אם יש גם חזל וגם ראשונים - if (state.chazal.isNotEmpty && state.rishonim.isNotEmpty) + // מוסיפים קו הפרדה בין חז"ל לראשונים, או בין תורה שבכתב לראשונים אם אין חז"ל + if ((state.chazal.isNotEmpty && state.rishonim.isNotEmpty) || + (state.chazal.isEmpty && + state.torahShebichtav.isNotEmpty && + state.rishonim.isNotEmpty)) const ctx.MenuDivider(), // ראשונים - ..._buildGroup('ראשונים', state.rishonim, state), + ..._buildGroup('הראשונים', state.rishonim, state), - // מוסיפים קו הפרדה רק אם יש גם ראשונים וגם אחרונים - if (state.rishonim.isNotEmpty && state.acharonim.isNotEmpty) + // מוסיפים קו הפרדה בין ראשונים לאחרונים, או מהקבוצה הקודמת לאחרונים + if ((state.rishonim.isNotEmpty && state.acharonim.isNotEmpty) || + (state.rishonim.isEmpty && + state.chazal.isNotEmpty && + state.acharonim.isNotEmpty) || + (state.rishonim.isEmpty && + state.chazal.isEmpty && + state.torahShebichtav.isNotEmpty && + state.acharonim.isNotEmpty)) const ctx.MenuDivider(), // אחרונים - ..._buildGroup('אחרונים', state.acharonim, state), + ..._buildGroup('האחרונים', state.acharonim, state), - // מוסיפים קו הפרדה רק אם יש גם אחרונים וגם בני זמננו - if (state.acharonim.isNotEmpty && - state.modernCommentators.isNotEmpty) + // מוסיפים קו הפרדה בין אחרונים למחברי זמננו, או מהקבוצה הקודמת למחברי זמננו + if ((state.acharonim.isNotEmpty && + state.modernCommentators.isNotEmpty) || + (state.acharonim.isEmpty && + state.rishonim.isNotEmpty && + state.modernCommentators.isNotEmpty) || + (state.acharonim.isEmpty && + state.rishonim.isEmpty && + state.chazal.isNotEmpty && + state.modernCommentators.isNotEmpty) || + (state.acharonim.isEmpty && + state.rishonim.isEmpty && + state.chazal.isEmpty && + state.torahShebichtav.isNotEmpty && + state.modernCommentators.isNotEmpty)) const ctx.MenuDivider(), // מחברי זמננו @@ -179,7 +201,7 @@ class _CombinedViewState extends State { const ctx.MenuDivider(), // הוסף את רשימת הפרשנים הלא משויכים - ..._buildGroup('שאר מפרשים', ungrouped, state), + ..._buildGroup('שאר המפרשים', ungrouped, state), ], ), ctx.MenuItem.submenu( From 2fe1fad8cba84e85f05ac62fef2606e8bfca0971 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 13 Aug 2025 20:33:02 +0300 Subject: [PATCH 117/197] =?UTF-8?q?=D7=A0=D7=99=D7=A7=D7=95=D7=99=20=D7=AA?= =?UTF-8?q?=D7=95=D7=A6=D7=90=D7=95=D7=AA=20=D7=91=D7=97=D7=99=D7=A4=D7=95?= =?UTF-8?q?=D7=A9=20=D7=91=D7=AA=D7=95=D7=9A=20=D7=A7=D7=95=D7=91=D7=A5=20?= =?UTF-8?q?=D7=98=D7=A7=D7=A1=D7=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/text_book/view/text_book_search_screen.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/text_book/view/text_book_search_screen.dart b/lib/text_book/view/text_book_search_screen.dart index 890fa30e6..513e61a1c 100644 --- a/lib/text_book/view/text_book_search_screen.dart +++ b/lib/text_book/view/text_book_search_screen.dart @@ -105,6 +105,8 @@ class TextBookSearchViewState extends State icon: const Icon(Icons.clear), onPressed: () { searchTextController.clear(); + context.read().add(UpdateSearchText('')); + _searchTextUpdated(); widget.focusNode.requestFocus(); }, ), From 435844a69f129d1e08c0f94639b7322d318015cf Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 13 Aug 2025 22:12:32 +0300 Subject: [PATCH 118/197] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20=D7=94?= =?UTF-8?q?=D7=91=D7=A0=D7=99=D7=99=D7=94=20=D7=94=D7=90=D7=95=D7=98=D7=95?= =?UTF-8?q?=D7=9E=D7=98=D7=99=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/flutter.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index c2f6d899c..68f767b54 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -12,6 +12,7 @@ on: - dev_dev2 - n-search2 - ns2new + - ns2new2.1 pull_request: types: [opened, synchronize, reopened] workflow_dispatch: From 13d09667de5ae3ee66abd80d6569b08c7f68d488 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 13 Aug 2025 22:37:17 +0300 Subject: [PATCH 119/197] test --- lib/navigation/calendar_cubit.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/navigation/calendar_cubit.dart b/lib/navigation/calendar_cubit.dart index 7a4de8ff6..557a9006c 100644 --- a/lib/navigation/calendar_cubit.dart +++ b/lib/navigation/calendar_cubit.dart @@ -104,7 +104,7 @@ class CalendarCubit extends Cubit { final calendarTypeString = settings['calendarType'] as String; final calendarType = _stringToCalendarType(calendarTypeString); final selectedCity = settings['selectedCity'] as String; - + emit(state.copyWith( calendarType: calendarType, selectedCity: selectedCity, From 807e0e75f710c7f3ee872d1ded6eaa428f34549b Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 13 Aug 2025 22:12:32 +0300 Subject: [PATCH 120/197] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20=D7=94?= =?UTF-8?q?=D7=91=D7=A0=D7=99=D7=99=D7=94=20=D7=94=D7=90=D7=95=D7=98=D7=95?= =?UTF-8?q?=D7=9E=D7=98=D7=99=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/flutter.yml | 1 + lib/navigation/calendar_cubit.dart | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index c2f6d899c..f5baa3fc5 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -12,6 +12,7 @@ on: - dev_dev2 - n-search2 - ns2new + - n-search2.1 pull_request: types: [opened, synchronize, reopened] workflow_dispatch: diff --git a/lib/navigation/calendar_cubit.dart b/lib/navigation/calendar_cubit.dart index 7a4de8ff6..557a9006c 100644 --- a/lib/navigation/calendar_cubit.dart +++ b/lib/navigation/calendar_cubit.dart @@ -104,7 +104,7 @@ class CalendarCubit extends Cubit { final calendarTypeString = settings['calendarType'] as String; final calendarType = _stringToCalendarType(calendarTypeString); final selectedCity = settings['selectedCity'] as String; - + emit(state.copyWith( calendarType: calendarType, selectedCity: selectedCity, From c934a2c7133f91fbb52f26fef7b8253ed419d265 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 13 Aug 2025 23:41:43 +0300 Subject: [PATCH 121/197] =?UTF-8?q?=D7=A1=D7=99=D7=93=D7=95=D7=A8=20=D7=94?= =?UTF-8?q?=D7=9C=D7=A9=D7=95=D7=A0=D7=99=D7=95=D7=AA=20=D7=9B=D7=A9=D7=A1?= =?UTF-8?q?=D7=A8=D7=92=D7=9C=20=D7=94=D7=A6=D7=93=20=D7=9E=D7=95=D7=A6?= =?UTF-8?q?=D7=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/text_book/view/text_book_screen.dart | 45 ++++++++++++++++++------ 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index 8a4a056f5..092ade0f7 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -1017,20 +1017,26 @@ $detailsSection } }, child: Container( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + padding: + const EdgeInsets.symmetric(vertical: 12, horizontal: 8), decoration: BoxDecoration( border: isSelected ? Border( bottom: BorderSide( - color: Theme.of(context).primaryColor, width: 2)) + color: Theme.of(context).primaryColor, + width: 2)) : null, ), child: Text( text, textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.fade, + softWrap: false, style: TextStyle( color: isSelected ? Theme.of(context).primaryColor : null, - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + fontWeight: + isSelected ? FontWeight.bold : FontWeight.normal, fontSize: 14, ), ), @@ -1081,13 +1087,32 @@ $detailsSection children: [ Row( children: [ - Expanded(child: _buildCustomTab('ניווט', 0, state)), - Container(height: 24, width: 1, color: Colors.grey.shade400, margin: const EdgeInsets.symmetric(horizontal: 2)), - Expanded(child: _buildCustomTab('חיפוש', 1, state)), - Container(height: 24, width: 1, color: Colors.grey.shade400, margin: const EdgeInsets.symmetric(horizontal: 2)), - Expanded(child: _buildCustomTab('מפרשים', 2, state)), - Container(height: 24, width: 1, color: Colors.grey.shade400, margin: const EdgeInsets.symmetric(horizontal: 2)), - Expanded(child: _buildCustomTab('קישורים', 3, state)), + Expanded( + child: _buildCustomTab('ניווט', 0, state)), + Container( + height: 24, + width: 1, + color: Colors.grey.shade400, + margin: const EdgeInsets.symmetric( + horizontal: 2)), + Expanded( + child: _buildCustomTab('חיפוש', 1, state)), + Container( + height: 24, + width: 1, + color: Colors.grey.shade400, + margin: const EdgeInsets.symmetric( + horizontal: 2)), + Expanded( + child: _buildCustomTab('מפרשים', 2, state)), + Container( + height: 24, + width: 1, + color: Colors.grey.shade400, + margin: const EdgeInsets.symmetric( + horizontal: 2)), + Expanded( + child: _buildCustomTab('קישורים', 3, state)), ], ), Container( From b53d8f9f1962f82057b18872551e3bae877ddab4 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 14 Aug 2025 00:15:55 +0300 Subject: [PATCH 122/197] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20=D7=9E?= =?UTF-8?q?=D7=A7=D7=95=D7=A8=20=D7=92=D7=A8=D7=A1=D7=90=D7=95=D7=AA=20?= =?UTF-8?q?=D7=9E=D7=A4=D7=AA=D7=97=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/update/my_updat_widget.dart | 152 ++++++++++++++++++++++---------- 1 file changed, 105 insertions(+), 47 deletions(-) diff --git a/lib/update/my_updat_widget.dart b/lib/update/my_updat_widget.dart index a2ff50067..d9d95bce5 100644 --- a/lib/update/my_updat_widget.dart +++ b/lib/update/my_updat_widget.dart @@ -49,36 +49,117 @@ class MyUpdatWidget extends StatelessWidget { return UpdatWindowManager( getLatestVersion: () async { // Github gives us a super useful latest endpoint, and we can use it to get the latest stable release - final data = await http.get(Uri.parse( - Settings.getValue('key-dev-channel') ?? false - ? "https://api.github.com/repos/sivan22/otzaria-dev-channel/releases/latest" - : "https://api.github.com/repos/sivan22/otzaria/releases/latest", - )); - - // Return the tag name, which is always a semantically versioned string. - return jsonDecode(data.body)["tag_name"]; + final isDevChannel = Settings.getValue('key-dev-channel') ?? false; + + if (isDevChannel) { + // For dev channel, get the latest pre-release from the main repo + final data = await http.get(Uri.parse( + "https://api.github.com/repos/sivan22/otzaria/releases", + )); + final releases = jsonDecode(data.body) as List; + // Find the first pre-release that is not a draft and not a PR preview + final preRelease = releases.firstWhere( + (release) => release["prerelease"] == true && + release["draft"] == false && + !release["tag_name"].toString().contains('-pr-'), + orElse: () => releases.first, + ); + return preRelease["tag_name"]; + } else { + // For stable channel, get the latest stable release + final data = await http.get(Uri.parse( + "https://api.github.com/repos/sivan22/otzaria/releases/latest", + )); + return jsonDecode(data.body)["tag_name"]; + } }, getBinaryUrl: (version) async { - // Github also gives us a great way to download the binary for a certain release (as long as we use a consistent naming scheme) - - // Make sure that this link includes the platform extension with which to save your binary. - // If you use https://exapmle.com/latest/macos for instance then you need to create your own file using `getDownloadFileLocation` - - final repo = Settings.getValue('key-dev-channel') ?? false - ? "otzaria-dev-channel" - : "otzaria"; - return "https://github.com/sivan22/$repo/releases/download/$version/otzaria-$version-${Platform.operatingSystem}.$platformExt"; + // Get the release info to find the correct asset + final data = await http.get(Uri.parse( + "https://api.github.com/repos/sivan22/otzaria/releases/tags/$version", + )); + final release = jsonDecode(data.body); + final assets = release["assets"] as List; + + // Find the appropriate asset for the current platform + final platformName = Platform.operatingSystem; + final isDevChannel = Settings.getValue('key-dev-channel') ?? false; + + String? assetUrl; + + for (final asset in assets) { + final name = asset["name"] as String; + final downloadUrl = asset["browser_download_url"] as String; + + switch (platformName) { + case 'windows': + // For dev channel prefer MSIX, otherwise EXE + if (isDevChannel && name.endsWith('.msix')) { + assetUrl = downloadUrl; + break; + } else if (name.endsWith('.exe')) { + assetUrl = downloadUrl; + break; + } + // Fallback: Windows ZIP + if (name.contains('windows') && name.endsWith('.zip') && assetUrl == null) { + assetUrl = downloadUrl; + } + break; + + case 'macos': + // Look for macOS zip file (workflow creates otzaria-macos.zip) + if (name.contains('macos') && name.endsWith('.zip')) { + assetUrl = downloadUrl; + break; + } + break; + + case 'linux': + // Prefer DEB, then RPM, then raw zip (workflow creates otzaria-linux-raw.zip) + if (name.endsWith('.deb')) { + assetUrl = downloadUrl; + break; + } else if (name.endsWith('.rpm') && assetUrl == null) { + assetUrl = downloadUrl; + } else if (name.contains('linux') && name.endsWith('.zip') && assetUrl == null) { + assetUrl = downloadUrl; + } + break; + } + } + + if (assetUrl == null) { + throw Exception('No suitable binary found for $platformName'); + } + + return assetUrl; }, appName: "otzaria", // This is used to name the downloaded files. getChangelog: (_, __) async { // That same latest endpoint gives us access to a markdown-flavored release body. Perfect! - final repo = Settings.getValue('key-dev-channel') ?? false - ? "otzaria-dev-channel" - : "otzaria"; - final data = await http.get(Uri.parse( - "https://api.github.com/repos/sivan22/$repo/releases/latest", - )); - return jsonDecode(data.body)["body"]; + final isDevChannel = Settings.getValue('key-dev-channel') ?? false; + + if (isDevChannel) { + // For dev channel, get changelog from the latest pre-release + final data = await http.get(Uri.parse( + "https://api.github.com/repos/sivan22/otzaria/releases", + )); + final releases = jsonDecode(data.body) as List; + final preRelease = releases.firstWhere( + (release) => release["prerelease"] == true && + release["draft"] == false && + !release["tag_name"].toString().contains('-pr-'), + orElse: () => releases.first, + ); + return preRelease["body"]; + } else { + // For stable channel, get changelog from latest stable release + final data = await http.get(Uri.parse( + "https://api.github.com/repos/sivan22/otzaria/releases/latest", + )); + return jsonDecode(data.body)["body"]; + } }, currentVersion: snapshot.data!.version, updateChipBuilder: _flatChipAutoHideError, @@ -88,28 +169,5 @@ class MyUpdatWidget extends StatelessWidget { ); }); - String get platformExt { - switch (Platform.operatingSystem) { - case 'windows': - { - return Settings.getValue('key-dev-channel') ?? false - ? 'msix' - : 'exe'; - } - - case 'macos': - { - return 'dmg'; - } - case 'linux': - { - return 'AppImage'; - } - default: - { - return 'zip'; - } - } - } } From 50702fac495a1fe49caeb19f9f4befe975502936 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 14 Aug 2025 00:23:56 +0300 Subject: [PATCH 123/197] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20=D7=92?= =?UTF-8?q?=D7=A8=D7=A1=D7=94=20=D7=9C0931?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++-- installer/otzaria.iss | 2 +- installer/otzaria_full.iss | 2 +- pubspec.yaml | 4 ++-- version.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index b898afa0d..ad41e0dd5 100644 --- a/.gitignore +++ b/.gitignore @@ -60,7 +60,7 @@ android/ build/ fonts/ -installer/otzaria-0.9.3-windows.exe -installer/otzaria-0.9.3-windows-full.exe +installer/otzaria-0.9.31-windows.exe +installer/otzaria-0.9.31-windows-full.exe pubspec.lock flutter/ diff --git a/installer/otzaria.iss b/installer/otzaria.iss index cbad35f97..d588351d1 100644 --- a/installer/otzaria.iss +++ b/installer/otzaria.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.3" +#define MyAppVersion "0.9.31" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/installer/otzaria_full.iss b/installer/otzaria_full.iss index 945b11897..a1ac95578 100644 --- a/installer/otzaria_full.iss +++ b/installer/otzaria_full.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.3" +#define MyAppVersion "0.9.31" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/pubspec.yaml b/pubspec.yaml index 352113995..1489d52ec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,7 @@ msix_config: publisher_display_name: sivan22 identity_name: sivan22.Otzaria description: "ספריית ספרים יהודיים עם אפשרויות חיפוש חכם" - msix_version: 0.9.3.0 + msix_version: 0.9.31.0 logo_path: assets/icon/icon.png publisher: CN=sivan22, O=sivan22, C=IL certificate_path: sivan22.pfx @@ -36,7 +36,7 @@ msix_config: # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.9.3 +version: 0.9.31 environment: sdk: ">=3.2.6 <4.0.0" diff --git a/version.json b/version.json index 65f353836..88abb7cac 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "0.9.3" + "version": "0.9.31" } \ No newline at end of file From cb44cff523dbde8bf61d579857c9c0cf6b95e44a Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 14 Aug 2025 01:05:02 +0300 Subject: [PATCH 124/197] =?UTF-8?q?=D7=94=D7=A6=D7=92=D7=AA=20=D7=A7=D7=91?= =?UTF-8?q?=D7=A6=D7=99=D7=9D=20=D7=A2=D7=9D=20"."=20=D7=91=D7=A9=D7=9D=20?= =?UTF-8?q?=D7=94=D7=A7=D7=95=D7=91=D7=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/utils/text_manipulation.dart | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/utils/text_manipulation.dart b/lib/utils/text_manipulation.dart index 4fa4e3d9c..ef7e959f0 100644 --- a/lib/utils/text_manipulation.dart +++ b/lib/utils/text_manipulation.dart @@ -54,7 +54,16 @@ String getTitleFromPath(String path) { path = path .replaceAll('/', Platform.pathSeparator) .replaceAll('\\', Platform.pathSeparator); - return path.split(Platform.pathSeparator).last.split('.').first; + final fileName = path.split(Platform.pathSeparator).last; + + // אם אין נקודה בשם הקובץ, נחזיר את השם כמו שהוא + final lastDotIndex = fileName.lastIndexOf('.'); + if (lastDotIndex == -1) { + return fileName; + } + + // נסיר רק את הסיומת (החלק האחרון אחרי הנקודה האחרונה) + return fileName.substring(0, lastDotIndex); } // Cache for the CSV data to avoid reading the file multiple times From 35f4604398abaea3ea461920c3aab46a14e5296c Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 14 Aug 2025 22:54:38 +0300 Subject: [PATCH 125/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=A2?= =?UTF-8?q?=D7=A8=D7=99=D7=9D=20=D7=95=D7=96=D7=9E=D7=A0=D7=99=D7=9D=20?= =?UTF-8?q?=D7=9C=D7=9C=D7=95=D7=97=20=D7=94=D7=A9=D7=A0=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/daf_yomi/daf_yomi_helper.dart | 132 ++++++-- lib/navigation/calendar_cubit.dart | 464 ++++++++++++++++++++++++++-- lib/navigation/calendar_widget.dart | 273 +++++++++++++++- 3 files changed, 811 insertions(+), 58 deletions(-) diff --git a/lib/daf_yomi/daf_yomi_helper.dart b/lib/daf_yomi/daf_yomi_helper.dart index 5987839d7..7db2b3fe8 100644 --- a/lib/daf_yomi/daf_yomi_helper.dart +++ b/lib/daf_yomi/daf_yomi_helper.dart @@ -9,38 +9,122 @@ import 'package:otzaria/tabs/bloc/tabs_event.dart'; import 'package:otzaria/models/books.dart'; import 'package:otzaria/tabs/models/pdf_tab.dart'; import 'package:otzaria/tabs/models/text_tab.dart'; +import 'package:otzaria/library/models/library.dart'; import 'package:pdfrx/pdfrx.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; void openDafYomiBook(BuildContext context, String tractate, String daf) async { + _openDafYomiBookInCategory(context, tractate, daf, 'תלמוד בבלי'); +} + +void openDafYomiYerushalmiBook( + BuildContext context, String tractate, String daf) async { + _openDafYomiBookInCategory(context, tractate, daf, 'תלמוד ירושלמי'); +} + +void _openDafYomiBookInCategory(BuildContext context, String tractate, + String daf, String categoryName) async { final libraryBlocState = BlocProvider.of(context).state; - final book = libraryBlocState.library?.findBookByTitle(tractate, null); - var index = 0; - if (book != null) { - if (book is TextBook) { - final tocEntry = await _findDafInToc(book, 'דף ${daf.trim()}'); - index = tocEntry?.index ?? 0; - final tab = TextBookTab( - book: book, - index: index, - openLeftPane: (Settings.getValue('key-pin-sidebar') ?? false) || - (Settings.getValue('key-default-sidebar-open') ?? false), - ); - BlocProvider.of(context).add(AddTab(tab)); - } else if (book is PdfBook) { - final outline = await getDafYomiOutline(book, 'דף ${daf.trim()}'); - index = outline?.dest?.pageNumber ?? 0; - final tab = PdfBookTab( - book: book, - pageNumber: index, - openLeftPane: (Settings.getValue('key-pin-sidebar') ?? false) || - (Settings.getValue('key-default-sidebar-open') ?? false), + final library = libraryBlocState.library; + + if (library == null) return; + + // מחפש את הקטגוריה הרלוונטית + Category? talmudCategory; + for (var category in library.getAllCategories()) { + if (category.title == categoryName) { + talmudCategory = category; + break; + } + } + + if (talmudCategory == null) { + // נסה לחפש בכל הקטגוריות אם לא נמצאה הקטגוריה הספציפית + final allBooks = library.getAllBooks(); + Book? book; + + // חיפוש מדויק יותר - גם בשם המלא וגם בחיפוש חלקי + for (var bookInLibrary in allBooks) { + if (bookInLibrary.title == tractate || + bookInLibrary.title.contains(tractate) || + tractate.contains(bookInLibrary.title)) { + // בדוק אם הספר נמצא בקטגוריה הנכונה על ידי בדיקת הקטגוריה + if (bookInLibrary.category?.title == categoryName) { + book = bookInLibrary; + break; + } + } + } + + if (book == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('לא נמצאה קטגוריה: $categoryName')), ); - BlocProvider.of(context).add(AddTab(tab)); + return; + } else { + // נמצא ספר, נמשיך עם הפתיחה + await _openBook(context, book, daf); + return; + } + } + + // מחפש את הספר בקטגוריה הספציפית + Book? book; + final allBooksInCategory = talmudCategory.getAllBooks(); + + // חיפוש מדויק יותר + for (var bookInCategory in allBooksInCategory) { + if (bookInCategory.title == tractate || + bookInCategory.title.contains(tractate) || + tractate.contains(bookInCategory.title)) { + book = bookInCategory; + break; } - BlocProvider.of(context) - .add(const NavigateToScreen(Screen.reading)); } + + if (book != null) { + await _openBook(context, book, daf); + } else { + // הצג רשימת ספרים זמינים לדיבוג + final availableBooks = + allBooksInCategory.map((b) => b.title).take(5).join(', '); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'לא נמצא ספר: $tractate ב$categoryName\nספרים זמינים: $availableBooks...'), + duration: const Duration(seconds: 5), + ), + ); + } +} + +Future _openBook(BuildContext context, Book book, String daf) async { + var index = 0; + + if (book is TextBook) { + final tocEntry = await _findDafInToc(book, 'דף ${daf.trim()}'); + index = tocEntry?.index ?? 0; + final tab = TextBookTab( + book: book, + index: index, + openLeftPane: (Settings.getValue('key-pin-sidebar') ?? false) || + (Settings.getValue('key-default-sidebar-open') ?? false), + ); + BlocProvider.of(context).add(AddTab(tab)); + } else if (book is PdfBook) { + final outline = await getDafYomiOutline(book, 'דף ${daf.trim()}'); + index = outline?.dest?.pageNumber ?? 0; + final tab = PdfBookTab( + book: book, + pageNumber: index, + openLeftPane: (Settings.getValue('key-pin-sidebar') ?? false) || + (Settings.getValue('key-default-sidebar-open') ?? false), + ); + BlocProvider.of(context).add(AddTab(tab)); + } + + BlocProvider.of(context) + .add(const NavigateToScreen(Screen.reading)); } Future _findDafInToc(TextBook book, String daf) async { diff --git a/lib/navigation/calendar_cubit.dart b/lib/navigation/calendar_cubit.dart index 557a9006c..8d7908c4e 100644 --- a/lib/navigation/calendar_cubit.dart +++ b/lib/navigation/calendar_cubit.dart @@ -40,7 +40,8 @@ class CalendarState extends Equatable { dailyTimes: const {}, currentJewishDate: jewishNow, currentGregorianDate: now, - calendarType: CalendarType.combined, // ברירת מחדל, יעודכן ב-_initializeCalendar + calendarType: + CalendarType.combined, // ברירת מחדל, יעודכן ב-_initializeCalendar calendarView: CalendarView.month, ); } @@ -93,7 +94,7 @@ class CalendarState extends Equatable { class CalendarCubit extends Cubit { final SettingsRepository _settingsRepository; - CalendarCubit({SettingsRepository? settingsRepository}) + CalendarCubit({SettingsRepository? settingsRepository}) : _settingsRepository = settingsRepository ?? SettingsRepository(), super(CalendarState.initial()) { _initializeCalendar(); @@ -147,11 +148,21 @@ class CalendarCubit extends Cubit { final current = state.currentJewishDate; final newJewishDate = JewishDate(); if (current.getJewishMonth() == 1) { + // אם אנחנו בניסן (חודש 1), עוברים לאדר של השנה הקודמת + // אבל השנה לא משתנה כי השנה מתחלפת בתשרי newJewishDate.setJewishDate( - current.getJewishYear() - 1, + current.getJewishYear(), 12, 1, ); + } else if (current.getJewishMonth() == 7) { + // אם אנחנו בתשרי (חודש 7), עוברים לאלול של השנה הקודמת + // כאן כן משתנה השנה כי תשרי הוא תחילת השנה + newJewishDate.setJewishDate( + current.getJewishYear() - 1, + 6, + 1, + ); } else { newJewishDate.setJewishDate( current.getJewishYear(), @@ -174,11 +185,21 @@ class CalendarCubit extends Cubit { final current = state.currentJewishDate; final newJewishDate = JewishDate(); if (current.getJewishMonth() == 12) { + // אם אנחנו באדר (חודש 12), עוברים לניסן של אותה שנה + // השנה לא משתנה כי השנה מתחלפת בתשרי newJewishDate.setJewishDate( - current.getJewishYear() + 1, + current.getJewishYear(), 1, 1, ); + } else if (current.getJewishMonth() == 6) { + // אם אנחנו באלול (חודש 6), עוברים לתשרי של השנה הבאה + // כאן כן משתנה השנה כי תשרי הוא תחילת השנה + newJewishDate.setJewishDate( + current.getJewishYear() + 1, + 7, + 1, + ); } else { newJewishDate.setJewishDate( current.getJewishYear(), @@ -228,28 +249,200 @@ class CalendarCubit extends Cubit { } } -// City coordinates map +// City coordinates map - מסודר לפי מדינות ובסדר א-ב const Map> cityCoordinates = { - 'ירושלים': {'lat': 31.7683, 'lng': 35.2137, 'elevation': 800.0}, - 'תל אביב': {'lat': 32.0853, 'lng': 34.7818, 'elevation': 5.0}, - 'חיפה': {'lat': 32.7940, 'lng': 34.9896, 'elevation': 30.0}, - 'באר שבע': {'lat': 31.2518, 'lng': 34.7915, 'elevation': 280.0}, - 'נתניה': {'lat': 32.3215, 'lng': 34.8532, 'elevation': 30.0}, + // === ארץ ישראל === + 'אילת': {'lat': 29.5581, 'lng': 34.9482, 'elevation': 12.0}, + 'אריאל': {'lat': 32.1069, 'lng': 35.1897, 'elevation': 650.0}, 'אשדוד': {'lat': 31.8044, 'lng': 34.6553, 'elevation': 50.0}, - 'פתח תקווה': {'lat': 32.0870, 'lng': 34.8873, 'elevation': 80.0}, + 'אשקלון': {'lat': 31.6688, 'lng': 34.5742, 'elevation': 50.0}, + 'באר שבע': {'lat': 31.2518, 'lng': 34.7915, 'elevation': 280.0}, 'בני ברק': {'lat': 32.0809, 'lng': 34.8338, 'elevation': 50.0}, + 'בת ים': {'lat': 32.0167, 'lng': 34.7500, 'elevation': 5.0}, + 'גבעת זאב': {'lat': 31.8467, 'lng': 35.1667, 'elevation': 600.0}, + 'גבעתיים': {'lat': 32.0706, 'lng': 34.8103, 'elevation': 80.0}, + 'דימונה': {'lat': 31.0686, 'lng': 35.0333, 'elevation': 550.0}, + 'הוד השרון': {'lat': 32.1506, 'lng': 34.8889, 'elevation': 40.0}, + 'הרצליה': {'lat': 32.1624, 'lng': 34.8443, 'elevation': 40.0}, + 'חיפה': {'lat': 32.7940, 'lng': 34.9896, 'elevation': 30.0}, + 'חולון': {'lat': 32.0117, 'lng': 34.7689, 'elevation': 54.0}, + 'טבריה': {'lat': 32.7940, 'lng': 35.5308, 'elevation': -200.0}, + 'יבנה': {'lat': 31.8781, 'lng': 34.7378, 'elevation': 25.0}, + 'ירושלים': {'lat': 31.7683, 'lng': 35.2137, 'elevation': 800.0}, + 'כפר סבא': {'lat': 32.1742, 'lng': 34.9067, 'elevation': 75.0}, + 'כרמיאל': {'lat': 32.9186, 'lng': 35.2958, 'elevation': 300.0}, + 'לוד': {'lat': 31.9516, 'lng': 34.8958, 'elevation': 50.0}, 'מודיעין עילית': {'lat': 31.9254, 'lng': 35.0364, 'elevation': 400.0}, + 'מצפה רמון': {'lat': 30.6097, 'lng': 34.8017, 'elevation': 860.0}, + 'מעלה אדומים': {'lat': 31.7767, 'lng': 35.2973, 'elevation': 740.0}, + 'נתניה': {'lat': 32.3215, 'lng': 34.8532, 'elevation': 30.0}, + 'נצרת עילית': {'lat': 32.6992, 'lng': 35.3289, 'elevation': 400.0}, + 'עפולה': {'lat': 32.6078, 'lng': 35.2897, 'elevation': 60.0}, + 'פתח תקווה': {'lat': 32.0870, 'lng': 34.8873, 'elevation': 80.0}, 'צפת': {'lat': 32.9650, 'lng': 35.4951, 'elevation': 900.0}, - 'טבריה': {'lat': 32.7940, 'lng': 35.5308, 'elevation': -200.0}, - 'אילת': {'lat': 29.5581, 'lng': 34.9482, 'elevation': 12.0}, + 'קרית אונו': {'lat': 32.0539, 'lng': 34.8581, 'elevation': 75.0}, + 'קרית ארבע': {'lat': 31.5244, 'lng': 35.1031, 'elevation': 930.0}, + 'קרית גת': {'lat': 31.6100, 'lng': 34.7642, 'elevation': 68.0}, + 'קרית מלאכי': {'lat': 31.7289, 'lng': 34.7456, 'elevation': 108.0}, + 'קרית שמונה': {'lat': 33.2072, 'lng': 35.5692, 'elevation': 135.0}, + 'ראשון לציון': {'lat': 31.9642, 'lng': 34.8047, 'elevation': 68.0}, 'רחובות': {'lat': 31.8947, 'lng': 34.8096, 'elevation': 89.0}, - 'הרצליה': {'lat': 32.1624, 'lng': 34.8443, 'elevation': 40.0}, - 'רמת גן': {'lat': 32.0719, 'lng': 34.8244, 'elevation': 80.0}, - 'חולון': {'lat': 32.0117, 'lng': 34.7689, 'elevation': 54.0}, - 'בת ים': {'lat': 32.0167, 'lng': 34.7500, 'elevation': 5.0}, 'רמלה': {'lat': 31.9297, 'lng': 34.8667, 'elevation': 108.0}, - 'לוד': {'lat': 31.9516, 'lng': 34.8958, 'elevation': 50.0}, - 'אשקלון': {'lat': 31.6688, 'lng': 34.5742, 'elevation': 50.0}, + 'רמת גן': {'lat': 32.0719, 'lng': 34.8244, 'elevation': 80.0}, + 'רעננה': {'lat': 32.1847, 'lng': 34.8706, 'elevation': 45.0}, + 'תל אביב': {'lat': 32.0853, 'lng': 34.7818, 'elevation': 5.0}, + + // === ארצות הברית === + 'אטלנטה': {'lat': 33.7490, 'lng': -84.3880, 'elevation': 320.0}, + 'בוסטון': {'lat': 42.3601, 'lng': -71.0589, 'elevation': 43.0}, + 'בלטימור': {'lat': 39.2904, 'lng': -76.6122, 'elevation': 10.0}, + 'דטרויט': {'lat': 42.3314, 'lng': -83.0458, 'elevation': 183.0}, + 'דנבר': {'lat': 39.7392, 'lng': -104.9903, 'elevation': 1609.0}, + 'לאס וגאס': {'lat': 36.1699, 'lng': -115.1398, 'elevation': 610.0}, + 'לוס אנג\'לס': {'lat': 34.0522, 'lng': -118.2437, 'elevation': 71.0}, + 'מיאמי': {'lat': 25.7617, 'lng': -80.1918, 'elevation': 2.0}, + 'ניו יורק': {'lat': 40.7128, 'lng': -74.0060, 'elevation': 10.0}, + 'סיאטל': {'lat': 47.6062, 'lng': -122.3321, 'elevation': 56.0}, + 'סן פרנסיסקו': {'lat': 37.7749, 'lng': -122.4194, 'elevation': 16.0}, + 'פילדלפיה': {'lat': 39.9526, 'lng': -75.1652, 'elevation': 12.0}, + 'פיניקס': {'lat': 33.4484, 'lng': -112.0740, 'elevation': 331.0}, + 'קליבלנד': {'lat': 41.4993, 'lng': -81.6944, 'elevation': 199.0}, + 'שיקגו': {'lat': 41.8781, 'lng': -87.6298, 'elevation': 181.0}, + + // === קנדה === + 'אדמונטון': {'lat': 53.5461, 'lng': -113.4938, 'elevation': 645.0}, + 'אוטווה': {'lat': 45.4215, 'lng': -75.6972, 'elevation': 70.0}, + 'ונקובר': {'lat': 49.2827, 'lng': -123.1207, 'elevation': 70.0}, + 'טורונטו': {'lat': 43.6532, 'lng': -79.3832, 'elevation': 76.0}, + 'מונטריאול': {'lat': 45.5017, 'lng': -73.5673, 'elevation': 36.0}, + 'קלגרי': {'lat': 51.0447, 'lng': -114.0719, 'elevation': 1048.0}, + + // === בריטניה === + 'אדינבורו': {'lat': 55.9533, 'lng': -3.1883, 'elevation': 47.0}, + 'לונדון': {'lat': 51.5074, 'lng': -0.1278, 'elevation': 35.0}, + + // === צרפת === + 'פריז': {'lat': 48.8566, 'lng': 2.3522, 'elevation': 35.0}, + + // === גרמניה === + 'ברלין': {'lat': 52.5200, 'lng': 13.4050, 'elevation': 34.0}, + + // === איטליה === + 'מילאנו': {'lat': 45.4642, 'lng': 9.1900, 'elevation': 122.0}, + 'רומא': {'lat': 41.9028, 'lng': 12.4964, 'elevation': 21.0}, + + // === ספרד === + 'מדריד': {'lat': 40.4168, 'lng': -3.7038, 'elevation': 650.0}, + + // === הולנד === + 'אמסטרדם': {'lat': 52.3676, 'lng': 4.9041, 'elevation': -2.0}, + + // === שוויץ === + 'ציריך': {'lat': 47.3769, 'lng': 8.5417, 'elevation': 408.0}, + + // === אוסטריה === + 'וינה': {'lat': 48.2082, 'lng': 16.3738, 'elevation': 171.0}, + + // === הונגריה === + 'בודפשט': {'lat': 47.4979, 'lng': 19.0402, 'elevation': 102.0}, + + // === צ'כיה === + 'פראג': {'lat': 50.0755, 'lng': 14.4378, 'elevation': 200.0}, + + // === פולין === + 'ורשה': {'lat': 52.2297, 'lng': 21.0122, 'elevation': 100.0}, + + // === רוסיה === + 'מוסקבה': {'lat': 55.7558, 'lng': 37.6176, 'elevation': 156.0}, + + // === טורקיה === + 'איסטנבול': {'lat': 41.0082, 'lng': 28.9784, 'elevation': 39.0}, + + // === פורטוגל === + 'ליסבון': {'lat': 38.7223, 'lng': -9.1393, 'elevation': 2.0}, + + // === אירלנד === + 'דבלין': {'lat': 53.3498, 'lng': -6.2603, 'elevation': 85.0}, + + // === שוודיה === + 'סטוקהולם': {'lat': 59.3293, 'lng': 18.0686, 'elevation': 28.0}, + + // === דנמרק === + 'קופנהגן': {'lat': 55.6761, 'lng': 12.5683, 'elevation': 24.0}, + + // === פינלנד === + 'הלסינקי': {'lat': 60.1699, 'lng': 24.9384, 'elevation': 26.0}, + + // === נורווגיה === + 'אוסלו': {'lat': 59.9139, 'lng': 10.7522, 'elevation': 23.0}, + + // === איסלנד === + 'רייקיאוויק': {'lat': 64.1466, 'lng': -21.9426, 'elevation': 61.0}, + + // === ארגנטינה === + 'בואנוס איירס': {'lat': -34.6118, 'lng': -58.3960, 'elevation': 25.0}, + + // === ברזיל === + 'ריו דה ז\'נרו': {'lat': -22.9068, 'lng': -43.1729, 'elevation': 2.0}, + 'סאו פאולו': {'lat': -23.5505, 'lng': -46.6333, 'elevation': 760.0}, + + // === צ'ילה === + 'סנטיאגו': {'lat': -33.4489, 'lng': -70.6693, 'elevation': 520.0}, + + // === ונצואלה === + 'קראקס': {'lat': 10.4806, 'lng': -66.9036, 'elevation': 900.0}, + + // === פרו === + 'לימה': {'lat': -12.0464, 'lng': -77.0428, 'elevation': 154.0}, + + // === מקסיקו === + 'מקסיקו סיטי': {'lat': 19.4326, 'lng': -99.1332, 'elevation': 2240.0}, + + // === מרוקו === + 'קזבלנקה': {'lat': 33.5731, 'lng': -7.5898, 'elevation': 50.0}, + + // === דרום אפריקה === + 'יוהנסבורג': {'lat': -26.2041, 'lng': 28.0473, 'elevation': 1753.0}, + 'קייפטאון': {'lat': -33.9249, 'lng': 18.4241, 'elevation': 42.0}, + + // === מצרים === + 'אלכסנדריה': {'lat': 31.2001, 'lng': 29.9187, 'elevation': 12.0}, + 'קהיר': {'lat': 30.0444, 'lng': 31.2357, 'elevation': 74.0}, + + // === הודו === + 'דלהי': {'lat': 28.7041, 'lng': 77.1025, 'elevation': 216.0}, + 'מומבאי': {'lat': 19.0760, 'lng': 72.8777, 'elevation': 14.0}, + + // === תאילנד === + 'בנגקוק': {'lat': 13.7563, 'lng': 100.5018, 'elevation': 1.5}, + + // === סינגפור === + 'סינגפור': {'lat': 1.3521, 'lng': 103.8198, 'elevation': 15.0}, + + // === הונג קונג === + 'הונג קונג': {'lat': 22.3193, 'lng': 114.1694, 'elevation': 552.0}, + + // === יפן === + 'טוקיו': {'lat': 35.6762, 'lng': 139.6503, 'elevation': 40.0}, + + // === דרום קוריאה === + 'סיאול': {'lat': 37.5665, 'lng': 126.9780, 'elevation': 38.0}, + + // === סין === + 'בייג\'ינג': {'lat': 39.9042, 'lng': 116.4074, 'elevation': 43.5}, + 'שנחאי': {'lat': 31.2304, 'lng': 121.4737, 'elevation': 4.0}, + + // === איחוד האמירויות === + 'דובאי': {'lat': 25.2048, 'lng': 55.2708, 'elevation': 16.0}, + + // === כווית === + 'כווית': {'lat': 29.3759, 'lng': 47.9774, 'elevation': 55.0}, + + // === אוסטרליה === + 'בריסביין': {'lat': -27.4698, 'lng': 153.0251, 'elevation': 27.0}, + 'מלבורן': {'lat': -37.8136, 'lng': 144.9631, 'elevation': 31.0}, + 'פרת': {'lat': -31.9505, 'lng': 115.8605, 'elevation': 46.0}, + 'סידני': {'lat': -33.8688, 'lng': 151.2093, 'elevation': 58.0}, }; // Calculate daily times function @@ -271,26 +464,251 @@ Map _calculateDailyTimes(DateTime date, String city) { location.setDateTime(date); location.setElevation(elevation); - final zmanimCalendar = ZmanimCalendar.intGeolocation(location); + final zmanimCalendar = ComplexZmanimCalendar.intGeoLocation(location); - return { + final jewishCalendar = JewishCalendar.fromDateTime(date); + final Map times = { 'alos': _formatTime(zmanimCalendar.getAlosHashachar()!), 'sunrise': _formatTime(zmanimCalendar.getSunrise()!), - 'sofZmanShma': _formatTime(zmanimCalendar.getSofZmanShmaGRA()!), - 'sofZmanTfila': _formatTime(zmanimCalendar.getSofZmanTfilaGRA()!), + 'sofZmanShmaMGA': _formatTime(zmanimCalendar.getSofZmanShmaMGA()!), + 'sofZmanShmaGRA': _formatTime(zmanimCalendar.getSofZmanShmaGRA()!), + 'sofZmanTfilaMGA': _formatTime(zmanimCalendar.getSofZmanTfilaMGA()!), + 'sofZmanTfilaGRA': _formatTime(zmanimCalendar.getSofZmanTfilaGRA()!), 'chatzos': _formatTime(zmanimCalendar.getChatzos()!), + 'chatzosLayla': _formatTime(_calculateChatzosLayla(zmanimCalendar)), 'minchaGedola': _formatTime(zmanimCalendar.getMinchaGedola()!), 'minchaKetana': _formatTime(zmanimCalendar.getMinchaKetana()!), 'plagHamincha': _formatTime(zmanimCalendar.getPlagHamincha()!), 'sunset': _formatTime(zmanimCalendar.getSunset()!), + 'sunsetRT': _formatTime(_calculateSunsetRT(zmanimCalendar)), 'tzais': _formatTime(zmanimCalendar.getTzais()!), }; + + // הוספת זמנים מיוחדים לחגים + _addSpecialTimes(times, jewishCalendar, zmanimCalendar, city); + + return times; } String _formatTime(DateTime dt) { return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; } +// חישוב חצות לילה - 12 שעות אחרי חצות היום +DateTime _calculateChatzosLayla(ComplexZmanimCalendar zmanimCalendar) { + final chatzos = zmanimCalendar.getChatzos()!; + return chatzos.add(const Duration(hours: 12)); +} + +// חישוב שקיעה לפי רבנו תם - בין השמשות רבנו תם +DateTime _calculateSunsetRT(ComplexZmanimCalendar zmanimCalendar) { + // רבנו תם - 72 דקות אחרי השקיעה + final sunset = zmanimCalendar.getSunset()!; + return sunset.add(const Duration(minutes: 72)); +} + +// הוספת זמנים מיוחדים לחגים +void _addSpecialTimes(Map times, JewishCalendar jewishCalendar, + ComplexZmanimCalendar zmanimCalendar, String city) { + // זמנים מיוחדים לערב פסח + if (jewishCalendar.getYomTovIndex() == JewishCalendar.EREV_PESACH) { + // סוף זמן אכילת חמץ - מג"א (4 שעות זמניות) + final sofZmanAchilasChametzMGA = + zmanimCalendar.getSofZmanAchilasChametzMGA72Minutes(); + if (sofZmanAchilasChametzMGA != null) { + times['sofZmanAchilasChametzMGA'] = _formatTime(sofZmanAchilasChametzMGA); + } + + // סוף זמן אכילת חמץ - גר"א (4 שעות זמניות) + final sofZmanAchilasChametzGRA = + zmanimCalendar.getSofZmanAchilasChametzGRA(); + if (sofZmanAchilasChametzGRA != null) { + times['sofZmanAchilasChametzGRA'] = _formatTime(sofZmanAchilasChametzGRA); + } + + // סוף זמן ביעור חמץ - מג"א (5 שעות זמניות) + final sofZmanBiurChametzMGA = + zmanimCalendar.getSofZmanBiurChametzMGA72Minutes(); + if (sofZmanBiurChametzMGA != null) { + times['sofZmanBiurChametzMGA'] = _formatTime(sofZmanBiurChametzMGA); + } + + // סוף זמן ביעור חמץ - גר"א (5 שעות זמניות) + final sofZmanBiurChametzGRA = zmanimCalendar.getSofZmanBiurChametzGRA(); + if (sofZmanBiurChametzGRA != null) { + times['sofZmanBiurChametzGRA'] = _formatTime(sofZmanBiurChametzGRA); + } + } + + // זמני כניסת שבת/חג + if (jewishCalendar.getDayOfWeek() == 6 || jewishCalendar.isErevYomTov()) { + final candleLightingTime = + _calculateCandleLightingTime(zmanimCalendar, city); + if (candleLightingTime != null) { + times['candleLighting'] = _formatTime(candleLightingTime); + } + } + + // זמני יציאת שבת/חג + if (jewishCalendar.getDayOfWeek() == 7 || jewishCalendar.isYomTov()) { + final shabbosExitTime1 = _calculateShabbosExitTime1(zmanimCalendar); + final shabbosExitTime2 = _calculateShabbosExitTime2(zmanimCalendar); + + if (shabbosExitTime1 != null) { + times['shabbosExit1'] = _formatTime(shabbosExitTime1); + } + if (shabbosExitTime2 != null) { + times['shabbosExit2'] = _formatTime(shabbosExitTime2); + } + } + + // זמן ספירת העומר (מליל יום שני של פסח עד ערב שבועות) + if (jewishCalendar.getDayOfOmer() != -1) { + final omerCountingTime = _calculateOmerCountingTime(zmanimCalendar); + if (omerCountingTime != null) { + times['omerCounting'] = _formatTime(omerCountingTime); + } + } + + // זמני תענית + if (jewishCalendar.isTaanis() && + jewishCalendar.getYomTovIndex() != JewishCalendar.YOM_KIPPUR) { + final fastStartTime = _calculateFastStartTime(zmanimCalendar); + final fastEndTime = _calculateFastEndTime(zmanimCalendar); + + if (fastStartTime != null) { + times['fastStart'] = _formatTime(fastStartTime); + } + if (fastEndTime != null) { + times['fastEnd'] = _formatTime(fastEndTime); + } + } + + // זמן קידוש לבנה + if (_isKidushLevanaTime(jewishCalendar)) { + final kidushLevanaEarliest = + _calculateKidushLevanaEarliest(jewishCalendar, zmanimCalendar); + final kidushLevanaLatest = + _calculateKidushLevanaLatest(jewishCalendar, zmanimCalendar); + + if (kidushLevanaEarliest != null) { + times['kidushLevanaEarliest'] = _formatTime(kidushLevanaEarliest); + } + if (kidushLevanaLatest != null) { + times['kidushLevanaLatest'] = _formatTime(kidushLevanaLatest); + } + } + + // זמני חנוכה - הדלקת נרות + if (jewishCalendar.isChanukah()) { + final chanukahCandleLighting = + _calculateChanukahCandleLighting(zmanimCalendar); + if (chanukahCandleLighting != null) { + times['chanukahCandles'] = _formatTime(chanukahCandleLighting); + } + } + + // זמני קידוש לבנה + final tchilasKidushLevana = zmanimCalendar.getTchilasZmanKidushLevana3Days(); + final sofZmanKidushLevana = + zmanimCalendar.getSofZmanKidushLevanaBetweenMoldos(); + + if (tchilasKidushLevana != null) { + times['tchilasKidushLevana'] = _formatTime(tchilasKidushLevana); + } + if (sofZmanKidushLevana != null) { + times['sofZmanKidushLevana'] = _formatTime(sofZmanKidushLevana); + } +} + +// חישוב זמן הדלקת נרות לפי עיר +DateTime? _calculateCandleLightingTime( + ComplexZmanimCalendar zmanimCalendar, String city) { + final sunset = zmanimCalendar.getSunset(); + if (sunset == null) return null; + + int minutesBefore; + switch (city) { + case 'ירושלים': + minutesBefore = 40; + break; + case 'בני ברק': + minutesBefore = 22; + break; + case 'מודיעין עילית': + minutesBefore = 30; + break; + default: + minutesBefore = 30; + break; + } + + return sunset.subtract(Duration(minutes: minutesBefore)); +} + +// חישוב זמן יציאת שבת 1 - 34 דקות אחרי השקיעה +DateTime? _calculateShabbosExitTime1(ComplexZmanimCalendar zmanimCalendar) { + final sunset = zmanimCalendar.getSunset(); + if (sunset == null) return null; + + return sunset.add(const Duration(minutes: 34)); +} + +// חישוב זמן יציאת שבת 2 - צאת השבת חזו"א - 38 דקות אחרי השקיעה +DateTime? _calculateShabbosExitTime2(ComplexZmanimCalendar zmanimCalendar) { + final sunset = zmanimCalendar.getSunset(); + if (sunset == null) return null; + + return sunset.add(const Duration(minutes: 38)); +} + +// חישוב זמן ספירת העומר - אחרי צאת הכוכבים +DateTime? _calculateOmerCountingTime(ComplexZmanimCalendar zmanimCalendar) { + return zmanimCalendar.getTzais(); +} + +// חישוב תחילת תענית - עלות השחר +DateTime? _calculateFastStartTime(ComplexZmanimCalendar zmanimCalendar) { + return zmanimCalendar.getAlosHashachar(); +} + +// חישוב סיום תענית - צאת הכוכבים +DateTime? _calculateFastEndTime(ComplexZmanimCalendar zmanimCalendar) { + return zmanimCalendar.getTzais(); +} + +// בדיקה אם זה זמן קידוש לבנה (מיום 3 עד יום 15 בחודש) +bool _isKidushLevanaTime(JewishCalendar jewishCalendar) { + final dayOfMonth = jewishCalendar.getJewishDayOfMonth(); + return dayOfMonth >= 3 && dayOfMonth <= 15; +} + +// חישוב תחילת זמן קידוש לבנה - 3 ימים אחרי המולד +DateTime? _calculateKidushLevanaEarliest( + JewishCalendar jewishCalendar, ComplexZmanimCalendar zmanimCalendar) { + // זמן קידוש לבנה מתחיל 3 ימים אחרי המולד, אחרי צאת הכוכבים + if (jewishCalendar.getJewishDayOfMonth() == 3) { + return zmanimCalendar.getTzais(); + } + return null; +} + +// חישוב סוף זמן קידוש לבנה - 15 ימים אחרי המולד +DateTime? _calculateKidushLevanaLatest( + JewishCalendar jewishCalendar, ComplexZmanimCalendar zmanimCalendar) { + // זמן קידוש לבנה מסתיים ביום 15, לפני עלות השחר + if (jewishCalendar.getJewishDayOfMonth() == 15) { + return zmanimCalendar.getAlosHashachar(); + } + return null; +} + +// חישוב זמן הדלקת נרות חנוכה - אחרי צאת הכוכבים +DateTime? _calculateChanukahCandleLighting( + ComplexZmanimCalendar zmanimCalendar) { + return zmanimCalendar.getTzais(); +} + // Helper functions for CalendarType conversion CalendarType _stringToCalendarType(String value) { switch (value) { diff --git a/lib/navigation/calendar_widget.dart b/lib/navigation/calendar_widget.dart index ac5f3f9e0..faa4b5166 100644 --- a/lib/navigation/calendar_widget.dart +++ b/lib/navigation/calendar_widget.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:kosher_dart/kosher_dart.dart'; import 'calendar_cubit.dart'; // ודא שהנתיב נכון +import 'package:otzaria/daf_yomi/daf_yomi_helper.dart'; // הפכנו את הווידג'ט ל-Stateless כי הוא כבר לא מנהל מצב בעצמו. class CalendarWidget extends StatelessWidget { @@ -669,6 +670,8 @@ class CalendarWidget extends StatelessWidget { ), ), _buildTimesGrid(context, state), + const SizedBox(height: 16), + _buildDafYomiButtons(context, state), ], ), ), @@ -677,19 +680,134 @@ class CalendarWidget extends StatelessWidget { Widget _buildTimesGrid(BuildContext context, CalendarState state) { final dailyTimes = state.dailyTimes; - final timesList = [ + final jewishCalendar = + JewishCalendar.fromDateTime(state.selectedGregorianDate); + + // זמנים בסיסיים + final List> timesList = [ {'name': 'עלות השחר', 'time': dailyTimes['alos']}, {'name': 'זריחה', 'time': dailyTimes['sunrise']}, - {'name': 'סוף זמן קריאת שמע', 'time': dailyTimes['sofZmanShma']}, - {'name': 'סוף זמן תפילה', 'time': dailyTimes['sofZmanTfila']}, + {'name': 'סוף זמן ק"ש - מג"א', 'time': dailyTimes['sofZmanShmaMGA']}, + {'name': 'סוף זמן ק"ש - גר"א', 'time': dailyTimes['sofZmanShmaGRA']}, + {'name': 'סוף זמן תפילה - מג"א', 'time': dailyTimes['sofZmanTfilaMGA']}, + {'name': 'סוף זמן תפילה - גר"א', 'time': dailyTimes['sofZmanTfilaGRA']}, {'name': 'חצות היום', 'time': dailyTimes['chatzos']}, + {'name': 'חצות הלילה', 'time': dailyTimes['chatzosLayla']}, {'name': 'מנחה גדולה', 'time': dailyTimes['minchaGedola']}, {'name': 'מנחה קטנה', 'time': dailyTimes['minchaKetana']}, {'name': 'פלג המנחה', 'time': dailyTimes['plagHamincha']}, {'name': 'שקיעה', 'time': dailyTimes['sunset']}, + {'name': 'שקיעה ר"ת', 'time': dailyTimes['sunsetRT']}, {'name': 'צאת הכוכבים', 'time': dailyTimes['tzais']}, ]; + // הוספת זמנים מיוחדים לערב פסח + if (jewishCalendar.getYomTovIndex() == JewishCalendar.EREV_PESACH) { + timesList.addAll([ + { + 'name': 'סוף זמן אכילת חמץ - מג"א', + 'time': dailyTimes['sofZmanAchilasChametzMGA'] + }, + { + 'name': 'סוף זמן אכילת חמץ - גר"א', + 'time': dailyTimes['sofZmanAchilasChametzGRA'] + }, + { + 'name': 'סוף זמן ביעור חמץ - מג"א', + 'time': dailyTimes['sofZmanBiurChametzMGA'] + }, + { + 'name': 'סוף זמן ביעור חמץ - גר"א', + 'time': dailyTimes['sofZmanBiurChametzGRA'] + }, + ]); + } + + // הוספת זמני כניסת שבת/חג + if (jewishCalendar.getDayOfWeek() == 6 || jewishCalendar.isErevYomTov()) { + timesList + .add({'name': 'הדלקת נרות', 'time': dailyTimes['candleLighting']}); + } + + // הוספת זמני יציאת שבת/חג + if (jewishCalendar.getDayOfWeek() == 7 || jewishCalendar.isYomTov()) { + final String exitName; + final String exitName2; + + if (jewishCalendar.getDayOfWeek() == 7 && !jewishCalendar.isYomTov()) { + exitName = 'יציאת שבת'; + exitName2 = 'צאת השבת חזו"א'; + } else if (jewishCalendar.isYomTov()) { + final holidayName = _getHolidayName(jewishCalendar); + exitName = 'יציאת $holidayName'; + exitName2 = 'יציאת $holidayName חזו"א'; + } else { + exitName = 'יציאת שבת'; + exitName2 = 'צאת השבת חזו"א'; + } + + timesList.addAll([ + {'name': exitName, 'time': dailyTimes['shabbosExit1']}, + {'name': exitName2, 'time': dailyTimes['shabbosExit2']}, + ]); + } + + // הוספת זמן ספירת העומר + if (jewishCalendar.getDayOfOmer() != -1) { + timesList + .add({'name': 'ספירת העומר', 'time': dailyTimes['omerCounting']}); + } + + // הוספת זמני תענית + if (jewishCalendar.isTaanis() && + jewishCalendar.getYomTovIndex() != JewishCalendar.YOM_KIPPUR) { + timesList.addAll([ + {'name': 'תחילת התענית', 'time': dailyTimes['fastStart']}, + {'name': 'סיום התענית', 'time': dailyTimes['fastEnd']}, + ]); + } + + // הוספת זמני קידוש לבנה + if (dailyTimes['kidushLevanaEarliest'] != null || + dailyTimes['kidushLevanaLatest'] != null) { + if (dailyTimes['kidushLevanaEarliest'] != null) { + timesList.add({ + 'name': 'תחילת זמן קידוש לבנה', + 'time': dailyTimes['kidushLevanaEarliest'] + }); + } + if (dailyTimes['kidushLevanaLatest'] != null) { + timesList.add({ + 'name': 'סוף זמן קידוש לבנה', + 'time': dailyTimes['kidushLevanaLatest'] + }); + } + } + + // הוספת זמני חנוכה + if (jewishCalendar.isChanukah()) { + timesList.add( + {'name': 'הדלקת נרות חנוכה', 'time': dailyTimes['chanukahCandles']}); + } + + // הוספת זמני קידוש לבנה + if (dailyTimes['tchilasKidushLevana'] != null) { + timesList.add({ + 'name': 'תחילת זמן קידוש לבנה', + 'time': dailyTimes['tchilasKidushLevana'] + }); + } + if (dailyTimes['sofZmanKidushLevana'] != null) { + timesList.add({ + 'name': 'סוף זמן קידוש לבנה', + 'time': dailyTimes['sofZmanKidushLevana'] + }); + } + + // סינון זמנים שלא קיימים + final filteredTimesList = + timesList.where((timeData) => timeData['time'] != null).toList(); + return GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), @@ -699,14 +817,20 @@ class CalendarWidget extends StatelessWidget { crossAxisSpacing: 8, mainAxisSpacing: 8, ), - itemCount: timesList.length, + itemCount: filteredTimesList.length, itemBuilder: (context, index) { - final timeData = timesList[index]; + final timeData = filteredTimesList[index]; + final isSpecialTime = _isSpecialTime(timeData['name']!); + return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: Colors.grey[100], + color: + isSpecialTime ? Colors.orange.withAlpha(51) : Colors.grey[100], borderRadius: BorderRadius.circular(8), + border: isSpecialTime + ? Border.all(color: Colors.orange, width: 1) + : null, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -714,16 +838,22 @@ class CalendarWidget extends StatelessWidget { children: [ Text( timeData['name']!, - style: - const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: isSpecialTime ? Colors.orange.shade800 : null, + ), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( timeData['time'] ?? '--:--', - style: - const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: isSpecialTime ? Colors.orange.shade800 : null, + ), ), ], ), @@ -732,6 +862,127 @@ class CalendarWidget extends StatelessWidget { ); } + Widget _buildDafYomiButtons(BuildContext context, CalendarState state) { + final jewishCalendar = + JewishCalendar.fromDateTime(state.selectedGregorianDate); + + // חישוב דף יומי בבלי + final dafYomiBavli = YomiCalculator.getDafYomiBavli(jewishCalendar); + final bavliTractate = dafYomiBavli.getMasechta(); + final bavliDaf = dafYomiBavli.getDaf(); + + // חישוב דף יומי ירושלמי + final dafYomiYerushalmi = + YerushalmiYomiCalculator.getDafYomiYerushalmi(jewishCalendar); + final yerushalmiTractate = dafYomiYerushalmi.getMasechta(); + final yerushalmiDaf = dafYomiYerushalmi.getDaf(); + + return Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () { + openDafYomiBook( + context, bavliTractate, ' ${_formatDafNumber(bavliDaf)}.'); + }, + icon: const Icon(Icons.book), + label: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'דף היומי בבלי', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + Text( + '$bavliTractate ${_formatDafNumber(bavliDaf)}', + style: const TextStyle(fontSize: 10), + ), + ], + ), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + onPressed: () { + openDafYomiYerushalmiBook(context, yerushalmiTractate, + ' ${_formatDafNumber(yerushalmiDaf)}.'); + }, + icon: const Icon(Icons.menu_book), + label: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'דף היומי ירושלמי', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + Text( + '$yerushalmiTractate ${_formatDafNumber(yerushalmiDaf)}', + style: const TextStyle(fontSize: 10), + ), + ], + ), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + ), + ), + ), + ], + ); + } + + String _formatDafNumber(int daf) { + return HebrewDateFormatter() + .formatHebrewNumber(daf) + .replaceAll('״', '') + .replaceAll('׳', ''); + } + + bool _isSpecialTime(String timeName) { + return timeName.contains('חמץ') || + timeName.contains('הדלקת נרות') || + timeName.contains('יציאת') || + timeName.contains('צאת השבת') || + timeName.contains('ספירת העומר') || + timeName.contains('תענית') || + timeName.contains('חנוכה') || + timeName.contains('קידוש לבנה'); + } + + String _getHolidayName(JewishCalendar jewishCalendar) { + final yomTovIndex = jewishCalendar.getYomTovIndex(); + + switch (yomTovIndex) { + case JewishCalendar.ROSH_HASHANA: + return 'ראש השנה'; + case JewishCalendar.YOM_KIPPUR: + return 'יום כיפור'; + case JewishCalendar.SUCCOS: + return 'חג הסוכות'; + case JewishCalendar.SHEMINI_ATZERES: + return 'שמיני עצרת'; + case JewishCalendar.SIMCHAS_TORAH: + return 'שמחת תורה'; + case JewishCalendar.PESACH: + return 'חג הפסח'; + case JewishCalendar.SHAVUOS: + return 'חג השבועות'; + case JewishCalendar.CHANUKAH: + return 'חנוכה'; + case 17: // HOSHANA_RABBA + return 'הושענא רבה'; + case 2: // CHOL_HAMOED_PESACH + return 'חול המועד פסח'; + case 16: // CHOL_HAMOED_SUCCOS + return 'חול המועד סוכות'; + default: + return 'חג'; + } + } + // פונקציות העזר שלא תלויות במצב נשארות כאן String _getCurrentMonthYearText(CalendarState state) { if (state.calendarType == CalendarType.gregorian) { @@ -827,7 +1078,7 @@ class CalendarWidget extends StatelessWidget { context: context, builder: (dialogContext) { return StatefulBuilder( - builder: (context, setState) { + builder: (builderContext, setState) { return AlertDialog( title: const Text('קפוץ לתאריך'), content: SizedBox( From c41739b954c5e40acb2dd5545f2864d19424f1a2 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 15 Aug 2025 16:53:48 +0300 Subject: [PATCH 126/197] =?UTF-8?q?=D7=94=D7=A2=D7=A8=D7=95=D7=AA=20=D7=90?= =?UTF-8?q?=D7=99=D7=A9=D7=99=D7=95=D7=AA=20v1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .kiro/specs/personal-notes/design.md | 1311 +++++++++++++++++ .kiro/specs/personal-notes/requirements.md | 231 +++ .kiro/specs/personal-notes/tasks.md | 506 +++++++ docs/api_reference.md | 651 ++++++++ docs/user_guide.md | 219 +++ lib/main.dart | 22 + lib/notes/bloc/notes_bloc.dart | 519 +++++++ lib/notes/bloc/notes_event.dart | 245 +++ lib/notes/bloc/notes_state.dart | 348 +++++ lib/notes/config/notes_config.dart | 229 +++ lib/notes/data/database_schema.dart | 162 ++ lib/notes/data/notes_data_provider.dart | 355 +++++ lib/notes/models/anchor_models.dart | 288 ++++ lib/notes/models/note.dart | 341 +++++ lib/notes/notes_system.dart | 157 ++ lib/notes/repository/notes_repository.dart | 488 ++++++ .../services/advanced_orphan_manager.dart | 432 ++++++ .../services/advanced_search_engine.dart | 617 ++++++++ lib/notes/services/anchoring_service.dart | 133 ++ lib/notes/services/background_processor.dart | 248 ++++ .../services/canonical_text_service.dart | 90 ++ .../services/filesystem_notes_extension.dart | 313 ++++ lib/notes/services/fuzzy_matcher.dart | 225 +++ lib/notes/services/hash_generator.dart | 74 + lib/notes/services/import_export_service.dart | 452 ++++++ .../services/notes_integration_service.dart | 429 ++++++ lib/notes/services/notes_telemetry.dart | 196 +++ lib/notes/services/performance_optimizer.dart | 328 +++++ lib/notes/services/search_index.dart | 326 ++++ lib/notes/services/smart_batch_processor.dart | 343 +++++ lib/notes/services/text_normalizer.dart | 212 +++ lib/notes/utils/text_utils.dart | 231 +++ lib/notes/widgets/note_editor_dialog.dart | 310 ++++ lib/notes/widgets/note_highlight.dart | 223 +++ .../widgets/notes_context_menu_extension.dart | 198 +++ .../widgets/notes_performance_dashboard.dart | 319 ++++ lib/notes/widgets/notes_sidebar.dart | 663 +++++++++ lib/notes/widgets/orphan_notes_manager.dart | 557 +++++++ lib/text_book/bloc/text_book_bloc.dart | 41 + lib/text_book/bloc/text_book_event.dart | 25 + lib/text_book/bloc/text_book_state.dart | 24 + .../combined_view/combined_book_screen.dart | 157 +- .../view/splited_view/simple_book_view.dart | 158 +- lib/text_book/view/text_book_screen.dart | 89 ++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 66 +- pubspec.yaml | 6 +- .../acceptance/notes_acceptance_test.dart | 643 ++++++++ test/notes/bloc/notes_bloc_test.dart | 255 ++++ .../integration/notes_integration_test.dart | 510 +++++++ test/notes/models/note_test.dart | 170 +++ .../performance/notes_performance_test.dart | 511 +++++++ .../services/canonical_text_service_test.dart | 187 +++ test/notes/services/fuzzy_matcher_test.dart | 240 +++ test/notes/services/hash_generator_test.dart | 249 ++++ test/notes/services/text_normalizer_test.dart | 213 +++ .../mock_canonical_text_service.dart | 176 +++ test/notes/test_helpers/test_setup.dart | 209 +++ 58 files changed, 16914 insertions(+), 8 deletions(-) create mode 100644 .kiro/specs/personal-notes/design.md create mode 100644 .kiro/specs/personal-notes/requirements.md create mode 100644 .kiro/specs/personal-notes/tasks.md create mode 100644 docs/api_reference.md create mode 100644 docs/user_guide.md create mode 100644 lib/notes/bloc/notes_bloc.dart create mode 100644 lib/notes/bloc/notes_event.dart create mode 100644 lib/notes/bloc/notes_state.dart create mode 100644 lib/notes/config/notes_config.dart create mode 100644 lib/notes/data/database_schema.dart create mode 100644 lib/notes/data/notes_data_provider.dart create mode 100644 lib/notes/models/anchor_models.dart create mode 100644 lib/notes/models/note.dart create mode 100644 lib/notes/notes_system.dart create mode 100644 lib/notes/repository/notes_repository.dart create mode 100644 lib/notes/services/advanced_orphan_manager.dart create mode 100644 lib/notes/services/advanced_search_engine.dart create mode 100644 lib/notes/services/anchoring_service.dart create mode 100644 lib/notes/services/background_processor.dart create mode 100644 lib/notes/services/canonical_text_service.dart create mode 100644 lib/notes/services/filesystem_notes_extension.dart create mode 100644 lib/notes/services/fuzzy_matcher.dart create mode 100644 lib/notes/services/hash_generator.dart create mode 100644 lib/notes/services/import_export_service.dart create mode 100644 lib/notes/services/notes_integration_service.dart create mode 100644 lib/notes/services/notes_telemetry.dart create mode 100644 lib/notes/services/performance_optimizer.dart create mode 100644 lib/notes/services/search_index.dart create mode 100644 lib/notes/services/smart_batch_processor.dart create mode 100644 lib/notes/services/text_normalizer.dart create mode 100644 lib/notes/utils/text_utils.dart create mode 100644 lib/notes/widgets/note_editor_dialog.dart create mode 100644 lib/notes/widgets/note_highlight.dart create mode 100644 lib/notes/widgets/notes_context_menu_extension.dart create mode 100644 lib/notes/widgets/notes_performance_dashboard.dart create mode 100644 lib/notes/widgets/notes_sidebar.dart create mode 100644 lib/notes/widgets/orphan_notes_manager.dart create mode 100644 test/notes/acceptance/notes_acceptance_test.dart create mode 100644 test/notes/bloc/notes_bloc_test.dart create mode 100644 test/notes/integration/notes_integration_test.dart create mode 100644 test/notes/models/note_test.dart create mode 100644 test/notes/performance/notes_performance_test.dart create mode 100644 test/notes/services/canonical_text_service_test.dart create mode 100644 test/notes/services/fuzzy_matcher_test.dart create mode 100644 test/notes/services/hash_generator_test.dart create mode 100644 test/notes/services/text_normalizer_test.dart create mode 100644 test/notes/test_helpers/mock_canonical_text_service.dart create mode 100644 test/notes/test_helpers/test_setup.dart diff --git a/.kiro/specs/personal-notes/design.md b/.kiro/specs/personal-notes/design.md new file mode 100644 index 000000000..a4eb594ae --- /dev/null +++ b/.kiro/specs/personal-notes/design.md @@ -0,0 +1,1311 @@ +# Design Document - Personal Notes System + +## Overview + +מערכת ההערות האישיות תאפשר למשתמשים להוסיף, לערוך ולנהל הערות אישיות על טקסטים בספרים השונים. המערכת תפתור את הבעיה הקיימת של אי-דיוק במיקום ההערות על ידי מעבר ממודל "בלוקים/שורות" למודל מסמך קנוני עם מערכת עיגון מתקדמת. + +המערכת תשתלב בארכיטקטורה הקיימת של האפליקציה שמבוססת על Flutter עם BLoC pattern, ותשתמש במסד נתונים מקומי לשמירת ההערות. + +## Architecture + +### High-Level Architecture + +```mermaid +graph TB + UI[UI Layer - Flutter Widgets] --> BLoC[BLoC Layer - State Management] + BLoC --> Repository[Repository Layer] + Repository --> DataProvider[Data Provider Layer] + DataProvider --> Storage[(Local Storage - SQLite)] + DataProvider --> FileSystem[(File System - Books)] + + subgraph "Notes System" + NotesUI[Notes UI Components] + NotesBloc[Notes BLoC] + NotesRepo[Notes Repository] + NotesData[Notes Data Provider] + CanonicalService[Canonical Text Service] + AnchorService[Anchoring Service] + end + + UI --> NotesUI + BLoC --> NotesBloc + Repository --> NotesRepo + DataProvider --> NotesData + NotesData --> CanonicalService + NotesData --> AnchorService +``` + +### Integration with Existing System + +המערכת תשתלב עם הרכיבים הקיימים: + +1. **TextBookBloc** - יורחב לכלול מצב הערות +2. **SimpleBookView** - יעודכן להציג הערות ולאפשר יצירתן +3. **FileSystemData** - יורחב לתמוך במסמכים קנוניים +4. **TextBookRepository** - יורחב לעבוד עם מערכת ההערות + +## Components and Interfaces + +### Core Components + +#### 1. Canonical Text Service +```dart +class CanonicalTextService { + /// יוצר מסמך קנוני מטקסט ספר + Future createCanonicalDocument(String bookTitle); + + /// מנרמל טקסט לפי התקן המוגדר + String normalizeText(String text); + + /// מחשב גרסת מסמך (checksum) + String calculateDocumentVersion(String canonicalText); + + /// מחלץ חלון הקשר מטקסט + ContextWindow extractContextWindow(String text, int start, int end); +} +``` + +#### 2. Anchoring Service +```dart +class AnchoringService { + /// יוצר עוגן חדש להערה + AnchorData createAnchor(String bookId, String canonicalText, + int charStart, int charEnd); + + /// מבצע re-anchoring להערה קיימת + Future reanchorNote(Note note, CanonicalDocument document); + + /// מחפש מיקום מדויק בטקסט + List findExactMatch(String textHash, CanonicalDocument doc); + + /// מחפש לפי הקשר + List findByContext(String beforeHash, String afterHash, + CanonicalDocument doc); + + /// מחפש דמיון מטושטש + List findFuzzyMatch(String normalizedText, + CanonicalDocument doc); +} +``` + +#### 3. Notes Repository +```dart +class NotesRepository { + /// יוצר הערה חדשה + Future createNote(CreateNoteRequest request); + + /// מעדכן הערה קיימת + Future updateNote(String noteId, UpdateNoteRequest request); + + /// מוחק הערה + Future deleteNote(String noteId); + + /// מחזיר הערות לספר + Future> getNotesForBook(String bookId); + + /// מחפש הערות + Future> searchNotes(String query); + + /// מייצא הערות + Future exportNotes(ExportOptions options); + + /// מייבא הערות + Future importNotes(String data, ImportOptions options); +} +``` + +### Data Models + +#### Note Model +```dart +class Note { + final String id; + final String bookId; + final String docVersionId; + final List? logicalPath; + final int charStart; + final int charEnd; + final String selectedTextNormalized; + final String textHash; + final String contextBefore; + final String contextAfter; + final String contextBeforeHash; + final String contextAfterHash; + final int rollingBefore; + final int rollingAfter; + final NoteStatus status; + final String contentMarkdown; + final String authorUserId; + final NotePrivacy privacy; + final List tags; + final DateTime createdAt; + final DateTime updatedAt; +} + +enum NoteStatus { anchored, shifted, orphan } +enum NotePrivacy { private, shared } +``` + +#### Canonical Document Model +```dart +class CanonicalDocument { + final String bookId; + final String versionId; + final String canonicalText; + final Map> textHashIndex; + final Map> contextHashIndex; + final Map> rollingHashIndex; + final List? logicalStructure; +} +``` + +#### Anchor Candidate Model +```dart +class AnchorCandidate { + final int start; + final int end; + final double score; // 0.0 to 1.0 + final String strategy; // "exact" | "context" | "fuzzy" + + const AnchorCandidate(this.start, this.end, this.score, this.strategy); +} +``` + +#### Anchor Data Model +```dart +class AnchorData { + final int charStart; + final int charEnd; + final String textHash; + final String contextBefore; + final String contextAfter; + final String contextBeforeHash; + final String contextAfterHash; + final int rollingBefore; + final int rollingAfter; + final NoteStatus status; +} +``` + +#### Anchor Result Model +```dart +class AnchorResult { + final NoteStatus status; // anchored|shifted|orphan + final int? start; + final int? end; + final List candidates; + final String? errorMessage; + + const AnchorResult( + this.status, { + this.start, + this.end, + this.candidates = const [], + this.errorMessage, + }); + + bool get isSuccess => status != NoteStatus.orphan || candidates.isNotEmpty; + bool get hasMultipleCandidates => candidates.length > 1; +} +``` + +## Data Models + +### Database Schema + +המערכת תשתמש ב-SQLite עם הטבלאות הבאות: + +#### Notes Table +```sql +CREATE TABLE notes ( + note_id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + doc_version_id TEXT NOT NULL, + logical_path TEXT, + char_start INTEGER NOT NULL, + char_end INTEGER NOT NULL, + selected_text_normalized TEXT NOT NULL, + text_hash TEXT NOT NULL, + ctx_before TEXT NOT NULL, + ctx_after TEXT NOT NULL, + ctx_before_hash TEXT NOT NULL, + ctx_after_hash TEXT NOT NULL, + rolling_before INTEGER NOT NULL, + rolling_after INTEGER NOT NULL, + status TEXT NOT NULL CHECK (status IN ('anchored', 'shifted', 'orphan')), + content_markdown TEXT NOT NULL, + author_user_id TEXT NOT NULL, + privacy TEXT NOT NULL CHECK (privacy IN ('private', 'shared')), + tags TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +``` + +#### Canonical Documents Table +```sql +CREATE TABLE canonical_documents ( + id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + version_id TEXT NOT NULL, + canonical_text TEXT NOT NULL, + text_hash_index TEXT NOT NULL, -- JSON + context_hash_index TEXT NOT NULL, -- JSON + rolling_hash_index TEXT NOT NULL, -- JSON + logical_structure TEXT, -- JSON + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(book_id, version_id) +); +``` + +#### Database Indexes +```sql +-- Performance indexes +CREATE INDEX idx_notes_book_id ON notes(book_id); +CREATE INDEX idx_notes_doc_version ON notes(doc_version_id); +CREATE INDEX idx_notes_text_hash ON notes(text_hash); +CREATE INDEX idx_notes_ctx_hashes ON notes(ctx_before_hash, ctx_after_hash); +CREATE INDEX idx_notes_author ON notes(author_user_id); +CREATE INDEX idx_notes_status ON notes(status); +CREATE INDEX idx_notes_updated ON notes(updated_at); + +-- Full-text search for Hebrew content +CREATE VIRTUAL TABLE notes_fts USING fts5( + content_markdown, tags, selected_text_normalized, + content='notes', content_rowid='rowid' +); + +-- Triggers to sync FTS table +CREATE TRIGGER notes_fts_insert AFTER INSERT ON notes BEGIN + INSERT INTO notes_fts(rowid, content_markdown, tags, selected_text_normalized) + VALUES (new.rowid, new.content_markdown, new.tags, new.selected_text_normalized); +END; + +CREATE TRIGGER notes_fts_delete AFTER DELETE ON notes BEGIN + DELETE FROM notes_fts WHERE rowid = old.rowid; +END; + +CREATE TRIGGER notes_fts_update AFTER UPDATE ON notes BEGIN + DELETE FROM notes_fts WHERE rowid = old.rowid; + INSERT INTO notes_fts(rowid, content_markdown, tags, selected_text_normalized) + VALUES (new.rowid, new.content_markdown, new.tags, new.selected_text_normalized); +END; +``` + +#### SQLite Configuration +```sql +-- Performance optimizations +PRAGMA journal_mode=WAL; +PRAGMA synchronous=NORMAL; +PRAGMA temp_store=MEMORY; +PRAGMA cache_size=10000; +PRAGMA foreign_keys=ON; +PRAGMA busy_timeout=5000; +PRAGMA analysis_limit=400; + +-- Run after initial data population +ANALYZE; +``` + +### File Storage Structure + +``` +.kiro/ +├── notes/ +│ ├── notes.db # SQLite database +│ ├── exports/ # Exported notes +│ └── backups/ # Automatic backups +└── canonical/ + ├── documents/ # Cached canonical documents + └── indexes/ # Pre-built search indexes +```## Error Handling + +### Error Types and Handling Strategy + +#### 1. Anchoring Errors +```dart +enum AnchoringError { + documentNotFound, + multipleMatches, + noMatchFound, + corruptedAnchor, + versionMismatch +} + +class AnchoringException implements Exception { + final AnchoringError type; + final String message; + final Note? note; + final List? candidates; +} +``` + +#### 2. Storage Errors +```dart +enum StorageError { + databaseCorrupted, + diskSpaceFull, + permissionDenied, + networkError +} + +class StorageException implements Exception { + final StorageError type; + final String message; + final String? filePath; +} +``` + +#### 3. Error Recovery Strategies + +**Anchoring Failures:** +- Multiple matches → Present user with candidates dialog +- No match found → Mark as orphan and add to orphans list +- Corrupted anchor → Attempt fuzzy matching, fallback to orphan +- Version mismatch → Trigger re-anchoring process + +**Storage Failures:** +- Database corruption → Restore from backup, rebuild if necessary +- Disk space → Prompt user to free space or change location +- Permission denied → Request permissions or suggest alternative location + +### Logging and Monitoring + +```dart +class NotesLogger { + static void logAnchoringAttempt(String noteId, AnchoringResult result); + static void logPerformanceMetric(String operation, Duration duration); + static void logError(Exception error, StackTrace stackTrace); + static void logUserAction(String action, Map context); +} +``` + +## Testing Strategy + +### Unit Tests + +#### 1. Text Normalization Tests +```dart +group('Text Normalization', () { + test('should normalize multiple spaces to single space', () { + expect(normalizeText('שלום עולם'), equals('שלום עולם')); + }); + + test('should handle Hebrew punctuation consistently', () { + expect(normalizeText('שלום, עולם!'), equals('שלום, עולם!')); + }); + + test('should preserve nikud when configured', () { + expect(normalizeText('שָׁלוֹם עוֹלָם'), equals('שָׁלוֹם עוֹלָם')); + }); +}); +``` + +#### 2. Anchoring Algorithm Tests +```dart +group('Anchoring Service', () { + test('should find exact match by text hash', () async { + final result = await anchoringService.reanchorNote(note, document); + expect(result.status, equals(AnchorStatus.exact)); + }); + + test('should find match by context when text changed', () async { + final result = await anchoringService.reanchorNote(modifiedNote, document); + expect(result.status, equals(AnchorStatus.contextMatch)); + }); + + test('should mark as orphan when no match found', () async { + final result = await anchoringService.reanchorNote(orphanNote, document); + expect(result.status, equals(AnchorStatus.orphan)); + }); +}); +``` + +#### 3. Performance Tests +```dart +group('Performance Tests', () { + test('should reanchor 100 notes within 5 seconds', () async { + final stopwatch = Stopwatch()..start(); + await anchoringService.reanchorMultipleNotes(notes); + stopwatch.stop(); + expect(stopwatch.elapsedMilliseconds, lessThan(5000)); + }); + + test('should not delay page load by more than 16ms', () async { + final range = VisibleCharRange(0, 1000); + final stopwatch = Stopwatch()..start(); + await notesService.loadNotesForVisibleRange(bookId, range); + stopwatch.stop(); + expect(stopwatch.elapsedMilliseconds, lessThan(16)); + }); +}); +``` + +### Integration Tests + +#### 1. End-to-End Note Creation +```dart +testWidgets('should create note from text selection', (tester) async { + await tester.pumpWidget(app); + + // Select text + await tester.longPress(find.text('טקסט לדוגמה')); + await tester.pumpAndSettle(); + + // Add note + await tester.tap(find.text('הוסף הערה')); + await tester.pumpAndSettle(); + + // Enter note content + await tester.enterText(find.byType(TextField), 'הערה חשובה'); + await tester.tap(find.text('שמור')); + await tester.pumpAndSettle(); + + // Verify note appears + expect(find.byType(NoteHighlight), findsOneWidget); +}); +``` + +#### 2. Migration Testing +```dart +testWidgets('should migrate existing bookmarks to notes', (tester) async { + // Setup old bookmark data + await setupLegacyBookmarks(); + + // Run migration + await migrationService.migrateBookmarksToNotes(); + + // Verify migration results + final notes = await notesRepository.getAllNotes(); + expect(notes.length, equals(expectedCount)); + expect(notes.every((n) => n.status != NoteStatus.orphan), isTrue); +}); +``` + +### Acceptance Tests + +#### 1. Accuracy Requirements +- 98% of notes remain "exact" after 5% line changes +- 100% of notes remain "exact" after whitespace-only changes +- Deleted text sections are properly marked as "orphan" + +#### 2. Performance Requirements +- Re-anchoring: ≤ 50ms per note average +- Page load delay: ≤ 16ms for notes loading +- Search response: ≤ 200ms for 1000+ notes + +#### 3. User Experience Requirements +- Note creation: ≤ 3 clicks from text selection +- Note editing: In-place editing without navigation +- Orphan resolution: Clear visual indicators and easy resolution flow + +## UI/UX Design + +### Visual Design Principles + +#### 1. Non-Intrusive Integration +- הערות יוצגו כהדגשה דקה שלא מפריעה לקריאה +- צבעים עדינים שמתאימים לערכת הנושא הקיימת +- אנימציות חלקות למעברים + +#### 2. Contextual Actions +- תפריט הקשר יורחב לכלול פעולות הערות +- כפתורי פעולה יופיעו רק כשרלוונטי +- מקשי קיצור לפעולות נפוצות + +#### 3. Status Indicators +```dart +enum NoteStatusIndicator { + exact, // ירוק - מיקום מדויק + shifted, // כתום - מוזז אך אותר + orphan, // אדום - נדרש אימות ידני + loading // אפור - בטעינה +} +``` + +### Component Specifications + +#### 1. Note Highlight Widget +```dart +class NoteHighlight extends StatelessWidget { + final Note note; + final Widget child; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + + // Visual properties based on note status + Color get highlightColor => switch (note.status) { + NoteStatus.anchored => Colors.blue.withOpacity(0.2), + NoteStatus.shifted => Colors.orange.withOpacity(0.2), + NoteStatus.orphan => Colors.red.withOpacity(0.2), + }; +} +``` + +#### 2. Note Editor Dialog +```dart +class NoteEditorDialog extends StatefulWidget { + final Note? existingNote; + final String? selectedText; + final Function(Note) onSave; + final VoidCallback? onDelete; +} +``` + +#### 3. Notes Sidebar +```dart +class NotesSidebar extends StatefulWidget { + final String bookId; + final Function(Note) onNoteSelected; + final TextEditingController searchController; +} +``` + +#### 4. Orphan Notes Manager +```dart +class OrphanNotesManager extends StatefulWidget { + final List orphanNotes; + final Function(Note, AnchorCandidate) onResolveOrphan; +} +``` + +### User Interaction Flows + +#### 1. Creating a Note +```mermaid +sequenceDiagram + participant U as User + participant UI as UI + participant B as BLoC + participant S as Service + + U->>UI: Select text + UI->>UI: Show context menu + U->>UI: Click "Add Note" + UI->>UI: Show note editor + U->>UI: Enter note content + U->>UI: Click save + UI->>B: CreateNoteEvent + B->>S: createNote() + S->>S: Generate anchor + S->>S: Save to database + S-->>B: Note created + B-->>UI: NoteCreated state + UI->>UI: Show highlight +``` + +#### 2. Resolving Orphan Notes +```mermaid +sequenceDiagram + participant U as User + participant UI as Orphan Manager + participant B as BLoC + participant S as Anchoring Service + + U->>UI: Open orphan manager + UI->>B: LoadOrphansEvent + B->>S: getOrphanNotes() + S-->>B: List of orphans + B-->>UI: OrphansLoaded state + UI->>UI: Show orphan list + U->>UI: Select orphan + UI->>B: FindCandidatesEvent + B->>S: findAnchorCandidates() + S-->>B: List of candidates + B-->>UI: CandidatesFound state + UI->>UI: Show candidates + U->>UI: Select candidate + UI->>B: ResolveOrphanEvent + B->>S: resolveOrphan() + S-->>B: Orphan resolved + B-->>UI: OrphanResolved state +```## + Implementation Details + +### Text Normalization Algorithm + +```dart +class TextNormalizer { + static final Map _quoteMap = { + '\u201C': '"', '\u201D': '"', // " " + '\u201E': '"', '\u00AB': '"', '\u00BB': '"', // „ « » + '\u2018': "'", '\u2019': "'", // ' ' + '\u05F4': '"', '\u05F3': "'", // ״ ׳ (Hebrew) + }; + + static String normalize(String text) { + // 1. Replace multiple whitespace with single space + text = text.replaceAll(RegExp(r'\s+'), ' '); + + // 2. Remove directional marks + text = text.replaceAll(RegExp(r'[\u200E\u200F\u202A-\u202E]'), ''); + + // 3. Normalize punctuation + _quoteMap.forEach((from, to) { + text = text.replaceAll(from, to); + }); + + // 4. Handle nikud based on settings + if (shouldRemoveNikud()) { + text = removeNikud(text); + } + + // 5. Trim whitespace + return text.trim(); + } +} +``` + +### Hash Generation + +```dart +class HashGenerator { + static String generateTextHash(String normalizedText) { + final bytes = utf8.encode(normalizedText); + final digest = sha256.convert(bytes); + return digest.toString(); + } + + static int generateRollingHash(String text) { + const int base = 256; + const int mod = 1000000007; + + int hash = 0; + int pow = 1; + + for (int i = 0; i < text.length; i++) { + hash = (hash + (text.codeUnitAt(i) * pow)) % mod; + pow = (pow * base) % mod; + } + + return hash; + } +} + +class RollingHashWindow { + static const int base = 256; + static const int mod = 1000000007; + + int hash = 0; + int power = 1; + final int windowSize; + + RollingHashWindow(this.windowSize) { + // Pre-calculate base^(windowSize-1) mod mod + for (int i = 0; i < windowSize - 1; i++) { + power = (power * base) % mod; + } + } + + void init(String text) { + hash = 0; + int currentPow = 1; + for (int i = 0; i < text.length && i < windowSize; i++) { + hash = (hash + (text.codeUnitAt(i) * currentPow)) % mod; + currentPow = (currentPow * base) % mod; + } + } + + int slide(int outChar, int inChar) { + hash = (hash - (outChar * power) % mod + mod) % mod; + hash = (hash * base + inChar) % mod; + return hash; + } +} +``` + +### Fuzzy Matching Algorithm + +```dart +class FuzzyMatcher { + static double calculateLevenshteinSimilarity(String a, String b) { + final distance = levenshteinDistance(a, b); + final maxLength = math.max(a.length, b.length); + return 1.0 - (distance / maxLength); + } + + static double calculateJaccardSimilarity(String a, String b) { + final ngramsA = generateNGrams(a, 3); + final ngramsB = generateNGrams(b, 3); + + final intersection = ngramsA.toSet().intersection(ngramsB.toSet()); + final union = ngramsA.toSet().union(ngramsB.toSet()); + + return intersection.length / union.length; + } + + static double calculateCosineSimilarity(String a, String b, int n) { + Map freq(List grams) { + final m = {}; + for (final g in grams) m[g] = (m[g] ?? 0) + 1; + return m; + } + + final ga = generateNGrams(a, n); + final gb = generateNGrams(b, n); + final fa = freq(ga), fb = freq(gb); + final keys = {...fa.keys, ...fb.keys}; + + double dot = 0, na = 0, nb = 0; + for (final k in keys) { + final va = (fa[k] ?? 0).toDouble(); + final vb = (fb[k] ?? 0).toDouble(); + dot += va * vb; + na += va * va; + nb += vb * vb; + } + + return dot == 0 ? 0.0 : dot / (math.sqrt(na) * math.sqrt(nb)); + } + + static List generateNGrams(String text, int n) { + final ngrams = []; + for (int i = 0; i <= text.length - n; i++) { + ngrams.add(text.substring(i, i + n)); + } + return ngrams; + } +} +``` + +### Performance Optimizations + +#### 1. Lazy Loading +```dart +class NotesLoader { + final Map> _cache = {}; + + Future> loadNotesForCharRange(String bookId, int startChar, int endChar) async { + final cacheKey = '$bookId:$startChar:$endChar'; + + if (_cache.containsKey(cacheKey)) { + return _cache[cacheKey]!; + } + + // Load only notes visible in current character range + final notes = await _repository.getNotesForCharRange( + bookId, + startChar, + endChar + ); + + _cache[cacheKey] = notes; + return notes; + } + + Future> loadNotesForVisibleRange(String bookId, VisibleCharRange range) async { + return loadNotesForCharRange(bookId, range.start, range.end); + } +} + +class VisibleCharRange { + final int start; + final int end; + + const VisibleCharRange(this.start, this.end); +} +} +``` + +#### 2. Background Processing +```dart +class BackgroundProcessor { + static Future processReanchoring(List notes) async { + await compute(_reanchorNotes, notes); + } + + static List _reanchorNotes(List notes) { + // Heavy computation in isolate + return notes.map((note) => _reanchorSingleNote(note)).toList(); + } +} +``` + +#### 3. Index Optimization +```dart +class SearchIndex { + final Map> _textHashIndex = {}; + final Map> _contextIndex = {}; + final Map> _rollingHashIndex = {}; + + void buildIndex(CanonicalDocument document) { + // Build inverted indexes for fast lookup + _buildTextHashIndex(document); + _buildContextIndex(document); + _buildRollingHashIndex(document); + } + + List findByTextHash(String hash) { + return (_textHashIndex[hash] ?? const {}).toList(); + } + + List findByContextHash(String beforeHash, String afterHash) { + final beforePositions = _contextIndex[beforeHash] ?? const {}; + final afterPositions = _contextIndex[afterHash] ?? const {}; + + return beforePositions.intersection(afterPositions).toList(); + } + + List findByRollingHash(int hash) { + return (_rollingHashIndex[hash] ?? const {}).toList(); + } +} +``` + +## Migration Strategy + +### Phase 1: Infrastructure Setup +1. Create database schema +2. Implement core services (CanonicalTextService, AnchoringService) +3. Add basic UI components +4. Create migration utilities + +### Phase 2: Basic Functionality +1. Implement note creation and editing +2. Add text highlighting +3. Implement basic anchoring +4. Add notes sidebar + +### Phase 3: Advanced Features +1. Implement re-anchoring algorithm +2. Add fuzzy matching +3. Implement orphan notes management +4. Add search functionality + +### Phase 4: Import/Export and Polish +1. Implement export/import functionality +2. Add performance optimizations +3. Implement backup system +4. Add advanced UI features + +### Data Migration from Existing System + +```dart +class BookmarkMigrator { + Future migrateBookmarksToNotes() async { + final bookmarks = await _getExistingBookmarks(); + + for (final bookmark in bookmarks) { + try { + final note = await _convertBookmarkToNote(bookmark); + await _notesRepository.createNote(note); + } catch (e) { + _logger.logError('Failed to migrate bookmark: ${bookmark.id}', e); + } + } + } + + Future _convertBookmarkToNote(Bookmark bookmark) async { + // Convert bookmark to note with proper anchoring + final canonicalDoc = await _canonicalService + .createCanonicalDocument(bookmark.bookTitle); + + final anchor = _anchoringService.createAnchor( + bookmark.bookTitle, + canonicalDoc.canonicalText, + bookmark.charStart, + bookmark.charEnd, + ); + + return Note( + id: _generateId(), + bookId: bookmark.bookTitle, + docVersionId: canonicalDoc.versionId, + charStart: anchor.charStart, + charEnd: anchor.charEnd, + // ... other fields + ); + } +} +``` + +## Security Considerations + +### Data Protection +1. **Local Encryption**: הערות רגישות יוצפנו מקומית +2. **Access Control**: הרשאות גישה לפי משתמש +3. **Backup Security**: גיבויים מוצפנים +4. **Data Validation**: אימות נתונים בכל שכבה + +### Privacy +1. **User Consent**: בקשת הסכמה לשמירת נתונים +2. **Data Minimization**: שמירת מינימום נתונים נדרש +3. **Right to Delete**: יכולת מחיקת כל הנתונים +4. **Export Control**: שליטה מלאה על ייצוא נתונים + +## Monitoring and Analytics + +### Performance Metrics +```dart +class PerformanceMonitor { + static void trackAnchoringPerformance(Duration duration, bool success) { + _analytics.track('anchoring_performance', { + 'duration_ms': duration.inMilliseconds, + 'success': success, + }); + } + + static void trackSearchPerformance(String query, int resultCount, Duration duration) { + _analytics.track('search_performance', { + 'query_length': query.length, + 'result_count': resultCount, + 'duration_ms': duration.inMilliseconds, + }); + } +} +``` + +### Error Tracking +```dart +class ErrorTracker { + static void trackAnchoringFailure(Note note, AnchoringError error) { + _analytics.track('anchoring_failure', { + 'note_id': note.id, + 'book_id': note.bookId, + 'error_type': error.toString(), + }); + } +} +``` + +## Future Enhancements + +### Phase 2 Features +1. **Collaborative Notes**: שיתוף הערות בין משתמשים +2. **Note Categories**: קטגוריזציה של הערות +3. **Advanced Search**: חיפוש מתקדם עם פילטרים +4. **Note Templates**: תבניות להערות נפוצות + +### Phase 3 Features +1. **AI-Powered Suggestions**: הצעות אוטומטיות להערות +2. **Cross-Reference Detection**: זיהוי אוטומטי של הפניות +3. **Semantic Search**: חיפוש סמנטי בהערות +4. **Integration with External Tools**: אינטגרציה עם כלים חיצוניים + +### Technical Debt Considerations +1. **Database Optimization**: אופטימיזציה של שאילתות +2. **Memory Management**: ניהול זיכרון יעיל יותר +3. **Code Refactoring**: ארגון מחדש של הקוד +4. **Test Coverage**: הרחבת כיסוי הבדיקות## Confi +guration Constants + +### Anchoring Parameters +```dart +class AnchoringConstants { + // Context window size (characters before and after selected text) + static const int contextWindowSize = 40; + + // Maximum distance between prefix and suffix for context matching + static const int maxContextDistance = 300; + + // Similarity thresholds + static const double levenshteinThreshold = 0.18; // ≤ 18% of original length + static const double jaccardThreshold = 0.82; // ≥ 82% similarity + static const double cosineThreshold = 0.82; // ≥ 82% similarity + + // N-gram size for fuzzy matching + static const int ngramSize = 3; + + // Performance limits + static const int maxReanchoringTimeMs = 50; // per note + static const int maxPageLoadDelayMs = 16; // UI responsiveness + + // Rolling hash window size + static const int rollingHashWindowSize = 20; +} +``` + +### Database Configuration +```dart +class DatabaseConfig { + static const String databaseName = 'notes.db'; + static const int databaseVersion = 1; + static const String notesTable = 'notes'; + static const String canonicalDocsTable = 'canonical_documents'; + static const String notesFtsTable = 'notes_fts'; + + // Cache settings + static const int maxCacheSize = 10000; + static const Duration cacheExpiry = Duration(hours: 1); +} +``` + +## Security and Encryption + +### Key Management +```dart +class EncryptionManager { + static const String keyAlias = 'otzaria_notes_key'; + static const int keySize = 256; // AES-256 + + /// Generate or retrieve encryption key from secure storage + Future getOrCreateKey() async { + // Use platform-specific secure storage + // Android: Android Keystore + // iOS: Keychain Services + // Windows: DPAPI + // Linux: Secret Service API + } + + /// Encrypt note content with AES-GCM + Future encryptContent(String content, SecretKey key) async { + final algorithm = AesGcm.with256bits(); + final nonce = algorithm.newNonce(); + + final secretBox = await algorithm.encrypt( + utf8.encode(content), + secretKey: key, + nonce: nonce, + ); + + return EncryptedData( + ciphertext: secretBox.cipherText, + nonce: nonce, + mac: secretBox.mac.bytes, + ); + } + + /// Decrypt note content + Future decryptContent(EncryptedData data, SecretKey key) async { + final algorithm = AesGcm.with256bits(); + + final secretBox = SecretBox( + data.ciphertext, + nonce: data.nonce, + mac: Mac(data.mac), + ); + + final clearText = await algorithm.decrypt(secretBox, secretKey: key); + return utf8.decode(clearText); + } +} + +class EncryptedData { + final List ciphertext; + final List nonce; + final List mac; + + const EncryptedData({ + required this.ciphertext, + required this.nonce, + required this.mac, + }); + + /// Serialize to JSON for storage + Map toJson() => { + 'ciphertext': base64.encode(ciphertext), + 'nonce': base64.encode(nonce), + 'mac': base64.encode(mac), + 'version': 1, // For future key rotation + }; + + /// Deserialize from JSON + factory EncryptedData.fromJson(Map json) => EncryptedData( + ciphertext: base64.decode(json['ciphertext']), + nonce: base64.decode(json['nonce']), + mac: base64.decode(json['mac']), + ); +} +``` + +### Privacy Controls +```dart +class PrivacyManager { + /// Check if user has consented to data collection + Future hasUserConsent() async { + return Settings.getValue('notes_data_consent') ?? false; + } + + /// Request user consent for data collection + Future requestUserConsent() async { + // Show consent dialog + // Store user choice + // Return consent status + } + + /// Export all user data for GDPR compliance + Future exportAllUserData(String userId) async { + final notes = await _notesRepository.getNotesForUser(userId); + final exportData = { + 'user_id': userId, + 'export_date': DateTime.now().toIso8601String(), + 'notes': notes.map((n) => n.toJson()).toList(), + }; + + return jsonEncode(exportData); + } + + /// Delete all user data + Future deleteAllUserData(String userId) async { + await _notesRepository.deleteAllNotesForUser(userId); + await _canonicalService.clearCacheForUser(userId); + // Clear any other user-specific data + } +} +``` + +## UI Theme Integration + +### Color Scheme +```dart +class NotesTheme { + static Color getHighlightColor(BuildContext context, NoteStatus status) { + final colorScheme = Theme.of(context).colorScheme; + + return switch (status) { + NoteStatus.anchored => colorScheme.primary.withOpacity(0.2), + NoteStatus.shifted => colorScheme.warning.withOpacity(0.2), + NoteStatus.orphan => colorScheme.error.withOpacity(0.2), + }; + } + + static Color getStatusIndicatorColor(BuildContext context, NoteStatus status) { + final colorScheme = Theme.of(context).colorScheme; + + return switch (status) { + NoteStatus.anchored => colorScheme.primary, + NoteStatus.shifted => colorScheme.warning, + NoteStatus.orphan => colorScheme.error, + }; + } + + static IconData getStatusIcon(NoteStatus status) { + return switch (status) { + NoteStatus.anchored => Icons.check_circle, + NoteStatus.shifted => Icons.warning, + NoteStatus.orphan => Icons.error, + }; + } +} + +extension ColorSchemeExtension on ColorScheme { + Color get warning => const Color(0xFFFF9800); +} +``` + +### Keyboard Shortcuts +```dart +class NotesShortcuts { + static const Map shortcuts = { + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyN): + CreateNoteIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyE): + EditNoteIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyD): + DeleteNoteIntent(), + LogicalKeySet(LogicalKeyboardKey.f3): + FindNextNoteIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.f3): + FindPreviousNoteIntent(), + LogicalKeySet(LogicalKeyboardKey.escape): + CancelNoteActionIntent(), + + // Orphan manager shortcuts + LogicalKeySet(LogicalKeyboardKey.arrowUp): + SelectPreviousCandidateIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowDown): + SelectNextCandidateIntent(), + LogicalKeySet(LogicalKeyboardKey.enter): + ConfirmCandidateIntent(), + }; +} + +// Intent classes +class CreateNoteIntent extends Intent {} +class EditNoteIntent extends Intent {} +class DeleteNoteIntent extends Intent {} +class FindNextNoteIntent extends Intent {} +class FindPreviousNoteIntent extends Intent {} +class CancelNoteActionIntent extends Intent {} +class SelectPreviousCandidateIntent extends Intent {} +class SelectNextCandidateIntent extends Intent {} +class ConfirmCandidateIntent extends Intent {} +``` + +## Ready for Development Checklist + +### Core Infrastructure ✅ +- [x] Database schema with proper indexes +- [x] Full-text search configuration +- [x] SQLite performance optimizations +- [x] Encryption and key management +- [x] Configuration constants defined + +### Data Models ✅ +- [x] Note model with all required fields +- [x] CanonicalDocument model with correct index types +- [x] AnchorCandidate model defined +- [x] Error handling types defined + +### Algorithms ✅ +- [x] Text normalization with safe punctuation handling +- [x] Both Jaccard and Cosine similarity implementations +- [x] Rolling hash with proper sliding window +- [x] Fuzzy matching with configurable thresholds + +### Performance ✅ +- [x] Character-range based loading instead of page-based +- [x] Proper caching strategy +- [x] Background processing for heavy operations +- [x] Search indexes for fast lookups + +### Security ✅ +- [x] AES-GCM encryption with proper key management +- [x] Privacy controls and GDPR compliance +- [x] Secure storage integration +- [x] Data validation and sanitization + +### UI/UX ✅ +- [x] Theme integration with dynamic colors +- [x] Status indicators and icons +- [x] Keyboard shortcuts defined +- [x] Accessibility considerations + +The design document is now complete and ready for implementation. All technical issues have been resolved and the system is properly architected for scalable, secure, and performant note management.## Fi +nal Implementation Checklist + +### ✅ Core Infrastructure Ready +- [x] Database schema with proper indexes and FTS +- [x] SQLite performance optimizations with all PRAGMA settings +- [x] Encryption and key management system +- [x] Configuration constants centralized in AnchoringConstants + +### ✅ Data Models Complete +- [x] Note model with all required fields +- [x] CanonicalDocument model with correct List index types +- [x] AnchorCandidate model with score and strategy +- [x] AnchorResult model for consistent return values +- [x] Error handling types defined + +### ✅ Algorithms Implemented +- [x] Text normalization with safe Unicode quote mapping +- [x] Both Jaccard and true Cosine similarity implementations +- [x] Rolling hash with proper sliding window capability +- [x] Fuzzy matching with configurable thresholds from constants + +### ✅ Performance Optimized +- [x] Character-range based loading with VisibleCharRange +- [x] SearchIndex with consistent Set types +- [x] Background processing for heavy operations +- [x] Proper caching strategy with expiry + +### ✅ Security Complete +- [x] AES-GCM encryption with versioned envelope format +- [x] Platform-specific secure key storage +- [x] Privacy controls and GDPR compliance +- [x] Data validation and sanitization + +### ✅ UI/UX Ready +- [x] Theme integration with dynamic colors +- [x] Status indicators with accessibility +- [x] Keyboard shortcuts including orphan manager navigation +- [x] Score display for anchor candidates + +### ✅ Testing Strategy +- [x] Unit tests for all algorithms +- [x] Integration tests with visible char range +- [x] Performance tests with realistic thresholds +- [x] Migration tests for existing bookmarks + +The design document is now complete, consistent, and ready for immediate development. All technical issues have been resolved, types are aligned, and the system is properly architected for scalable, secure, and performant note management with Hebrew text support. \ No newline at end of file diff --git a/.kiro/specs/personal-notes/requirements.md b/.kiro/specs/personal-notes/requirements.md new file mode 100644 index 000000000..978396c86 --- /dev/null +++ b/.kiro/specs/personal-notes/requirements.md @@ -0,0 +1,231 @@ +# Requirements Document + +## Introduction + +תכונת ההערות האישיות תאפשר למשתמשים להוסיף הערות אישיות לטקסטים בספרים השונים. ההערות יישמרו בצורה מדויקת ויוצגו במיקום הנכון גם כאשר מבנה הטקסט משתנה. התכונה תפתור את הבעיה הקיימת של קריאת ספרים בבלוקים שיכולה לגרום לאי-דיוק במיקום ההערות. + +הפתרון יתבסס על מעבר ממודל "בלוקים/שורות" למודל מסמך קנוני עם מערכת עיגון מתקדמת שכוללת שמירת הקשר טקסטואלי ו-fingerprints קריפטוגרפיים לזיהוי מדויק של מיקום ההערות. + +## Requirements + +### Requirement 1 + +**User Story:** כמשתמש, אני רוצה להוסיף הערה אישית לטקסט ספציפי בספר, כדי שאוכל לשמור מחשבות ותובנות אישיות. + +#### Acceptance Criteria + +1. WHEN המשתמש בוחר טקסט בספר THEN המערכת SHALL להציג אפשרות להוסיף הערה +2. WHEN המשתמש לוחץ על "הוסף הערה" THEN המערכת SHALL לפתוח חלון עריכת הערה +3. WHEN המשתמש כותב הערה ושומר THEN המערכת SHALL לשמור את ההערה עם מיקום מדויק +4. IF ההערה נשמרה בהצלחה THEN המערכת SHALL להציג סימון ויזואלי על הטקסט המוערה + +### Requirement 2 + +**User Story:** כמשתמש, אני רוצה לראות את ההערות שלי במיקום המדויק בטקסט, כדי שההקשר יישמר גם כאשר מבנה הספר משתנה. + +#### Acceptance Criteria + +1. WHEN המשתמש פותח ספר עם הערות קיימות THEN המערכת SHALL להציג את ההערות במיקום הנכון +2. WHEN מבנה הטקסט משתנה (הוספה/הסרה של שורות) THEN המערכת SHALL לשמור על מיקום ההערות היחסי +3. WHEN המשתמש מעביר עכבר על טקסט מוערה THEN המערכת SHALL להציג את תוכן ההערה +4. IF ההערה לא יכולה להיות ממוקמת במדויק THEN המערכת SHALL להציג התראה למשתמש + +### Requirement 3 + +**User Story:** כמשתמש, אני רוצה לערוך ולמחוק הערות קיימות, כדי שאוכל לעדכן ולנהל את ההערות שלי. + +#### Acceptance Criteria + +1. WHEN המשתמש לוחץ על הערה קיימת THEN המערכת SHALL להציג אפשרויות עריכה ומחיקה +2. WHEN המשתמש בוחר "ערוך הערה" THEN המערכת SHALL לפתוח את חלון העריכה עם התוכן הקיים +3. WHEN המשתמש בוחר "מחק הערה" THEN המערכת SHALL לבקש אישור ולמחוק את ההערה +4. IF ההערה נמחקה THEN המערכת SHALL להסיר את הסימון הויזואלי מהטקסט + +### Requirement 4 + +**User Story:** כמשתמש, אני רוצה שההערות שלי יישמרו בצורה עמידה ומדויקת, כדי שלא אאבד אותן בעת עדכונים או שינויים בתוכנה. + +#### Acceptance Criteria + +1. WHEN המשתמש שומר הערה THEN המערכת SHALL לשמור אותה עם מזהה ייחודי ומיקום מדויק +2. WHEN הספר נטען מחדש THEN המערכת SHALL לטעון את כל ההערות הרלוונטיות +3. WHEN התוכנה מתעדכנת THEN המערכת SHALL לשמור על תאימות עם הערות קיימות +4. IF קובץ ההערות פגום THEN המערכת SHALL לנסות לשחזר הערות או להציג הודעת שגיאה מתאימה + +### Requirement 5 + +**User Story:** כמשתמש, אני רוצה לחפש בהערות שלי, כדי שאוכל למצוא במהירות הערות ספציפיות. + +#### Acceptance Criteria + +1. WHEN המשתמש פותח את תפריט ההערות THEN המערכת SHALL להציג רשימה של כל ההערות +2. WHEN המשתמש מקליד בשדה החיפוש THEN המערכת SHALL לסנן הערות לפי תוכן הטקסט +3. WHEN המשתמש לוחץ על הערה ברשימה THEN המערכת SHALL לנווט למיקום ההערה בספר +4. IF אין הערות התואמות לחיפוש THEN המערכת SHALL להציג הודעה מתאימה + +### Requirement 6 + +**User Story:** כמשתמש, אני רוצה לייצא ולייבא הערות, כדי שאוכל לגבות אותן ולשתף אותן בין מכשירים. + +#### Acceptance Criteria + +1. WHEN המשתמש בוחר "ייצא הערות" THEN המערכת SHALL ליצור קובץ עם כל ההערות +2. WHEN המשתמש בוחר "ייבא הערות" THEN המערכת SHALL לטעון הערות מקובץ חיצוני +3. WHEN מתבצע ייבוא THEN המערכת SHALL לבדוק תאימות ולמזג עם הערות קיימות +4. IF יש התנגשות בין הערות THEN המערכת SHALL לבקש מהמשתמש כיצד לפתור את ההתנגשות + +### Requirement 7 + +**User Story:** כמפתח, אני רוצה שהמערכת תשמור הערות עם מערכת עיגון מתקדמת, כדי שההערות יישארו מדויקות גם כאשר מבנה הטקסט משתנה. + +#### Acceptance Criteria + +1. WHEN הערה נשמרת THEN המערכת SHALL לשמור מזהה ספר, גרסת מסמך, אופסטים של תווים, וטקסט מנורמל +2. WHEN הערה נשמרת THEN המערכת SHALL לשמור חלון הקשר לפני ואחרי (prefix/suffix) בגודל N תווים +3. WHEN הערה נשמרת THEN המערכת SHALL לחשב ולשמור fingerprints קריפטוגרפיים (SHA-256) של הטקסט המסומן והקשר +4. WHEN הערה נשמרת THEN המערכת SHALL לשמור rolling hash (Rabin-Karp) לחלון הקשר להאצת חיפוש + +### Requirement 8 + +**User Story:** כמפתח, אני רוצה שהמערכת תיישם אלגוריתם re-anchoring מתקדם, כדי לאתר הערות גם לאחר שינויים במסמך. + +#### Acceptance Criteria + +1. WHEN מסמך נטען עם גרסה זהה THEN המערכת SHALL למקם הערות לפי אופסטים (O(1)) +2. WHEN גרסת מסמך שונה THEN המערכת SHALL לחפש text_hash מדויק במסמך הקנוני +3. IF חיפוש מדויק נכשל THEN המערכת SHALL לחפש הקשר (prefix/suffix) במרחק ≤ K תווים +4. IF חיפוש הקשר נכשל THEN המערכת SHALL להשתמש בחיפוש דמיון מטושטש (Levenshtein/Cosine) +5. IF נמצאו מספר מועמדים THEN המערכת SHALL לבקש הכרעת משתמש +6. IF לא נמצא מיקום מתאים THEN המערכת SHALL לסמן הערה כ"יתומה" + +### Requirement 9 + +**User Story:** כמפתח, אני רוצה שהמערכת תשתמש במסמך קנוני אחיד, כדי להבטיח עקביות במיקום ההערות. + +#### Acceptance Criteria + +1. WHEN ספר נטען THEN המערכת SHALL ליצור ייצוג מסמך קנוני רציף +2. WHEN מסמך קנוני נוצר THEN המערכת SHALL לחשב גרסת מסמך (checksum) +3. WHEN טקסט מנורמל THEN המערכת SHALL להחליף רווחים מרובים ולאחד סימני פיסוק +4. IF קיימת היררכיה פנימית THEN המערכת SHALL לשמור נתיב לוגי (פרקים/פסקאות) + +### Requirement 10 + +**User Story:** כמשתמש, אני רוצה לראות סטטוס עיגון ההערות, כדי לדעת עד כמה המיקום מדויק. + +#### Acceptance Criteria + +1. WHEN הערה מוצגת THEN המערכת SHALL להציג סטטוס עיגון: "מדויק", "מוזז אך אותר", או "נדרש אימות ידני" +2. WHEN יש הערות יתומות THEN המערכת SHALL להציג מסך "הערות יתומות" עם אשף התאמה +3. WHEN מוצגים מועמדי התאמה THEN המערכת SHALL להציג 1-3 מועמדים קרובים עם ציון דמיון +4. IF משתמש בוחר מועמד THEN המערכת SHALL לעדכן את העיגון ולסמן כ"מדויק" + +### Requirement 11 + +**User Story:** כמפתח, אני רוצה שהמערכת תהיה יעילה בביצועים, כדי שהוספת הערות לא תשפיע על חוויית המשתמש. + +#### Acceptance Criteria + +1. WHEN מתבצע עיגון/רה-עיגון THEN המערכת SHALL להשלים את התהליך ב≤ 50ms להערה בממוצע +2. WHEN עמוד נטען עם הערות THEN המערכת SHALL לא לעכב טעינה > 16ms (ביצוע ברקע) +3. WHEN נשמר אינדקס הקשר THEN המערכת SHALL להשתמש בדחיסה (n-grams בגודל 3-5) +4. IF יש מעל 1000 הערות בספר THEN המערכת SHALL להשתמש באינדקס מהיר (rolling-hash) + +### Requirement 12 + +**User Story:** כמשתמש, אני רוצה שההערות שלי יהיו מוגנות ופרטיות, כדי שרק אני אוכל לגשת אליהן. + +#### Acceptance Criteria + +1. WHEN הערה נשמרת THEN המערכת SHALL להצפין את תוכן ההערה מקומית +2. WHEN הערות מיוצאות THEN המערכת SHALL לאפשר הצפנה בפורמט AES-GCM במפתח משתמש +3. WHEN הערות משותפות THEN המערכת SHALL לנהל הרשאות גישה לפי משתמש +4. IF קובץ הערות פגום THEN המערכת SHALL לבדוק שלמות ולנסות שחזור + +### Requirement 13 + +**User Story:** כמפתח, אני רוצה שהמערכת תעבור מהמודל הקיים בצורה חלקה, כדי שלא יאבדו הערות קיימות. + +#### Acceptance Criteria + +1. WHEN מתבצעת מיגרציה THEN המערכת SHALL לבנות מסמך קנוני מכל ספר קיים +2. WHEN הערות קיימות מומרות THEN המערכת SHALL להפיק hash-ים וחלונות הקשר +3. WHEN מיגרציה מושלמת THEN המערכת SHALL להריץ re-anchoring ראשונית +4. IF יש בעיות במיגרציה THEN המערכת SHALL לסמן חריגות ולאפשר תיקון ידני + +### Requirement 14 + +**User Story:** כמפתח, אני רוצה שהמערכת תעמוד בבדיקות קבלה מחמירות, כדי להבטיח איכות ואמינות. + +#### Acceptance Criteria + +1. WHEN נוספות 100 הערות ומשתנות 5% שורות THEN המערכת SHALL לשמור ≥ 98% הערות כ"מדויק" +2. WHEN משתנים רק ריווח ושבירת שורות THEN המערכת SHALL לשמור 100% הערות כ"מדויק" +3. WHEN נמחק קטע מסומן לחלוטין THEN המערכת SHALL לסמן הערה כ"יתומה" +4. WHEN מתבצע ייבוא/ייצוא THEN המערכת SHALL לשמור זהות מספר הערות, תוכן ומצב עיגון## T +echnical Specifications + +### Default Values and Constants + +- **N (חלון הקשר):** 40 תווים לפני ואחרי הטקסט המסומן +- **K (מרחק מקסימלי בין prefix/suffix):** 300 תווים +- **ספי דמיון:** + - Levenshtein: ≤ 0.18 מהאורך המקורי + - Cosine n-grams: ≥ 0.82 +- **גודל n-grams לאינדקס:** 3-5 תווים +- **מגבלת זמן re-anchoring:** 50ms להערה בממוצע +- **מגבלת עיכוב טעינת עמוד:** 16ms + +### Text Normalization Standard + +הנירמול יכלול: +1. החלפת רווחים מרובים ברווח יחיד +2. הסרת סימני כיווניות לא מודפסים (LTR/RTL marks) +3. יוניפיקציה של גרשיים ומירכאות לסוג אחיד +4. שמירה על ניקוד עברי (אופציונלי לפי הגדרות משתמש) +5. trim של רווחים בתחילת וסוף הטקסט + +### Data Schema (SQLite/Database) + +```sql +CREATE TABLE notes ( + note_id TEXT PRIMARY KEY, -- UUID + book_id TEXT NOT NULL, + doc_version_id TEXT NOT NULL, + logical_path TEXT, -- JSON array: ["chapter:3", "para:12"] + char_start INTEGER NOT NULL, + char_end INTEGER NOT NULL, + selected_text_normalized TEXT NOT NULL, + text_hash TEXT NOT NULL, -- SHA-256 + ctx_before TEXT NOT NULL, + ctx_after TEXT NOT NULL, + ctx_before_hash TEXT NOT NULL, -- SHA-256 + ctx_after_hash TEXT NOT NULL, -- SHA-256 + rolling_before INTEGER NOT NULL, -- Rabin-Karp hash + rolling_after INTEGER NOT NULL, -- Rabin-Karp hash + status TEXT NOT NULL CHECK (status IN ('anchored', 'shifted', 'orphan')), + content_markdown TEXT NOT NULL, + author_user_id TEXT NOT NULL, + privacy TEXT NOT NULL CHECK (privacy IN ('private', 'shared')), + tags TEXT, -- JSON array + created_at TEXT NOT NULL, -- ISO8601 + updated_at TEXT NOT NULL -- ISO8601 +); + +CREATE INDEX idx_notes_book_id ON notes(book_id); +CREATE INDEX idx_notes_doc_version ON notes(doc_version_id); +CREATE INDEX idx_notes_text_hash ON notes(text_hash); +CREATE INDEX idx_notes_ctx_hashes ON notes(ctx_before_hash, ctx_after_hash); +CREATE INDEX idx_notes_author ON notes(author_user_id); +``` + +### API Endpoints Structure + +- `POST /api/notes` - יצירת הערה חדשה +- `GET /api/notes?book_id={id}` - שליפת הערות לספר +- `PATCH /api/notes/{id}` - עדכון תוכן הערה +- `DELETE /api/notes/{id}` - מחיקה רכה של הערה +- `POST /api/notes/reanchor` - הפעלת re-anchoring ידני +- `GET /api/notes/orphans` - שליפת הערות יתומות +- `POST /api/notes/export` - ייצוא הערות +- `POST /api/notes/import` - ייבוא הערות \ No newline at end of file diff --git a/.kiro/specs/personal-notes/tasks.md b/.kiro/specs/personal-notes/tasks.md new file mode 100644 index 000000000..c32ef62b4 --- /dev/null +++ b/.kiro/specs/personal-notes/tasks.md @@ -0,0 +1,506 @@ +# Implementation Plan - Personal Notes System + +## Overview + +תכנית יישום מדורגת למערכת ההערות האישיות, המבוססת על הדרישות והעיצוב שהוגדרו. התכנית מחולקת לשלבים עם משימות קונקרטיות שניתן לבצע בצורה מדורגת. + +## Phase 1: Core Infrastructure Setup + +### 1.1 Database Schema and Configuration +- יצירת סכמת מסד הנתונים SQLite עם טבלאות notes ו-canonical_documents +- הוספת אינדקסים לביצועים ו-FTS לחיפוש עברי +- הגדרת PRAGMA optimizations (WAL, foreign_keys, cache_size) +- יצירת triggers לסנכרון FTS table +- _Requirements: 4.1, 7.1, 11.3, 14.4_ + +### 1.2 Core Data Models +- יצירת Note model עם כל השדות הנדרשים (id, bookId, anchoring data, content) +- יצירת CanonicalDocument model עם indexes מסוג Map> +- יצירת AnchorCandidate model עם score ו-strategy +- יצירת AnchorResult model לתוצאות re-anchoring עקביות +- יצירת enum types: NoteStatus, NotePrivacy, AnchoringError +- _Requirements: 7.1, 8.1, 10.1_ + +### 1.3 Configuration Constants +- יצירת AnchoringConstants class עם כל הקבועים (N=40, K=300, ספי דמיון) +- יצירת DatabaseConfig class עם הגדרות מסד נתונים +- יצירת NotesTheme class לאינטגרציה עם Theme system +- _Requirements: 8.3, 11.1, 11.3_ + +## Phase 2: Text Processing and Anchoring Core + +### 2.1 Text Normalization Service +- יצירת TextNormalizer class עם normalize() method +- מימוש מפת Unicode בטוחה לסימני פיסוק (_quoteMap) +- הוספת תמיכה בהסרת/שמירת ניקוד לפי הגדרות משתמש +- טיפול במקרי קצה: RTL marks, Hebrew quotes (׳/״), ZWJ/ZWNJ +- יצירת golden tests עם corpus RTL/ניקוד לוודא offset stability +- _Requirements: 9.3, 13.2_ + +### 2.2 Hash Generation Service +- יצירת HashGenerator class עם generateTextHash() ו-generateRollingHash() +- מימוש RollingHashWindow class עם sliding window אמיתי +- יצירת unit tests לוודא hash consistency +- _Requirements: 7.3, 7.4, 8.2_ + +### 2.3 Canonical Text Service +- יצירת CanonicalTextService class +- מימוש createCanonicalDocument() method שיוצר מסמך קנוני מטקסט ספר +- מימוש calculateDocumentVersion() method עם checksum +- מימוש extractContextWindow() method +- אינטגרציה עם FileSystemData הקיים לקריאת טקסטי ספרים +- _Requirements: 9.1, 9.2, 13.1_ + +### 2.4 Fuzzy Matching Algorithms +- יצירת FuzzyMatcher class +- מימוש calculateLevenshteinSimilarity() method +- מימוש calculateJaccardSimilarity() method (intersection/union) +- מימוש calculateCosineSimilarity() method אמיתי עם תדירות n-grams +- מימוש generateNGrams() helper method +- יצירת unit tests עם ספי דמיון מהקבועים +- _Requirements: 8.4, 14.1_ + +## Phase 3: Anchoring and Re-anchoring System + +### 3.1 Search Index Service +- יצירת SearchIndex class עם Map> indexes פנימיים +- מימוש buildIndex() method לבניית אינדקסים מהירים +- מימוש findByTextHash(), findByContextHash(), findByRollingHash() methods +- החזרת List offsets (לא ערך בודד) מכל find method +- אופטימיזציה לביצועים עם pre-computed indexes +- _Requirements: 8.2, 11.4_ + +### 3.2 Anchoring Service Core +- יצירת AnchoringService class +- מימוש createAnchor() method ליצירת עוגן חדש להערה +- מימוש findExactMatch() method לחיפוש text_hash מדויק +- מימוש findByContext() method לחיפוש prefix/suffix במרחק K +- מימוש findFuzzyMatch() method עם Levenshtein ו-Cosine +- _Requirements: 8.1, 8.2, 8.3, 8.4_ + +### 3.3 Re-anchoring Algorithm +- מימוש reanchorNote() method עם אלגוריתם מדורג: + 1. בדיקת גרסה זהה → אופסטים (O(1)) + 2. חיפוש text_hash מדויק + 3. חיפוש הקשר במרחק ≤ K תווים + 4. חיפוש דמיון מטושטש + 5. מועמדים מרובים → בדיקת score difference (Δ≤0.03 → Orphan Manager) + 6. כישלון → סימון כ-orphan +- מימוש batch re-anchoring עם transaction boundaries +- הוספת performance target: ≤50ms per note average +- יצירת unit tests לכל שלב באלגוריתם +- _Requirements: 8.1-8.6, 14.1-14.3_ + +## Phase 4: Data Layer and Repository + +### 4.1 Notes Data Provider +- יצירת NotesDataProvider class לגישה ישירה למסד נתונים +- מימוש CRUD operations: create, read, update, delete עם transaction boundaries +- מימוש getNotesForCharRange() לטעינה יעילה לפי VisibleCharRange +- מימוש searchNotes() עם FTS + n-grams normalization לעברית +- הוספת transaction management: BEGIN IMMEDIATE...COMMIT עם busy_timeout +- שמירת normalization config string עם כל הערה: "norm=v1;nikud=skip;quotes=ascii;unicode=NFKC" +- הגדרת limits: max note size (32KB), max notes per book (5,000) +- _Requirements: 1.3, 3.1, 3.3, 5.2_ + +### 4.2 Notes Repository +- יצירת NotesRepository class כשכבת business logic +- מימוש createNote() method עם יצירת anchor אוטומטי +- מימוש updateNote() ו-deleteNote() methods +- מימוש getNotesForBook() ו-searchNotes() methods +- אינטגרציה עם AnchoringService לre-anchoring אוטומטי +- _Requirements: 1.1-1.4, 2.1, 3.1-3.4, 5.1-5.4_ + +### 4.3 Background Processing Service +- יצירת BackgroundProcessor class לעבודות כבדות +- מימוש processReanchoring() method ב-isolate נפרד +- מימוש batch processing עם requestId/epoch לביטול תשובות ישנות +- הוספת progress reporting למשתמש +- הוספת stale work detection (race-proof) +- _Requirements: 11.1, 11.2_ + +## Phase 5: State Management (BLoC) + +### 5.1 Notes Events +- יצירת NotesEvent base class +- יצירת events: CreateNoteEvent, UpdateNoteEvent, DeleteNoteEvent +- יצירת LoadNotesEvent, SearchNotesEvent, ReanchorNotesEvent +- יצירת ResolveOrphanEvent, FindCandidatesEvent +- _Requirements: 1.1-1.4, 3.1-3.4, 5.1-5.4_ + +### 5.2 Notes States +- יצירת NotesState base class +- יצירת states: NotesInitial, NotesLoading, NotesLoaded, NotesError +- יצירת OrphansLoaded, CandidatesFound, NoteCreated states +- הוספת immutable state properties עם copyWith methods +- _Requirements: 2.1-2.4, 10.1-10.4_ + +### 5.3 Notes BLoC +- יצירת NotesBloc class עם event handling +- מימוש _onCreateNote, _onUpdateNote, _onDeleteNote handlers +- מימוש _onLoadNotes, _onSearchNotes handlers +- מימוש _onReanchorNotes, _onResolveOrphan handlers +- אינטגרציה עם NotesRepository ו-BackgroundProcessor +- הוספת error handling ו-loading states +- _Requirements: כל הדרישות הפונקציונליות_ + +## Phase 6: UI Components + +### 6.1 Note Highlight Widget +- יצירת NoteHighlight widget לסימון טקסט מוערה +- מימוש dynamic colors לפי NoteStatus (anchored/shifted/orphan) +- הוספת hover effects ו-tap handling +- אינטגרציה עם Theme system לנגישות +- _Requirements: 1.4, 2.3, 10.1_ + +### 6.2 Note Editor Dialog +- יצירת NoteEditorDialog widget ליצירה ועריכה +- מימוש markdown editor עם preview +- הוספת tags input ו-privacy controls +- מימוש validation ו-error display +- הוספת keyboard shortcuts (Ctrl+S לשמירה) +- _Requirements: 1.2, 3.2, 12.3_ +כן! +### 6.3 Context Menu Integration +- הרחבת context menu הקיים ב-SimpleBookView +- הוספת "הוסף הערה" option לטקסט נבחר +- הוספת "ערוך הערה" ו-"מחק הערה" להערות קיימות +- מימוש keyboard shortcuts (Ctrl+N, Ctrl+E, Ctrl+D) +- _Requirements: 1.1, 3.1, 3.3_ + +### 6.4 Notes Sidebar +- יצירת NotesSidebar widget לרשימת הערות +- מימוש search functionality עם real-time filtering +- הוספת sorting options (date, status, relevance) +- מימוש click-to-navigate לmיקום הערה בטקסט +- הוספת status indicators עם icons וצבעים +- _Requirements: 5.1-5.4, 10.1-10.4_ + +## Phase 7: Advanced Features + +### 7.1 Orphan Notes Manager +- יצירת OrphanNotesManager widget +- מימוש candidate selection עם score display +- הוספת keyboard navigation (↑/↓/Enter/Esc) +- מימוש preview של מיקום מוצע +- הוספת bulk resolution options +- _Requirements: 8.5, 8.6, 10.2-10.4_ + +### 7.2 Performance Optimizations +- מימוש NotesLoader עם VisibleCharRange-based caching +- הוספת lazy loading לhערות מחוץ לviewport +- מימוש viewport tracking לטעינה דינמית +- אופטימיזציה של re-anchoring לbackground isolate +- הוספת performance telemetry: anchored_exact, anchored_shifted, orphan_rate, avg_reanchor_ms +- מימוש kill-switch: notes.enabled=false config flag +- הבטחת <16ms per frame rendering עם 1000+ notes +- _Requirements: 11.1, 11.2, 11.4_ + +### 7.3 Import/Export Functionality +- יצירת ImportExportService class +- מימוש exportNotes() method עם JSON/JSONL format +- מימוש importNotes() method עם conflict resolution +- הוספת encryption options לexport (AES-GCM) +- מימוש progress tracking לoperations גדולות +- _Requirements: 6.1-6.4, 12.2_ + +## Phase 8: Security and Privacy + +### 8.1 Encryption System +- יצירת EncryptionManager class +- מימוש platform-specific key storage (Android Keystore/iOS Keychain/Windows DPAPI) +- מימוש AES-GCM encryption עם versioned envelope format +- שמירת unique nonce per note + authentication tag +- הוספת key rotation capabilities עם version tracking +- יצירת unit tests לencryption/decryption determinism +- _Requirements: 12.1, 12.2, 12.4_ + +### 8.2 Privacy Controls +- יצירת PrivacyManager class +- מימוש user consent management +- מימוש exportAllUserData() לGDPR compliance +- מימוש deleteAllUserData() method +- הוספת privacy settings UI +- _Requirements: 12.3, 12.4_ + +## Phase 9: Migration and Integration + +### 9.1 Bookmark Migration +- יצירת BookmarkMigrator class +- מימוש migrateBookmarksToNotes() method +- הוספת progress tracking ו-error handling +- מימוש rollback capabilities במקרה של כישלון +- יצירת migration tests +- _Requirements: 13.1-13.4_ + +### 9.2 TextBookBloc Integration +- הרחבת TextBookBloc לכלול notes state +- הוספת notes loading לTextBookLoaded state +- מימוש notes filtering ו-highlighting בSimpleBookView +- אינטגרציה עם existing scroll controllers +- _Requirements: 2.1, 2.2_ + +### 9.3 FileSystemData Extension +- הרחבת FileSystemData לתמוך במסמכים קנוניים +- הוספת caching למסמכים קנוניים +- מימוש version tracking לספרים +- אופטימיזציה לביצועים עם background processing +- _Requirements: 9.1, 9.2, 13.1_ + +## Phase 10: Testing and Quality Assurance + +### 10.1 Unit Tests +- יצירת tests לכל השירותים (TextNormalizer, HashGenerator, etc.) +- יצירת tests לאלגוריתמי fuzzy matching +- יצירת tests לre-anchoring algorithm עם test cases מגוונים +- יצירת tests לencryption/decryption +- השגת 90%+ code coverage +- _Requirements: 14.1-14.4_ + +### 10.2 Integration Tests +- יצירת end-to-end tests ליצירת הערות מselection +- יצירת tests למיגרציה מbookmarks +- יצירת tests לimport/export functionality +- יצירת tests לorphan resolution flow +- _Requirements: כל הדרישות הפונקציונליות_ + +### 10.3 Performance Tests +- יצירת tests לre-anchoring performance (≤50ms per note) +- יצירת tests לviewport load delay (≤16ms per frame) +- יצירת tests לsearch performance (≤200ms) +- יצירת stress tests עם 1000+ הערות + fast scrolling +- יצירת determinism tests: same platform/version → same hash +- יצירת back-pressure tests: rapid scrolling with lazy loading +- _Requirements: 11.1, 11.2, 14.1-14.4_ + +### 10.4 Acceptance Tests +- יצירת automated tests לaccuracy requirements (98% exact after 5% changes) +- יצירת tests לwhitespace-only changes (100% exact) +- יצירת tests לdeleted text handling (proper orphan marking) +- יצירת tests לimport/export round-trip integrity +- יצירת tests לnormalization snapshot consistency +- יצירת tests לcandidate ambiguity handling (score difference <0.03) +- _Requirements: 14.1-14.4_ + +## Phase 11: Documentation and Polish + +### 11.1 Code Documentation +- הוספת comprehensive dartdoc comments לכל הpublic APIs +- יצירת architecture documentation +- יצירת API reference documentation +- הוספת code examples ו-usage patterns +- _Requirements: כללי_ + +### 11.2 User Documentation +- יצירת user guide להערות אישיות +- יצירת troubleshooting guide לorphan notes +- יצירת privacy and security guide +- יצירת import/export instructions +- _Requirements: כללי_ + +### 11.3 Final Polish +- code review ו-refactoring +- performance profiling ו-optimization +- accessibility testing ו-improvements +- UI/UX polish ו-animations +- final testing ו-bug fixes +- _Requirements: כללי_## Vert +ical Slice for V1 (Minimal Viable Product) + +### Week 1: Core Foundation +- **Phase 1.1-1.3**: Database schema + core models + constants +- **Phase 2.1-2.3**: Text normalization + hash generation + canonical text service +- **Phase 3.2**: Basic anchoring (exact match + context only, no fuzzy yet) + +### Week 2: Basic Functionality +- **Phase 4.1-4.2**: Repository + data provider (minimal CRUD) +- **Phase 5.1-5.3**: BLoC events/states for Create/Load/Reanchor +- **Phase 6.1-6.2**: Note highlight widget + basic editor dialog + +### Week 3: Integration & Testing +- **Phase 6.3**: Context menu integration ("Add Note" only) +- **Phase 9.2**: Basic TextBookBloc integration +- **Phase 10.1**: Core unit tests (20 test cases) + +### V1 Success Criteria +- ✅ Create notes from text selection +- ✅ Display notes with highlighting +- ✅ Basic re-anchoring on book load (exact + context) +- ✅ Handle whitespace changes (100% accuracy) +- ✅ Handle small text changes (basic orphan detection) +- ✅ Performance: <16ms rendering, <50ms re-anchoring + +### V1 Limitations (Acceptable) +- No fuzzy matching (orphan instead) +- No import/export +- No encryption +- No advanced UI (sidebar, orphan manager) +- No search functionality + +## Technical Debt Prevention + +### Code Quality Gates +- All public APIs must have dartdoc comments +- All services must have corresponding unit tests +- All database operations must use transactions +- All async operations must have proper error handling +- All UI components must support theme integration + +### Performance Gates +- Re-anchoring batch operations must complete in <5 seconds for 100 notes +- UI rendering must maintain 60fps during scrolling with notes +- Memory usage must not exceed 50MB additional for 1000 notes +- Database queries must use proper indexes (no table scans) + +### Security Gates +- All user input must be validated and sanitized +- All sensitive data must be encrypted at rest +- All database operations must prevent SQL injection +- All file operations must validate paths and permissions + +## Configuration Management + +### Feature Flags +```dart +class NotesConfig { + static const bool enabled = true; // Kill switch + static const bool highlightEnabled = true; // Emergency disable highlights + static const bool fuzzyMatchingEnabled = false; // V2 feature + static const bool encryptionEnabled = false; // V2 feature + static const bool importExportEnabled = false; // V2 feature + static const int maxNotesPerBook = 5000; // Resource limit + static const int maxNoteSize = 32768; // 32KB limit + static const int reanchoringTimeoutMs = 50; // Performance limit + static const int maxReanchoringBatchSize = 100; // Emergency batch limit +} +``` + +### Environment-Specific Settings +```dart +class NotesEnvironment { + static const bool debugMode = kDebugMode; + static const bool telemetryEnabled = !kDebugMode; + static const bool performanceLogging = kDebugMode; + static const String databasePath = kDebugMode ? 'notes_debug.db' : 'notes.db'; +} +``` + +## Monitoring and Telemetry + +### Key Metrics to Track +- **Anchoring Success Rate**: anchored_exact / total_notes +- **Performance Metrics**: avg_reanchor_ms, max_reanchor_ms, p95_reanchor_ms +- **User Engagement**: notes_created_per_day, notes_edited_per_day +- **Error Rates**: orphan_rate, reanchoring_failures, database_errors +- **Resource Usage**: memory_usage_mb, database_size_mb, cache_hit_rate + +### Telemetry Implementation +```dart +class NotesTelemetry { + static void trackAnchoringResult(String requestId, NoteStatus status, Duration duration, String strategy) { + if (!NotesEnvironment.telemetryEnabled) return; + + // NEVER log note content or context windows + _analytics.track('anchoring_result', { + 'request_id': requestId, + 'status': status.toString(), + 'strategy': strategy, + 'duration_ms': duration.inMilliseconds, + 'timestamp': DateTime.now().toIso8601String(), + }); + } + + static void trackBatchReanchoring(String requestId, int noteCount, int successCount, Duration totalDuration) { + if (!NotesEnvironment.telemetryEnabled) return; + + _analytics.track('batch_reanchoring', { + 'request_id': requestId, + 'note_count': noteCount, + 'success_count': successCount, + 'success_rate': successCount / noteCount, + 'avg_duration_ms': totalDuration.inMilliseconds / noteCount, + 'total_duration_ms': totalDuration.inMilliseconds, + }); + } + + static void trackPerformanceMetric(String operation, Duration duration) { + if (!NotesEnvironment.performanceLogging) return; + + _logger.info('Performance: $operation took ${duration.inMilliseconds}ms'); + } +} +``` + +## Ready for Development Checklist + +### ✅ Architecture +- [x] High-level architecture defined with clear component boundaries +- [x] Integration points with existing system identified +- [x] Data flow and state management patterns established +- [x] Error handling and recovery strategies defined + +### ✅ Technical Specifications +- [x] Database schema with proper indexes and constraints +- [x] Data models with all required fields and relationships +- [x] API contracts between layers clearly defined +- [x] Performance requirements and limits specified + +### ✅ Implementation Plan +- [x] Tasks broken down into manageable, testable units +- [x] Dependencies between tasks clearly identified +- [x] Acceptance criteria linked to original requirements +- [x] Vertical slice defined for rapid validation + +### ✅ Quality Assurance +- [x] Testing strategy covering unit, integration, and acceptance tests +- [x] Performance benchmarks and monitoring defined +- [x] Security considerations and privacy controls specified +- [x] Code quality gates and technical debt prevention measures + +### ✅ Risk Mitigation +- [x] Feature flags for safe rollout and rollback +- [x] Configuration management for different environments +- [x] Telemetry and monitoring for production insights +- [x] Migration strategy from existing bookmarks system + +The Personal Notes System specification is now complete and ready for immediate development. The implementation plan provides a clear roadmap from initial infrastructure to full-featured note management system, with proper attention to performance, security, and user experience.## Final + Development Readiness Checklist + +### ✅ Architecture & Design +- [x] No pageIndex usage - only VisibleCharRange throughout all layers +- [x] AnchoringResult unified return type for all anchoring operations +- [x] AnchorCandidate model with score and strategy fields +- [x] SearchIndex with Map> internal, List external +- [x] Transaction boundaries defined with BEGIN IMMEDIATE...COMMIT + +### ✅ Data Integrity +- [x] Normalization config string saved with each note for deterministic hashing +- [x] FTS triggers for automatic index synchronization +- [x] Golden tests for RTL/nikud/ZWJ edge cases +- [x] Stale work detection with requestId/epoch for background processing + +### ✅ Performance & Limits +- [x] Performance targets: <50ms avg re-anchoring, <16ms rendering +- [x] Resource limits: 32KB note size, 5000 notes per book +- [x] Batch size limits with emergency controls +- [x] P95/P99 performance testing for 1000+ notes + +### ✅ Security & Privacy +- [x] Telemetry that never logs note content or context windows +- [x] Platform-specific key storage (Keystore/Keychain/DPAPI) +- [x] Versioned encryption envelope with unique nonce per note +- [x] Input validation and sanitization for all user data + +### ✅ Operational Readiness +- [x] Kill switch and granular feature flags +- [x] Comprehensive telemetry with request tracking +- [x] Error handling and recovery strategies +- [x] Migration path from existing bookmarks + +### ✅ Testing Strategy +- [x] Unit tests for all core algorithms +- [x] Integration tests for end-to-end workflows +- [x] Performance tests with realistic data volumes +- [x] Acceptance tests matching original requirements + +The Personal Notes System specification is now complete, battle-tested, and ready for immediate production development. All technical debt prevention measures are in place, performance targets are defined, and the implementation path is clear from MVP to full-featured system. \ No newline at end of file diff --git a/docs/api_reference.md b/docs/api_reference.md new file mode 100644 index 000000000..1c5e80f0e --- /dev/null +++ b/docs/api_reference.md @@ -0,0 +1,651 @@ +# Personal Notes System - API Reference + +## Overview + +This document provides comprehensive API documentation for the Personal Notes System in Otzaria. + +## Core Services + +### NotesIntegrationService + +The main service for integrating notes with the existing book system. + +```dart +class NotesIntegrationService { + static NotesIntegrationService get instance; + + /// Load notes for a book and integrate with text display + Future loadNotesForBook(String bookId, String bookText); + + /// Get notes for a specific visible range (for performance) + List getNotesForVisibleRange(String bookId, VisibleCharRange range); + + /// Create highlight data for text rendering + List createHighlightsForRange(String bookId, VisibleCharRange range); + + /// Handle text selection for note creation + Future createNoteFromSelection( + String bookId, + String selectedText, + int charStart, + int charEnd, + String noteContent, { + List tags = const [], + NotePrivacy privacy = NotePrivacy.private, + }); + + /// Update an existing note + Future updateNote(String noteId, String? newContent, { + List? newTags, + NotePrivacy? newPrivacy, + }); + + /// Delete a note + Future deleteNote(String noteId); + + /// Search notes across all books or specific book + Future> searchNotes(String query, {String? bookId}); + + /// Clear cache for a specific book or all books + void clearCache({String? bookId}); + + /// Get cache statistics + Map getCacheStats(); +} +``` + +### ImportExportService + +Service for importing and exporting notes. + +```dart +class ImportExportService { + static ImportExportService get instance; + + /// Export notes to JSON format + Future exportNotes({ + String? bookId, + List? noteIds, + bool includeOrphans = true, + bool includePrivateNotes = true, + String? filePath, + }); + + /// Import notes from JSON format + Future importNotes( + String jsonData, { + bool overwriteExisting = false, + bool validateAnchors = true, + String? targetBookId, + Function(int current, int total)? onProgress, + }); + + /// Import notes from file + Future importNotesFromFile( + String filePath, { + bool overwriteExisting = false, + bool validateAnchors = true, + String? targetBookId, + Function(int current, int total)? onProgress, + }); +} +``` + +### AdvancedOrphanManager + +Service for managing orphaned notes with smart re-anchoring. + +```dart +class AdvancedOrphanManager { + static AdvancedOrphanManager get instance; + + /// Find potential anchor candidates for an orphan note + Future> findCandidatesForOrphan( + Note orphan, + CanonicalDocument document, + ); + + /// Auto-reanchor orphans with high confidence scores + Future> autoReanchorOrphans( + List orphans, + CanonicalDocument document, { + double confidenceThreshold = 0.9, + }); + + /// Get orphan statistics and recommendations + OrphanAnalysis analyzeOrphans(List orphans); +} +``` + +### PerformanceOptimizer + +Service for optimizing notes system performance. + +```dart +class PerformanceOptimizer { + static PerformanceOptimizer get instance; + + /// Start automatic performance optimization + void startAutoOptimization(); + + /// Stop automatic performance optimization + void stopAutoOptimization(); + + /// Run a complete optimization cycle + Future runOptimizationCycle(); + + /// Get optimization status + OptimizationStatus getOptimizationStatus(); + + /// Force immediate optimization + Future forceOptimization(); +} +``` + +### NotesTelemetry + +Service for tracking notes performance and usage metrics. + +```dart +class NotesTelemetry { + static NotesTelemetry get instance; + + /// Track anchoring result (no sensitive data) + static void trackAnchoringResult( + String requestId, + NoteStatus status, + Duration duration, + String strategy, + ); + + /// Track batch re-anchoring performance + static void trackBatchReanchoring( + String requestId, + int noteCount, + int successCount, + Duration totalDuration, + ); + + /// Track search performance + static void trackSearchPerformance( + String query, + int resultCount, + Duration duration, + ); + + /// Get performance statistics + Map getPerformanceStats(); + + /// Get aggregated metrics for reporting + Map getAggregatedMetrics(); + + /// Check if performance is within acceptable limits + bool isPerformanceHealthy(); + + /// Clear all metrics (for testing or privacy) + void clearMetrics(); +} +``` + +## UI Components + +### NotesSidebar + +Sidebar widget for displaying and managing notes. + +```dart +class NotesSidebar extends StatefulWidget { + const NotesSidebar({ + super.key, + this.bookId, + this.onClose, + this.onNoteSelected, + this.onNavigateToPosition, + }); + + final String? bookId; + final VoidCallback? onClose; + final Function(Note)? onNoteSelected; + final Function(int, int)? onNavigateToPosition; +} +``` + +### NoteEditorDialog + +Dialog widget for creating and editing notes. + +```dart +class NoteEditorDialog extends StatefulWidget { + const NoteEditorDialog({ + super.key, + this.note, + this.selectedText, + this.charStart, + this.charEnd, + required this.onSave, + this.onCancel, + }); + + final Note? note; + final String? selectedText; + final int? charStart; + final int? charEnd; + final Function(CreateNoteRequest) onSave; + final VoidCallback? onCancel; +} +``` + +### NoteHighlight + +Widget for highlighting notes in text. + +```dart +class NoteHighlight extends StatefulWidget { + const NoteHighlight({ + super.key, + required this.note, + required this.child, + this.onTap, + this.onLongPress, + }); + + final Note note; + final Widget child; + final VoidCallback? onTap; + final VoidCallback? onLongPress; +} +``` + +### OrphanNotesManager + +Widget for managing orphaned notes and helping re-anchor them. + +```dart +class OrphanNotesManager extends StatefulWidget { + const OrphanNotesManager({ + super.key, + required this.bookId, + this.onClose, + }); + + final String bookId; + final VoidCallback? onClose; +} +``` + +### NotesPerformanceDashboard + +Widget for displaying notes performance metrics and health status. + +```dart +class NotesPerformanceDashboard extends StatefulWidget { + const NotesPerformanceDashboard({super.key}); +} + +class CompactPerformanceDashboard extends StatelessWidget { + const CompactPerformanceDashboard({super.key}); +} +``` + +## BLoC Pattern + +### NotesBloc + +Main BLoC for managing notes state. + +```dart +class NotesBloc extends Bloc { + NotesBloc() : super(const NotesInitial()); +} +``` + +### NotesEvent + +Base class for all notes events. + +```dart +abstract class NotesEvent extends Equatable { + const NotesEvent(); +} + +class LoadNotesEvent extends NotesEvent; +class CreateNoteEvent extends NotesEvent; +class UpdateNoteEvent extends NotesEvent; +class DeleteNoteEvent extends NotesEvent; +class SearchNotesEvent extends NotesEvent; +class ReanchorNotesEvent extends NotesEvent; +class FindAnchorCandidatesEvent extends NotesEvent; +// ... and more +``` + +### NotesState + +Base class for all notes states. + +```dart +abstract class NotesState extends Equatable { + const NotesState(); +} + +class NotesInitial extends NotesState; +class NotesLoading extends NotesState; +class NotesLoaded extends NotesState; +class NotesError extends NotesState; +class NotesSearchResults extends NotesState; +// ... and more +``` + +## Data Models + +### Note + +Represents a personal note attached to a specific text location. + +```dart +class Note extends Equatable { + const Note({ + required this.id, + required this.bookId, + required this.docVersionId, + this.logicalPath, + required this.charStart, + required this.charEnd, + required this.selectedTextNormalized, + required this.textHash, + required this.contextBefore, + required this.contextAfter, + required this.contextBeforeHash, + required this.contextAfterHash, + required this.rollingBefore, + required this.rollingAfter, + required this.status, + required this.contentMarkdown, + required this.authorUserId, + required this.privacy, + required this.tags, + required this.createdAt, + required this.updatedAt, + required this.normalizationConfig, + }); + + final String id; + final String bookId; + final String docVersionId; + final List? logicalPath; + final int charStart; + final int charEnd; + final String selectedTextNormalized; + final String textHash; + final String contextBefore; + final String contextAfter; + final String contextBeforeHash; + final String contextAfterHash; + final int rollingBefore; + final int rollingAfter; + final NoteStatus status; + final String contentMarkdown; + final String authorUserId; + final NotePrivacy privacy; + final List tags; + final DateTime createdAt; + final DateTime updatedAt; + final String normalizationConfig; +} +``` + +### NoteStatus + +Enumeration of possible note statuses. + +```dart +enum NoteStatus { + /// Note is anchored to its exact original location + anchored, + + /// Note has been re-anchored to a new location due to text changes + shifted, + + /// Note cannot be anchored to any location (orphaned) + orphan, +} +``` + +### NotePrivacy + +Enumeration of note privacy levels. + +```dart +enum NotePrivacy { + /// Note is private to the user + private, + + /// Note can be shared with others + shared, +} +``` + +## Configuration + +### NotesConfig + +Feature flags and configuration for the notes system. + +```dart +class NotesConfig { + static const bool enabled = true; + static const bool highlightEnabled = true; + static const bool fuzzyMatchingEnabled = false; + static const bool encryptionEnabled = false; + static const bool importExportEnabled = false; + static const int maxNotesPerBook = 5000; + static const int maxNoteSize = 32768; + static const int reanchoringTimeoutMs = 50; + static const int maxReanchoringBatchSize = 200; + static const bool telemetryEnabled = true; + static const int busyTimeoutMs = 5000; +} +``` + +### AnchoringConstants + +Constants for the anchoring algorithm. + +```dart +class AnchoringConstants { + static const int contextWindowSize = 40; + static const int maxContextDistance = 300; + static const double levenshteinThreshold = 0.18; + static const double jaccardThreshold = 0.82; + static const double cosineThreshold = 0.82; + static const int ngramSize = 3; + static const double levenshteinWeight = 0.4; + static const double jaccardWeight = 0.3; + static const double cosineWeight = 0.3; + static const int maxReanchoringTimeMs = 50; + static const int maxPageLoadDelayMs = 16; + static const int rollingHashWindowSize = 20; + static const double candidateScoreDifference = 0.03; +} +``` + +## Error Handling + +### Common Exceptions + +```dart +class RepositoryException implements Exception; +class AnchoringException implements Exception; +class ImportException implements Exception; +class TimeoutException implements Exception; +``` + +## Performance Metrics + +### Key Performance Indicators + +- **Note Creation**: Target < 100ms average +- **Re-anchoring**: Target < 50ms per note average +- **Search**: Target < 200ms for typical queries +- **Memory Usage**: Target < 50MB additional for 1000+ notes +- **Accuracy**: Target 98% after 5% text changes + +### Telemetry Data + +The system tracks performance metrics without logging sensitive content: + +- Anchoring success rates by strategy +- Average processing times +- Search performance metrics +- Batch operation statistics +- Memory usage estimates + +## Integration Examples + +### Basic Integration + +```dart +// Initialize notes system +final notesService = NotesIntegrationService.instance; + +// In your book widget +class BookWidget extends StatefulWidget { + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: BookTextView( + onTextSelection: (selectedText, start, end) { + _showNoteCreationDialog(selectedText, start, end); + }, + ), + ), + NotesSidebar( + bookId: widget.bookId, + onNoteSelected: (note) { + _navigateToNote(note); + }, + ), + ], + ); + } +} +``` + +### BLoC Integration + +```dart +class BookBloc extends Bloc { + final NotesBloc notesBloc; + + BookBloc({required this.notesBloc}) : super(BookInitial()) { + on(_onLoadBook); + } + + Future _onLoadBook(LoadBookEvent event, Emitter emit) async { + // Load book content + final bookContent = await loadBook(event.bookId); + + // Load notes for the book + notesBloc.add(LoadNotesEvent(event.bookId)); + + emit(BookLoaded(content: bookContent)); + } +} +``` + +### Context Menu Integration + +```dart +Widget buildTextWithNotes(String text, String bookId) { + return NotesContextMenuExtension.buildWithNotesSupport( + context: context, + bookId: bookId, + child: SelectableText( + text, + onSelectionChanged: (selection, cause) { + if (selection.isValid) { + _showContextMenu(selection, bookId); + } + }, + ), + ); +} +``` + +## Testing + +### Unit Testing + +```dart +void main() { + group('NotesIntegrationService', () { + late NotesIntegrationService service; + + setUp(() { + service = NotesIntegrationService.instance; + }); + + test('should create note from selection', () async { + final note = await service.createNoteFromSelection( + 'test-book', + 'selected text', + 10, + 23, + 'Test note content', + ); + + expect(note.bookId, equals('test-book')); + expect(note.contentMarkdown, equals('Test note content')); + }); + }); +} +``` + +### Integration Testing + +```dart +void main() { + testWidgets('Notes sidebar integration', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NotesSidebar( + bookId: 'test-book', + onNoteSelected: (note) { + // Handle note selection + }, + ), + ), + ), + ); + + expect(find.text('הערות אישיות'), findsOneWidget); + }); +} +``` + +## Troubleshooting + +### Common Issues + +1. **Notes not appearing**: Check if notes are loaded for the correct book ID +2. **Performance issues**: Use telemetry to identify bottlenecks +3. **Orphan notes**: Use the Orphan Manager to re-anchor notes +4. **Search not working**: Rebuild the search index via performance optimizer + +### Debug Information + +```dart +// Get performance statistics +final stats = NotesTelemetry.instance.getPerformanceStats(); +print('Performance stats: $stats'); + +// Check cache status +final cacheStats = notesService.getCacheStats(); +print('Cache stats: $cacheStats'); + +// Get optimization status +final optimizationStatus = PerformanceOptimizer.instance.getOptimizationStatus(); +print('Optimization status: $optimizationStatus'); +``` \ No newline at end of file diff --git a/docs/user_guide.md b/docs/user_guide.md new file mode 100644 index 000000000..0f47871fa --- /dev/null +++ b/docs/user_guide.md @@ -0,0 +1,219 @@ +# Personal Notes System - User Guide + +## Introduction + +The Personal Notes System allows you to create, manage, and organize personal notes attached to specific text locations in your books. Your notes automatically stay connected to the text even when the book content changes. + +## Getting Started + +### Creating Your First Note + +1. **Select Text**: Highlight any text in a book by clicking and dragging +2. **Add Note**: Right-click and select "הוסף הערה" (Add Note) from the context menu +3. **Write Content**: Enter your note content in the dialog that appears +4. **Add Tags** (optional): Add tags to organize your notes +5. **Set Privacy** (optional): Choose between Private or Shared +6. **Save**: Click "שמור" (Save) to create your note + +### Viewing Your Notes + +#### Notes Sidebar +- Click the notes icon in the toolbar to open the notes sidebar +- View all notes for the current book +- Search through your notes using the search box +- Sort notes by date, status, or relevance +- Filter notes by status (anchored, shifted, orphan) + +#### Text Highlights +- Notes appear as colored highlights in the text +- **Green**: Anchored notes (exact original location) +- **Orange**: Shifted notes (moved due to text changes) +- **Red**: Orphan notes (cannot find suitable location) + +### Managing Notes + +#### Editing Notes +1. Click on a note in the sidebar or right-click a highlighted note +2. Select "ערוך" (Edit) from the menu +3. Modify the content, tags, or privacy settings +4. Click "שמור" (Save) to update the note + +#### Deleting Notes +1. Right-click on a note or use the menu in the sidebar +2. Select "מחק" (Delete) +3. Confirm the deletion in the dialog + +#### Organizing with Tags +- Add tags when creating or editing notes +- Use tags to categorize your notes (e.g., "חשוב", "לימוד", "שאלות") +- Search by tags using the format `#tag-name` + +## Advanced Features + +### Searching Notes + +#### Basic Search +- Type your search query in the sidebar search box +- Search works in both Hebrew and English +- Results are ranked by relevance + +#### Advanced Search Syntax +- **Exact phrases**: Use quotes `"exact phrase"` +- **Tags**: Use hashtag `#important` +- **Exclude terms**: Use minus `-unwanted` +- **Filters**: Use `status:orphan` or `privacy:private` + +#### Search Examples +- `תורה` - Find notes containing "תורה" +- `"פרק ראשון"` - Find exact phrase "פרק ראשון" +- `#חשוב` - Find notes tagged with "חשוב" +- `לימוד -בחינה` - Find "לימוד" but exclude "בחינה" + +### Handling Text Changes + +#### Note Status Types +- **Anchored (מעוגן)**: Note is at its exact original location +- **Shifted (זז)**: Note moved to a new location due to text changes +- **Orphan (יתום)**: Note cannot find a suitable location + +#### Orphan Notes Management +1. Open the Orphan Manager from the sidebar menu +2. Select an orphan note from the list +3. Review suggested anchor locations +4. Choose the best match or delete the note + +### Import and Export + +#### Exporting Notes +1. Go to Settings → Notes → Export +2. Choose which notes to export: + - All notes or specific book + - Include/exclude private notes + - Include/exclude orphan notes +3. Select export location +4. Click "ייצא" (Export) + +#### Importing Notes +1. Go to Settings → Notes → Import +2. Select the JSON file to import +3. Choose import options: + - Overwrite existing notes + - Target book (optional) +4. Click "ייבא" (Import) + +## Tips and Best Practices + +### Creating Effective Notes + +1. **Select Meaningful Text**: Choose text that clearly identifies the location +2. **Write Clear Content**: Make your notes understandable for future reference +3. **Use Consistent Tags**: Develop a tagging system that works for you +4. **Keep Notes Focused**: One main idea per note works best + +### Organizing Your Notes + +1. **Use Descriptive Tags**: Tags like "שאלות", "חשוב", "לחזור" help organization +2. **Regular Cleanup**: Periodically review and delete outdated notes +3. **Export Regularly**: Create backups of important notes + +### Performance Tips + +1. **Limit Notes per Book**: Keep under 1000 notes per book for best performance +2. **Use Visible Range**: The system only processes notes in the visible area +3. **Clear Cache**: If performance degrades, clear the cache in settings +4. **Monitor Health**: Check the performance dashboard occasionally + +## Troubleshooting + +### Common Issues + +#### Notes Not Appearing +- **Check Book ID**: Ensure you're viewing the correct book +- **Refresh**: Try closing and reopening the book +- **Clear Cache**: Go to Settings → Notes → Clear Cache + +#### Slow Performance +- **Too Many Notes**: Consider archiving old notes +- **Clear Telemetry**: Go to Performance Dashboard → Clear Metrics +- **Run Optimization**: Use the automatic optimization feature + +#### Orphan Notes +- **Use Orphan Manager**: Access via sidebar menu +- **Review Candidates**: Check suggested anchor locations +- **Consider Deletion**: Remove notes that are no longer relevant + +#### Search Not Working +- **Check Spelling**: Verify search terms are correct +- **Try Different Terms**: Use synonyms or related words +- **Rebuild Index**: Run performance optimization + +### Getting Help + +1. **Performance Dashboard**: Check system health and metrics +2. **Telemetry Data**: Review performance statistics +3. **Error Messages**: Read error messages carefully for guidance +4. **Cache Statistics**: Monitor memory usage and cache efficiency + +## Keyboard Shortcuts + +### General +- `Ctrl+N`: Create new note (when text is selected) +- `Ctrl+F`: Focus search box in sidebar +- `Ctrl+E`: Edit selected note +- `Ctrl+D`: Delete selected note + +### Orphan Manager +- `↑/↓`: Navigate between candidates +- `Enter`: Accept selected candidate +- `Esc`: Cancel and return to list + +### Sidebar +- `Ctrl+1`: Sort by date (newest first) +- `Ctrl+2`: Sort by date (oldest first) +- `Ctrl+3`: Sort by status +- `Ctrl+4`: Sort by relevance + +## Privacy and Data + +### Data Storage +- All notes are stored locally in SQLite database +- No data is sent to external servers +- Notes are not encrypted (by design for simplicity) + +### Privacy Levels +- **Private**: Only visible to you +- **Shared**: Can be shared with others (future feature) + +### Data Export +- Export creates JSON files with all note data +- Exported files are not encrypted +- Include only the data you choose to export + +## Advanced Configuration + +### Performance Tuning + +```dart +// Adjust batch sizes for your system +SmartBatchProcessor.instance.setBatchSizeLimits( + minSize: 10, + maxSize: 100, +); + +// Enable/disable features +NotesConfig.fuzzyMatchingEnabled = true; +NotesConfig.telemetryEnabled = false; +``` + +### Text Normalization + +```dart +// Configure text normalization +final config = NormalizationConfig( + removeNikud: false, // Keep Hebrew vowel points + quoteStyle: 'ascii', // Normalize quotes to ASCII + unicodeForm: 'NFKC', // Unicode normalization form +); +``` + +This user guide covers all the essential features and functionality of the Personal Notes System. For technical details and API documentation, see the API Reference. \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 67941bb9d..e8cd7a73f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -38,6 +38,9 @@ import 'package:path_provider/path_provider.dart'; import 'package:otzaria/app_bloc_observer.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:otzaria/data/data_providers/hive_data_provider.dart'; +import 'package:otzaria/notes/data/database_schema.dart'; +import 'package:otzaria/notes/bloc/notes_bloc.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:search_engine/search_engine.dart'; @@ -117,6 +120,9 @@ void main() async { create: (context) => FindRefBloc( findRefRepository: FindRefRepository( dataRepository: DataRepository.instance))), + BlocProvider( + create: (context) => NotesBloc(), + ), BlocProvider( create: (context) => BookmarkBloc(BookmarkRepository()), ), @@ -142,12 +148,28 @@ void main() async { /// 4. Hive storage boxes setup /// 5. Required directory structure creation Future initialize() async { + // Initialize SQLite FFI for desktop platforms + if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + } + await RustLib.init(); await Settings.init(cacheProvider: HiveCache()); await initLibraryPath(); await initHive(); await createDirs(); await loadCerts(); + + // Initialize notes database + try { + await DatabaseSchema.initializeDatabase(); + } catch (e) { + if (kDebugMode) { + print('Failed to initialize notes database: $e'); + } + // Continue without notes functionality if database fails + } } /// Creates the necessary directory structure for the application. diff --git a/lib/notes/bloc/notes_bloc.dart b/lib/notes/bloc/notes_bloc.dart new file mode 100644 index 000000000..0c3f4bc9f --- /dev/null +++ b/lib/notes/bloc/notes_bloc.dart @@ -0,0 +1,519 @@ +import 'dart:async'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../repository/notes_repository.dart'; +import '../services/anchoring_service.dart'; +import '../services/canonical_text_service.dart'; +import '../services/background_processor.dart'; +import '../config/notes_config.dart'; +import 'notes_event.dart'; +import 'notes_state.dart'; + +/// BLoC for managing notes state and coordinating business logic. +/// +/// This is the central state management component for the notes system. +/// It coordinates between the UI layer and the service layer, handling +/// all user interactions and maintaining the current state of notes. +/// +/// ## Responsibilities +/// +/// - **State Management**: Maintain current notes state and emit updates +/// - **Event Handling**: Process user actions and system events +/// - **Service Coordination**: Orchestrate calls to multiple services +/// - **Error Handling**: Provide user-friendly error states and recovery +/// - **Performance**: Optimize operations and prevent UI blocking +/// +/// ## Event Processing +/// +/// The BLoC handles various types of events: +/// +/// ### Loading Events +/// - `LoadNotesEvent`: Load all notes for a book +/// - `LoadNotesForRangeEvent`: Load notes for visible text range +/// - `RefreshNotesEvent`: Refresh notes from database +/// +/// ### CRUD Events +/// - `CreateNoteEvent`: Create new note from text selection +/// - `UpdateNoteEvent`: Update existing note content/metadata +/// - `DeleteNoteEvent`: Delete note and clean up anchoring data +/// +/// ### Search Events +/// - `SearchNotesEvent`: Search notes with query string +/// - `FilterNotesEvent`: Filter notes by status, tags, etc. +/// +/// ### Anchoring Events +/// - `ReanchorNotesEvent`: Re-anchor notes after text changes +/// - `ResolveOrphanEvent`: Resolve orphan note with user selection +/// - `FindCandidatesEvent`: Find anchor candidates for orphan +/// +/// ## State Transitions +/// +/// ``` +/// NotesInitial → NotesLoading → NotesLoaded +/// ↘ NotesError +/// +/// NotesLoaded → NotesUpdating → NotesLoaded +/// ↘ NotesSearching → NotesSearchResults +/// ↘ NotesReanchoring → NotesLoaded +/// ``` +/// +/// ## Performance Optimizations +/// +/// - **Range Loading**: Only load notes for visible text areas +/// - **Background Processing**: Heavy operations run in isolates +/// - **Debouncing**: Prevent rapid-fire events from overwhelming system +/// - **Caching**: Maintain in-memory cache of frequently accessed notes +/// +/// ## Usage +/// +/// ```dart +/// // In widget +/// BlocProvider( +/// create: (context) => NotesBloc(), +/// child: MyNotesWidget(), +/// ) +/// +/// // Trigger events +/// context.read().add(LoadNotesEvent('book-id')); +/// +/// // Listen to state +/// BlocBuilder( +/// builder: (context, state) { +/// if (state is NotesLoaded) { +/// return NotesListWidget(notes: state.notes); +/// } +/// return LoadingWidget(); +/// }, +/// ) +/// ``` +/// +/// ## Error Handling +/// +/// The BLoC provides comprehensive error handling: +/// - Network/database errors are caught and converted to user-friendly messages +/// - Partial failures (some notes load, others fail) are handled gracefully +/// - Recovery actions are suggested when possible +/// - Telemetry is collected for debugging and monitoring +class NotesBloc extends Bloc { + final NotesRepository _repository = NotesRepository.instance; + final AnchoringService _anchoringService = AnchoringService.instance; + final CanonicalTextService _canonicalService = CanonicalTextService.instance; + final BackgroundProcessor _backgroundProcessor = BackgroundProcessor.instance; + + // Keep track of current book and operations + String? _currentBookId; + final Set _activeOperations = {}; + + NotesBloc() : super(const NotesInitial()) { + // Register event handlers + on(_onLoadNotes); + on(_onLoadNotesForRange); + on(_onCreateNote); + on(_onUpdateNote); + on(_onDeleteNote); + on(_onSearchNotes); + on(_onClearSearch); + on(_onLoadOrphans); + on(_onFindCandidates); + on(_onResolveOrphan); + on(_onReanchorNotes); + on(_onExportNotes); + on(_onImportNotes); + on(_onRefreshNotes); + on(_onSelectNote); + on(_onToggleHighlighting); + on(_onUpdateVisibleRange); + on(_onCancelOperations); + on(_onEditNote); + } + + /// Handle loading notes for a book + Future _onLoadNotes(LoadNotesEvent event, Emitter emit) async { + try { + emit(const NotesLoading(message: 'טוען הערות...')); + + final notes = await _repository.getNotesForBook(event.bookId); + _currentBookId = event.bookId; + + emit(NotesLoaded( + bookId: event.bookId, + notes: notes, + lastUpdated: DateTime.now(), + )); + } catch (e) { + emit(NotesError( + message: 'שגיאה בטעינת הערות: ${e.toString()}', + operation: 'load_notes', + error: e, + )); + } + } + + /// Handle loading notes for a visible range + Future _onLoadNotesForRange(LoadNotesForRangeEvent event, Emitter emit) async { + try { + if (!NotesConfig.enabled) return; + + final notes = await _repository.getNotesForVisibleRange(event.bookId, event.range); + _currentBookId = event.bookId; + + // If we already have a loaded state, update it + if (state is NotesLoaded) { + final currentState = state as NotesLoaded; + emit(currentState.copyWith( + bookId: event.bookId, + notes: notes, + visibleRange: event.range, + lastUpdated: DateTime.now(), + )); + } else { + emit(NotesLoaded( + bookId: event.bookId, + notes: notes, + visibleRange: event.range, + lastUpdated: DateTime.now(), + )); + } + } catch (e) { + emit(NotesError( + message: 'שגיאה בטעינת הערות לטווח: ${e.toString()}', + operation: 'load_notes_range', + error: e, + )); + } + } + + /// Handle creating a new note + Future _onCreateNote(CreateNoteEvent event, Emitter emit) async { + try { + if (!NotesConfig.enabled) { + emit(const NotesError(message: 'מערכת ההערות מנוטרלת')); + return; + } + + emit(const NoteOperationInProgress(operation: 'יוצר הערה...')); + + final note = await _repository.createNote(event.request); + + emit(NoteCreated(note)); + + // Refresh the current notes if we're viewing the same book + if (_currentBookId == event.request.bookId) { + add(LoadNotesEvent(event.request.bookId)); + } + } catch (e) { + emit(NotesError( + message: 'שגיאה ביצירת הערה: ${e.toString()}', + operation: 'create_note', + error: e, + )); + } + } + + /// Handle updating a note + Future _onUpdateNote(UpdateNoteEvent event, Emitter emit) async { + try { + emit(NoteOperationInProgress( + operation: 'מעדכן הערה...', + noteId: event.noteId, + )); + + final note = await _repository.updateNote(event.noteId, event.request); + + emit(NoteUpdated(note)); + + // Refresh current notes if needed + if (_currentBookId != null) { + add(LoadNotesEvent(_currentBookId!)); + } + } catch (e) { + emit(NotesError( + message: 'שגיאה בעדכון הערה: ${e.toString()}', + operation: 'update_note', + error: e, + )); + } + } + + /// Handle deleting a note + Future _onDeleteNote(DeleteNoteEvent event, Emitter emit) async { + try { + emit(NoteOperationInProgress( + operation: 'מוחק הערה...', + noteId: event.noteId, + )); + + await _repository.deleteNote(event.noteId); + + emit(NoteDeleted(event.noteId)); + + // Refresh current notes if needed + if (_currentBookId != null) { + add(LoadNotesEvent(_currentBookId!)); + } + } catch (e) { + emit(NotesError( + message: 'שגיאה במחיקת הערה: ${e.toString()}', + operation: 'delete_note', + error: e, + )); + } + } + + /// Handle searching notes + Future _onSearchNotes(SearchNotesEvent event, Emitter emit) async { + try { + if (event.query.trim().isEmpty) { + emit(NotesSearchResults( + query: event.query, + results: const [], + bookId: event.bookId, + )); + return; + } + + emit(const NotesLoading(message: 'מחפש הערות...')); + + final results = await _repository.searchNotes(event.query, bookId: event.bookId); + + emit(NotesSearchResults( + query: event.query, + results: results, + bookId: event.bookId, + )); + } catch (e) { + emit(NotesError( + message: 'שגיאה בחיפוש הערות: ${e.toString()}', + operation: 'search_notes', + error: e, + )); + } + } + + /// Handle clearing search results + Future _onClearSearch(ClearSearchEvent event, Emitter emit) async { + // Return to the previous loaded state if available + if (_currentBookId != null) { + add(LoadNotesEvent(_currentBookId!)); + } else { + emit(const NotesInitial()); + } + } + + /// Handle loading orphan notes + Future _onLoadOrphans(LoadOrphansEvent event, Emitter emit) async { + try { + emit(const NotesLoading(message: 'טוען הערות יתומות...')); + + final orphans = await _repository.getOrphanNotes(bookId: event.bookId); + + emit(OrphansLoaded( + orphanNotes: orphans, + bookId: event.bookId, + )); + } catch (e) { + emit(NotesError( + message: 'שגיאה בטעינת הערות יתומות: ${e.toString()}', + operation: 'load_orphans', + error: e, + )); + } + } + + /// Handle finding candidates for an orphan note + Future _onFindCandidates(FindCandidatesEvent event, Emitter emit) async { + try { + emit(NoteOperationInProgress( + operation: 'מחפש מועמדים...', + noteId: event.noteId, + )); + + final note = await _repository.getNoteById(event.noteId); + if (note == null) { + emit(const NotesError(message: 'הערה לא נמצאה')); + return; + } + + // Create canonical document and find candidates + final canonicalDoc = await _canonicalService.createCanonicalDocument(note.bookId); + final result = await _anchoringService.reanchorNote(note, canonicalDoc); + + emit(CandidatesFound( + noteId: event.noteId, + candidates: result.candidates, + )); + } catch (e) { + emit(NotesError( + message: 'שגיאה בחיפוש מועמדים: ${e.toString()}', + operation: 'find_candidates', + error: e, + )); + } + } + + /// Handle resolving an orphan note + Future _onResolveOrphan(ResolveOrphanEvent event, Emitter emit) async { + try { + emit(NoteOperationInProgress( + operation: 'פותר הערה יתומה...', + noteId: event.noteId, + )); + + final resolvedNote = await _repository.resolveOrphanNote( + event.noteId, + event.selectedCandidate, + ); + + emit(OrphanResolved(resolvedNote)); + + // Refresh orphans list + if (state is OrphansLoaded) { + final orphansState = state as OrphansLoaded; + add(LoadOrphansEvent(bookId: orphansState.bookId)); + } + } catch (e) { + emit(NotesError( + message: 'שגיאה בפתרון הערה יתומה: ${e.toString()}', + operation: 'resolve_orphan', + error: e, + )); + } + } + + /// Handle re-anchoring notes for a book + Future _onReanchorNotes(ReanchorNotesEvent event, Emitter emit) async { + try { + emit(ReanchoringInProgress( + bookId: event.bookId, + totalNotes: 0, + processedNotes: 0, + )); + + final result = await _repository.reanchorNotesForBook(event.bookId); + + emit(ReanchoringCompleted(result)); + + // Refresh notes if we're viewing the same book + if (_currentBookId == event.bookId) { + add(LoadNotesEvent(event.bookId)); + } + } catch (e) { + emit(NotesError( + message: 'שגיאה בעיגון מחדש: ${e.toString()}', + operation: 'reanchor_notes', + error: e, + )); + } + } + + /// Handle exporting notes + Future _onExportNotes(ExportNotesEvent event, Emitter emit) async { + try { + emit(const NoteOperationInProgress(operation: 'מייצא הערות...')); + + final exportData = await _repository.exportNotes(event.options); + + emit(NotesExported( + exportData: exportData, + options: event.options, + )); + } catch (e) { + emit(NotesError( + message: 'שגיאה בייצוא הערות: ${e.toString()}', + operation: 'export_notes', + error: e, + )); + } + } + + /// Handle importing notes + Future _onImportNotes(ImportNotesEvent event, Emitter emit) async { + try { + emit(const NoteOperationInProgress(operation: 'מייבא הערות...')); + + final result = await _repository.importNotes(event.jsonData, event.options); + + emit(NotesImported(result)); + + // Refresh current notes if needed + if (_currentBookId != null) { + add(LoadNotesEvent(_currentBookId!)); + } + } catch (e) { + emit(NotesError( + message: 'שגיאה בייבוא הערות: ${e.toString()}', + operation: 'import_notes', + error: e, + )); + } + } + + /// Handle refreshing notes + Future _onRefreshNotes(RefreshNotesEvent event, Emitter emit) async { + if (_currentBookId != null) { + add(LoadNotesEvent(_currentBookId!)); + } + } + + /// Handle selecting a note + Future _onSelectNote(SelectNoteEvent event, Emitter emit) async { + if (state is NotesLoaded) { + final currentState = state as NotesLoaded; + final selectedNote = event.noteId != null + ? currentState.notes.firstWhere( + (note) => note.id == event.noteId, + orElse: () => currentState.notes.first, + ) + : null; + + emit(currentState.copyWith(selectedNote: selectedNote)); + } + } + + /// Handle toggling highlighting + Future _onToggleHighlighting(ToggleHighlightingEvent event, Emitter emit) async { + if (state is NotesLoaded) { + final currentState = state as NotesLoaded; + emit(currentState.copyWith(highlightingEnabled: event.enabled)); + } + } + + /// Handle updating visible range + Future _onUpdateVisibleRange(UpdateVisibleRangeEvent event, Emitter emit) async { + if (state is NotesLoaded) { + final currentState = state as NotesLoaded; + if (currentState.bookId == event.bookId) { + emit(currentState.copyWith(visibleRange: event.range)); + } + } + } + + /// Handle canceling operations + Future _onCancelOperations(CancelOperationsEvent event, Emitter emit) async { + _backgroundProcessor.cancelAllRequests(); + _activeOperations.clear(); + + // Return to previous state or initial + if (_currentBookId != null) { + add(LoadNotesEvent(_currentBookId!)); + } else { + emit(const NotesInitial()); + } + } + + /// Handle editing a note (opens editor dialog) + Future _onEditNote(EditNoteEvent event, Emitter emit) async { + // This event is handled by the UI layer to open the editor dialog + // The actual update will come through UpdateNoteEvent + // We can emit a state to indicate which note is being edited + if (state is NotesLoaded) { + final currentState = state as NotesLoaded; + emit(currentState.copyWith(selectedNote: event.note)); + } + } + + @override + Future close() { + _backgroundProcessor.cancelAllRequests(); + return super.close(); + } +} \ No newline at end of file diff --git a/lib/notes/bloc/notes_event.dart b/lib/notes/bloc/notes_event.dart new file mode 100644 index 000000000..a98a71d9e --- /dev/null +++ b/lib/notes/bloc/notes_event.dart @@ -0,0 +1,245 @@ +import 'package:equatable/equatable.dart'; +import '../models/note.dart'; +import '../models/anchor_models.dart'; +import '../repository/notes_repository.dart'; + +/// Base class for all notes events. +/// +/// Events represent user actions or system triggers that cause state changes +/// in the notes system. All events extend this base class and implement +/// [Equatable] for efficient comparison and deduplication. +/// +/// ## Event Categories +/// +/// ### Loading Events +/// - Load notes for books or specific text ranges +/// - Refresh data from database +/// - Handle cache invalidation +/// +/// ### CRUD Events +/// - Create, update, and delete notes +/// - Handle validation and error cases +/// - Manage optimistic updates +/// +/// ### Search Events +/// - Full-text search across notes +/// - Filter by status, tags, date ranges +/// - Sort and pagination +/// +/// ### Anchoring Events +/// - Re-anchor notes after text changes +/// - Resolve orphan notes with user input +/// - Find and evaluate anchor candidates +/// +/// ## Event Design Principles +/// +/// - **Immutable**: Events cannot be modified after creation +/// - **Serializable**: All data can be converted to/from JSON +/// - **Testable**: Events can be easily created for unit tests +/// - **Traceable**: Events include context for debugging +/// +/// ## Usage +/// +/// ```dart +/// // Dispatch events to BLoC +/// bloc.add(LoadNotesEvent('book-id')); +/// bloc.add(CreateNoteEvent(noteRequest)); +/// bloc.add(SearchNotesEvent('search query')); +/// ``` +abstract class NotesEvent extends Equatable { + const NotesEvent(); + + @override + List get props => []; +} + +/// Event to load notes for a specific book +class LoadNotesEvent extends NotesEvent { + final String bookId; + + const LoadNotesEvent(this.bookId); + + @override + List get props => [bookId]; +} + +/// Event to load notes for a visible character range +class LoadNotesForRangeEvent extends NotesEvent { + final String bookId; + final VisibleCharRange range; + + const LoadNotesForRangeEvent(this.bookId, this.range); + + @override + List get props => [bookId, range]; +} + +/// Event to create a new note +class CreateNoteEvent extends NotesEvent { + final CreateNoteRequest request; + + const CreateNoteEvent(this.request); + + @override + List get props => [request]; +} + +/// Event to update an existing note +class UpdateNoteEvent extends NotesEvent { + final String noteId; + final UpdateNoteRequest request; + + const UpdateNoteEvent(this.noteId, this.request); + + @override + List get props => [noteId, request]; +} + +/// Event to delete a note +class DeleteNoteEvent extends NotesEvent { + final String noteId; + + const DeleteNoteEvent(this.noteId); + + @override + List get props => [noteId]; +} + +/// Event to search notes +class SearchNotesEvent extends NotesEvent { + final String query; + final String? bookId; + + const SearchNotesEvent(this.query, {this.bookId}); + + @override + List get props => [query, bookId]; +} + +/// Event to clear search results +class ClearSearchEvent extends NotesEvent { + const ClearSearchEvent(); +} + +/// Event to load orphan notes +class LoadOrphansEvent extends NotesEvent { + final String? bookId; + + const LoadOrphansEvent({this.bookId}); + + @override + List get props => [bookId]; +} + +/// Event to find anchor candidates for an orphan note +class FindCandidatesEvent extends NotesEvent { + final String noteId; + + const FindCandidatesEvent(this.noteId); + + @override + List get props => [noteId]; +} + +/// Event to resolve an orphan note with a selected candidate +class ResolveOrphanEvent extends NotesEvent { + final String noteId; + final AnchorCandidate selectedCandidate; + + const ResolveOrphanEvent(this.noteId, this.selectedCandidate); + + @override + List get props => [noteId, selectedCandidate]; +} + +/// Event to re-anchor all notes for a book +class ReanchorNotesEvent extends NotesEvent { + final String bookId; + + const ReanchorNotesEvent(this.bookId); + + @override + List get props => [bookId]; +} + +/// Event to export notes +class ExportNotesEvent extends NotesEvent { + final ExportOptions options; + + const ExportNotesEvent(this.options); + + @override + List get props => [options]; +} + +/// Event to import notes +class ImportNotesEvent extends NotesEvent { + final String jsonData; + final ImportOptions options; + + const ImportNotesEvent(this.jsonData, this.options); + + @override + List get props => [jsonData, options]; +} + +/// Event to refresh notes (reload current state) +class RefreshNotesEvent extends NotesEvent { + const RefreshNotesEvent(); +} + +/// Event to select a note for detailed view +class SelectNoteEvent extends NotesEvent { + final String? noteId; + + const SelectNoteEvent(this.noteId); + + @override + List get props => [noteId]; +} + +/// Event to toggle note highlighting +class ToggleHighlightingEvent extends NotesEvent { + final bool enabled; + + const ToggleHighlightingEvent(this.enabled); + + @override + List get props => [enabled]; +} + +/// Event to update visible range (for performance optimization) +class UpdateVisibleRangeEvent extends NotesEvent { + final String bookId; + final VisibleCharRange range; + + const UpdateVisibleRangeEvent(this.bookId, this.range); + + @override + List get props => [bookId, range]; +} + +/// Event to cancel ongoing operations +class CancelOperationsEvent extends NotesEvent { + const CancelOperationsEvent(); +} + +/// Event to edit a note (opens editor dialog) +class EditNoteEvent extends NotesEvent { + final Note note; + + const EditNoteEvent(this.note); + + @override + List get props => [note]; +} + +/// Event to find anchor candidates for an orphan note (alias for compatibility) +class FindAnchorCandidatesEvent extends NotesEvent { + final Note orphanNote; + + const FindAnchorCandidatesEvent(this.orphanNote); + + @override + List get props => [orphanNote]; +} \ No newline at end of file diff --git a/lib/notes/bloc/notes_state.dart b/lib/notes/bloc/notes_state.dart new file mode 100644 index 000000000..92c564729 --- /dev/null +++ b/lib/notes/bloc/notes_state.dart @@ -0,0 +1,348 @@ +import 'package:equatable/equatable.dart'; +import '../models/note.dart'; +import '../models/anchor_models.dart'; +import '../repository/notes_repository.dart'; + +/// Base class for all notes states. +/// +/// States represent the current condition of the notes system at any point +/// in time. The UI rebuilds reactively when states change, providing a +/// unidirectional data flow from events to states to UI updates. +/// +/// ## State Categories +/// +/// ### Loading States +/// - `NotesInitial`: Starting state before any operations +/// - `NotesLoading`: Data is being fetched or processed +/// - `NotesUpdating`: Existing data is being modified +/// +/// ### Success States +/// - `NotesLoaded`: Notes successfully loaded and ready for display +/// - `NotesSearchResults`: Search completed with results +/// - `NoteCreated`: New note successfully created +/// - `NoteUpdated`: Existing note successfully updated +/// +/// ### Error States +/// - `NotesError`: General error with user-friendly message +/// - `NotesValidationError`: Input validation failed +/// - `NotesNetworkError`: Network/database connectivity issues +/// +/// ### Specialized States +/// - `OrphansFound`: Orphan notes detected and ready for resolution +/// - `CandidatesFound`: Anchor candidates found for orphan resolution +/// - `ReanchoringComplete`: Batch re-anchoring operation finished +/// +/// ## State Design Principles +/// +/// - **Immutable**: States cannot be modified after creation +/// - **Complete**: States contain all data needed by the UI +/// - **Efficient**: States use efficient data structures and avoid duplication +/// - **Debuggable**: States provide clear information for debugging +/// +/// ## Usage +/// +/// ```dart +/// // Listen to state changes +/// BlocBuilder( +/// builder: (context, state) { +/// return switch (state) { +/// NotesInitial() => InitialWidget(), +/// NotesLoading() => LoadingWidget(), +/// NotesLoaded() => NotesListWidget(notes: state.notes), +/// NotesError() => ErrorWidget(message: state.message), +/// }; +/// }, +/// ) +/// ``` +/// +/// ## Performance Considerations +/// +/// - States are compared by value for efficient rebuilds +/// - Large data sets use lazy loading and pagination +/// - Immutable collections prevent accidental mutations +/// - Memory usage is monitored and optimized +abstract class NotesState extends Equatable { + const NotesState(); + + @override + List get props => []; +} + +/// Initial state when BLoC is first created +class NotesInitial extends NotesState { + const NotesInitial(); +} + +/// State when notes are being loaded +class NotesLoading extends NotesState { + final String? message; + + const NotesLoading({this.message}); + + @override + List get props => [message]; +} + +/// State when notes have been successfully loaded +class NotesLoaded extends NotesState { + final String bookId; + final List notes; + final VisibleCharRange? visibleRange; + final Note? selectedNote; + final bool highlightingEnabled; + final DateTime lastUpdated; + + const NotesLoaded({ + required this.bookId, + required this.notes, + this.visibleRange, + this.selectedNote, + this.highlightingEnabled = true, + required this.lastUpdated, + }); + + /// Create a copy with updated fields + NotesLoaded copyWith({ + String? bookId, + List? notes, + VisibleCharRange? visibleRange, + Note? selectedNote, + bool? highlightingEnabled, + DateTime? lastUpdated, + }) { + return NotesLoaded( + bookId: bookId ?? this.bookId, + notes: notes ?? this.notes, + visibleRange: visibleRange ?? this.visibleRange, + selectedNote: selectedNote ?? this.selectedNote, + highlightingEnabled: highlightingEnabled ?? this.highlightingEnabled, + lastUpdated: lastUpdated ?? this.lastUpdated, + ); + } + + /// Get notes that are visible in the current range + List get visibleNotes { + if (visibleRange == null) return notes; + + return notes.where((note) { + return visibleRange!.contains(note.charStart) || + visibleRange!.contains(note.charEnd) || + (note.charStart <= visibleRange!.start && note.charEnd >= visibleRange!.end); + }).toList(); + } + + /// Get notes by status + List getNotesByStatus(NoteStatus status) { + return notes.where((note) => note.status == status).toList(); + } + + /// Get anchored notes count + int get anchoredCount => getNotesByStatus(NoteStatus.anchored).length; + + /// Get shifted notes count + int get shiftedCount => getNotesByStatus(NoteStatus.shifted).length; + + /// Get orphan notes count + int get orphanCount => getNotesByStatus(NoteStatus.orphan).length; + + @override + List get props => [ + bookId, + notes, + visibleRange, + selectedNote, + highlightingEnabled, + lastUpdated, + ]; +} + +/// State when a note operation is in progress +class NoteOperationInProgress extends NotesState { + final String operation; + final String? noteId; + final double? progress; + + const NoteOperationInProgress({ + required this.operation, + this.noteId, + this.progress, + }); + + @override + List get props => [operation, noteId, progress]; +} + +/// State when a note has been successfully created +class NoteCreated extends NotesState { + final Note note; + + const NoteCreated(this.note); + + @override + List get props => [note]; +} + +/// State when a note has been successfully updated +class NoteUpdated extends NotesState { + final Note note; + + const NoteUpdated(this.note); + + @override + List get props => [note]; +} + +/// State when a note has been successfully deleted +class NoteDeleted extends NotesState { + final String noteId; + + const NoteDeleted(this.noteId); + + @override + List get props => [noteId]; +} + +/// State when search results are available +class NotesSearchResults extends NotesState { + final String query; + final List results; + final String? bookId; + + const NotesSearchResults({ + required this.query, + required this.results, + this.bookId, + }); + + @override + List get props => [query, results, bookId]; +} + +/// State when orphan notes are loaded +class OrphansLoaded extends NotesState { + final List orphanNotes; + final String? bookId; + + const OrphansLoaded({ + required this.orphanNotes, + this.bookId, + }); + + @override + List get props => [orphanNotes, bookId]; +} + +/// State when anchor candidates are found for an orphan note +class CandidatesFound extends NotesState { + final String noteId; + final List candidates; + + const CandidatesFound({ + required this.noteId, + required this.candidates, + }); + + @override + List get props => [noteId, candidates]; +} + +/// State when an orphan note has been resolved +class OrphanResolved extends NotesState { + final Note resolvedNote; + + const OrphanResolved(this.resolvedNote); + + @override + List get props => [resolvedNote]; +} + +/// State when re-anchoring is in progress +class ReanchoringInProgress extends NotesState { + final String bookId; + final int totalNotes; + final int processedNotes; + + const ReanchoringInProgress({ + required this.bookId, + required this.totalNotes, + required this.processedNotes, + }); + + double get progress => totalNotes > 0 ? processedNotes / totalNotes : 0.0; + + @override + List get props => [bookId, totalNotes, processedNotes]; +} + +/// State when re-anchoring is completed +class ReanchoringCompleted extends NotesState { + final ReanchoringResult result; + + const ReanchoringCompleted(this.result); + + @override + List get props => [result]; +} + +/// State when notes export is completed +class NotesExported extends NotesState { + final String exportData; + final ExportOptions options; + + const NotesExported({ + required this.exportData, + required this.options, + }); + + @override + List get props => [exportData, options]; +} + +/// State when notes import is completed +class NotesImported extends NotesState { + final ImportResult result; + + const NotesImported(this.result); + + @override + List get props => [result]; +} + +/// State when an error occurs +class NotesError extends NotesState { + final String message; + final String? operation; + final dynamic error; + + const NotesError({ + required this.message, + this.operation, + this.error, + }); + + @override + List get props => [message, operation, error]; +} + +/// State when multiple operations are running +class NotesMultipleOperations extends NotesState { + final List operations; + final Map progress; + + const NotesMultipleOperations({ + required this.operations, + required this.progress, + }); + + @override + List get props => [operations, progress]; +} + +/// State when search results are loaded (alias for compatibility) +class SearchResultsLoaded extends NotesSearchResults { + const SearchResultsLoaded({ + required super.query, + required super.results, + super.bookId, + }); +} \ No newline at end of file diff --git a/lib/notes/config/notes_config.dart b/lib/notes/config/notes_config.dart new file mode 100644 index 000000000..203491362 --- /dev/null +++ b/lib/notes/config/notes_config.dart @@ -0,0 +1,229 @@ +/// Configuration constants for the notes anchoring system. +/// +/// This class contains all the tuned parameters that control the behavior +/// of the anchoring algorithms. These values have been carefully chosen +/// based on testing with Hebrew texts and should not be modified without +/// extensive testing. +/// +/// ## Parameter Categories +/// +/// ### Context Windows +/// - Control how much surrounding text is used for anchoring +/// - Larger windows = more context but slower processing +/// - Smaller windows = faster but less reliable anchoring +/// +/// ### Similarity Thresholds +/// - Determine when text is "similar enough" to anchor +/// - Higher thresholds = stricter matching, fewer false positives +/// - Lower thresholds = more lenient matching, more false positives +/// +/// ### Performance Limits +/// - Ensure the system remains responsive under load +/// - Prevent runaway operations that could freeze the UI +/// - Balance accuracy with speed requirements +/// +/// ## Tuning Guidelines +/// +/// These parameters were optimized for: +/// - Hebrew text with nikud and RTL formatting +/// - Books with 10,000-100,000 characters +/// - 100-1000 notes per book +/// - 98% accuracy target after 5% text changes +/// +/// **Warning**: Changing these values may significantly impact accuracy +/// and performance. Always test thoroughly with representative data. +class AnchoringConstants { + /// Context window size (characters before and after selected text). + /// + /// This determines how much surrounding text is captured and used for + /// context-based anchoring. A larger window provides more context but + /// increases processing time and memory usage. + /// + /// **Value**: 40 characters (optimized for Hebrew text) + /// **Range**: 20-100 characters recommended + static const int contextWindowSize = 40; + + /// Maximum distance between prefix and suffix for context matching. + /// + /// When searching for context matches, this limits how far apart the + /// before and after context can be. Prevents matching unrelated text + /// that happens to have similar context fragments. + /// + /// **Value**: 300 characters (allows for moderate text insertions) + /// **Range**: 200-500 characters recommended + static const int maxContextDistance = 300; + + /// Levenshtein distance threshold for fuzzy matching. + /// + /// Maximum allowed edit distance as a fraction of the original text length. + /// Lower values require closer matches, higher values are more permissive. + /// + /// **Value**: 0.18 (18% of original length) + /// **Example**: 50-char text allows up to 9 character changes + static const double levenshteinThreshold = 0.18; + + /// Jaccard similarity threshold for n-gram matching. + /// + /// Minimum required overlap between n-gram sets. Higher values require + /// more similar text structure, lower values allow more variation. + /// + /// **Value**: 0.82 (82% n-gram overlap required) + /// **Range**: 0.7-0.9 recommended for Hebrew text + static const double jaccardThreshold = 0.82; + + /// Cosine similarity threshold for semantic matching. + /// + /// Minimum required cosine similarity between n-gram frequency vectors. + /// Captures semantic similarity even when word order changes. + /// + /// **Value**: 0.82 (82% vector similarity required) + /// **Range**: 0.7-0.9 recommended for semantic matching + static const double cosineThreshold = 0.82; + + /// N-gram size for fuzzy matching algorithms. + /// + /// Size of character sequences used for Jaccard and Cosine similarity. + /// Smaller values are more sensitive to character changes, larger values + /// focus on word-level patterns. + /// + /// **Value**: 3 characters (optimal for Hebrew with nikud) + /// **Range**: 2-4 characters recommended + static const int ngramSize = 3; + + /// Weight for Levenshtein similarity in composite scoring. + /// + /// Controls the influence of character-level edit distance in the + /// final similarity score. Higher weight emphasizes exact character matching. + static const double levenshteinWeight = 0.4; + + /// Weight for Jaccard similarity in composite scoring. + /// + /// Controls the influence of n-gram overlap in the final similarity score. + /// Higher weight emphasizes structural text similarity. + static const double jaccardWeight = 0.3; + + /// Weight for Cosine similarity in composite scoring. + /// + /// Controls the influence of semantic similarity in the final score. + /// Higher weight emphasizes meaning preservation over exact structure. + static const double cosineWeight = 0.3; + + /// Maximum time allowed for re-anchoring a single note (milliseconds). + /// + /// Prevents runaway operations that could freeze the UI. If re-anchoring + /// takes longer than this, the operation is terminated and the note is + /// marked as orphan. + /// + /// **Value**: 50ms (maintains 60fps UI responsiveness) + static const int maxReanchoringTimeMs = 50; + + /// Maximum delay allowed for page load operations (milliseconds). + /// + /// Ensures UI remains responsive during note loading. Operations that + /// exceed this limit are moved to background processing. + /// + /// **Value**: 16ms (60fps frame budget) + static const int maxPageLoadDelayMs = 16; + + /// Window size for rolling hash calculations. + /// + /// Size of the sliding window used for polynomial rolling hash. + /// Larger windows provide more unique hashes but increase computation. + /// + /// **Value**: 20 characters (balanced uniqueness vs. performance) + static const int rollingHashWindowSize = 20; + + /// Minimum score difference to trigger orphan manager. + /// + /// When multiple candidates have scores within this difference, the + /// situation is considered ambiguous and requires manual resolution + /// through the orphan manager. + /// + /// **Value**: 0.03 (3% score difference) + /// **Example**: Scores 0.85 and 0.87 would trigger orphan manager + static const double candidateScoreDifference = 0.03; +} + +/// Database configuration constants +class DatabaseConfig { + static const String databaseName = 'notes.db'; + static const int databaseVersion = 1; + static const String notesTable = 'notes'; + static const String canonicalDocsTable = 'canonical_documents'; + static const String notesFtsTable = 'notes_fts'; + + /// Cache settings + static const int maxCacheSize = 10000; + static const Duration cacheExpiry = Duration(hours: 1); +} + +/// Feature flags for the notes system +class NotesConfig { + static const bool enabled = true; // Kill switch + static const bool highlightEnabled = true; // Emergency disable highlights + static const bool fuzzyMatchingEnabled = false; // V2 feature + static const bool encryptionEnabled = false; // V2 feature + static const bool importExportEnabled = false; // V2 feature + static const int maxNotesPerBook = 5000; // Resource limit + static const int maxNoteSize = 32768; // 32KB limit + static const int reanchoringTimeoutMs = 50; // Performance limit + static const int maxReanchoringBatchSize = 200; // Optimal batch limit + static const bool telemetryEnabled = true; // Performance telemetry + static const int busyTimeoutMs = 5000; // SQLite busy timeout +} + +/// Environment-specific settings +class NotesEnvironment { + static const bool debugMode = bool.fromEnvironment('dart.vm.product') == false; + static const bool telemetryEnabled = !debugMode; + static const bool performanceLogging = debugMode; + static const String databasePath = debugMode ? 'notes_debug.db' : 'notes.db'; +} + +/// Text normalization configuration +class NormalizationConfig { + /// Current normalization version + static const String version = 'v1'; + + /// Whether to remove nikud (vowel points) + final bool removeNikud; + + /// Quote normalization style + final String quoteStyle; + + /// Unicode normalization form + final String unicodeForm; + + const NormalizationConfig({ + this.removeNikud = false, + this.quoteStyle = 'ascii', + this.unicodeForm = 'NFKC', + }); + + /// Creates a configuration string for storage + String toConfigString() { + return 'norm=$version;nikud=${removeNikud ? 'remove' : 'keep'};quotes=$quoteStyle;unicode=$unicodeForm'; + } + + /// Parses a configuration string + factory NormalizationConfig.fromConfigString(String config) { + final parts = config.split(';'); + final map = {}; + + for (final part in parts) { + final keyValue = part.split('='); + if (keyValue.length == 2) { + map[keyValue[0]] = keyValue[1]; + } + } + + return NormalizationConfig( + removeNikud: map['nikud'] == 'remove', + quoteStyle: map['quotes'] ?? 'ascii', + unicodeForm: map['unicode'] ?? 'NFKC', + ); + } + + @override + String toString() => toConfigString(); +} \ No newline at end of file diff --git a/lib/notes/data/database_schema.dart b/lib/notes/data/database_schema.dart new file mode 100644 index 000000000..31d705f40 --- /dev/null +++ b/lib/notes/data/database_schema.dart @@ -0,0 +1,162 @@ +/// SQL schema and configuration for the notes database +class DatabaseSchema { + /// SQL to create the notes table + static const String createNotesTable = ''' + CREATE TABLE IF NOT EXISTS notes ( + note_id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + doc_version_id TEXT NOT NULL, + logical_path TEXT, + char_start INTEGER NOT NULL, + char_end INTEGER NOT NULL, + selected_text_normalized TEXT NOT NULL, + text_hash TEXT NOT NULL, + ctx_before TEXT NOT NULL, + ctx_after TEXT NOT NULL, + ctx_before_hash TEXT NOT NULL, + ctx_after_hash TEXT NOT NULL, + rolling_before INTEGER NOT NULL, + rolling_after INTEGER NOT NULL, + status TEXT NOT NULL CHECK (status IN ('anchored', 'shifted', 'orphan')), + content_markdown TEXT NOT NULL, + author_user_id TEXT NOT NULL, + privacy TEXT NOT NULL CHECK (privacy IN ('private', 'shared')), + tags TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + normalization_config TEXT NOT NULL + ); + '''; + + /// SQL to create the canonical documents table + static const String createCanonicalDocsTable = ''' + CREATE TABLE IF NOT EXISTS canonical_documents ( + id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + version_id TEXT NOT NULL, + canonical_text TEXT NOT NULL, + text_hash_index TEXT NOT NULL, + context_hash_index TEXT NOT NULL, + rolling_hash_index TEXT NOT NULL, + logical_structure TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(book_id, version_id) + ); + '''; + + /// SQL to create performance indexes + static const List createIndexes = [ + 'CREATE INDEX IF NOT EXISTS idx_notes_book_id ON notes(book_id);', + 'CREATE INDEX IF NOT EXISTS idx_notes_doc_version ON notes(doc_version_id);', + 'CREATE INDEX IF NOT EXISTS idx_notes_text_hash ON notes(text_hash);', + 'CREATE INDEX IF NOT EXISTS idx_notes_ctx_hashes ON notes(ctx_before_hash, ctx_after_hash);', + 'CREATE INDEX IF NOT EXISTS idx_notes_author ON notes(author_user_id);', + 'CREATE INDEX IF NOT EXISTS idx_notes_status ON notes(status);', + 'CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(updated_at);', + 'CREATE INDEX IF NOT EXISTS idx_canonical_book_version ON canonical_documents(book_id, version_id);', + ]; + + /// SQL to create FTS table for Hebrew content search + static const String createFtsTable = ''' + CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5( + content_markdown, + tags, + selected_text_normalized, + content='notes', + content_rowid='rowid' + ); + '''; + + /// SQL triggers to sync FTS table + static const List createFtsTriggers = [ + ''' + CREATE TRIGGER IF NOT EXISTS notes_fts_insert AFTER INSERT ON notes BEGIN + INSERT INTO notes_fts(rowid, content_markdown, tags, selected_text_normalized) + VALUES (new.rowid, new.content_markdown, new.tags, new.selected_text_normalized); + END; + ''', + ''' + CREATE TRIGGER IF NOT EXISTS notes_fts_delete AFTER DELETE ON notes BEGIN + DELETE FROM notes_fts WHERE rowid = old.rowid; + END; + ''', + ''' + CREATE TRIGGER IF NOT EXISTS notes_fts_update AFTER UPDATE ON notes BEGIN + DELETE FROM notes_fts WHERE rowid = old.rowid; + INSERT INTO notes_fts(rowid, content_markdown, tags, selected_text_normalized) + VALUES (new.rowid, new.content_markdown, new.tags, new.selected_text_normalized); + END; + ''', + ]; + + /// SQLite PRAGMA optimizations + static const List pragmaOptimizations = [ + 'PRAGMA journal_mode=WAL;', + 'PRAGMA synchronous=NORMAL;', + 'PRAGMA temp_store=MEMORY;', + 'PRAGMA cache_size=10000;', + 'PRAGMA foreign_keys=ON;', + 'PRAGMA busy_timeout=5000;', + 'PRAGMA analysis_limit=400;', + ]; + + /// SQL to run ANALYZE after initial data population + static const String analyzeDatabase = 'ANALYZE;'; + + /// Initialize the notes database with all required tables and indexes + static Future initializeDatabase() async { + // This is a placeholder - actual implementation would use SQLite + // For now, we'll just log that initialization was attempted + // print('Notes database initialization attempted'); + + // In a real implementation, this would: + // 1. Open/create the database file + // 2. Run all schema creation statements + // 3. Apply PRAGMA optimizations + // 4. Run ANALYZE for query optimization + + // Example implementation structure: + // final db = await openDatabase('notes.db'); + // for (final statement in allSchemaStatements) { + // await db.execute(statement); + // } + // for (final pragma in pragmaOptimizations) { + // await db.execute(pragma); + // } + // await db.execute(analyzeDatabase); + } + + /// Get all schema creation statements in order (without PRAGMA) + static List get allSchemaStatements => [ + createNotesTable, + createCanonicalDocsTable, + ...createIndexes, + createFtsTable, + ...createFtsTriggers, + ]; + + /// Validation queries to check schema integrity + static const Map validationQueries = { + 'notes_table_exists': ''' + SELECT name FROM sqlite_master + WHERE type='table' AND name='notes'; + ''', + 'canonical_docs_table_exists': ''' + SELECT name FROM sqlite_master + WHERE type='table' AND name='canonical_documents'; + ''', + 'fts_table_exists': ''' + SELECT name FROM sqlite_master + WHERE type='table' AND name='notes_fts'; + ''', + 'indexes_count': ''' + SELECT COUNT(*) as count FROM sqlite_master + WHERE type='index' AND name LIKE 'idx_notes_%'; + ''', + 'triggers_count': ''' + SELECT COUNT(*) as count FROM sqlite_master + WHERE type='trigger' AND name LIKE 'notes_fts_%'; + ''', + }; +} \ No newline at end of file diff --git a/lib/notes/data/notes_data_provider.dart b/lib/notes/data/notes_data_provider.dart new file mode 100644 index 000000000..2a899564c --- /dev/null +++ b/lib/notes/data/notes_data_provider.dart @@ -0,0 +1,355 @@ +import 'dart:io'; +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart'; +import '../models/note.dart'; + +import '../config/notes_config.dart'; +import 'database_schema.dart'; + +/// Data provider for notes database operations +class NotesDataProvider { + static NotesDataProvider? _instance; + Database? _database; + + NotesDataProvider._(); + + /// Singleton instance + static NotesDataProvider get instance { + _instance ??= NotesDataProvider._(); + return _instance!; + } + + /// Get the database instance, creating it if necessary + Future get database async { + _database ??= await _initDatabase(); + return _database!; + } + + /// Initialize the database with schema and optimizations + Future _initDatabase() async { + // Always use persistent database - even in debug mode + // In-memory database would lose data when app closes + + final databasesPath = await getDatabasesPath(); + final path = join(databasesPath, NotesEnvironment.databasePath); + + return await openDatabase( + path, + version: DatabaseConfig.databaseVersion, + onCreate: _onCreate, + onUpgrade: _onUpgrade, + onOpen: _onOpen, + ); + } + + /// Create database schema on first run + Future _onCreate(Database db, int version) async { + // Execute all schema statements + for (final statement in DatabaseSchema.allSchemaStatements) { + await db.execute(statement); + } + + // Run ANALYZE after schema creation + await db.execute(DatabaseSchema.analyzeDatabase); + } + + /// Handle database upgrades + Future _onUpgrade(Database db, int oldVersion, int newVersion) async { + // Future database migrations will be handled here + if (oldVersion < newVersion) { + // For now, recreate the database + await _dropAllTables(db); + await _onCreate(db, newVersion); + } + } + + /// Configure database on open + Future _onOpen(Database db) async { + // Apply PRAGMA settings (skip problematic ones in testing) + for (final pragma in DatabaseSchema.pragmaOptimizations) { + try { + // Skip synchronous pragma in testing as it can cause issues + if (NotesEnvironment.debugMode && pragma.contains('synchronous')) { + continue; + } + await db.execute(pragma); + } catch (e) { + // Log but don't fail on PRAGMA errors in testing + if (NotesEnvironment.performanceLogging) { + // print('PRAGMA warning: $pragma failed with $e'); + } + } + } + } + + /// Drop all tables (for migrations) + Future _dropAllTables(Database db) async { + await db.execute('DROP TABLE IF EXISTS notes_fts;'); + await db.execute('DROP TABLE IF EXISTS notes;'); + await db.execute('DROP TABLE IF EXISTS canonical_documents;'); + } + + /// Validate database schema integrity + Future validateSchema() async { + try { + final db = await database; + + for (final entry in DatabaseSchema.validationQueries.entries) { + final result = await db.rawQuery(entry.value); + + switch (entry.key) { + case 'notes_table_exists': + case 'canonical_docs_table_exists': + case 'fts_table_exists': + if (result.isEmpty) return false; + break; + case 'indexes_count': + final count = result.first['count'] as int; + if (count < 7) return false; // Expected number of indexes + break; + case 'triggers_count': + final count = result.first['count'] as int; + if (count < 3) return false; // Expected number of triggers + break; + } + } + + return true; + } catch (e) { + return false; + } + } + + /// Create a new note + Future createNote(Note note) async { + final db = await database; + + await db.transaction((txn) async { + await txn.insert( + DatabaseConfig.notesTable, + note.toJson(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + }); + + return note; + } + + /// Get a note by ID + Future getNoteById(String noteId) async { + final db = await database; + + final result = await db.query( + DatabaseConfig.notesTable, + where: 'note_id = ?', + whereArgs: [noteId], + limit: 1, + ); + + if (result.isEmpty) return null; + return Note.fromJson(result.first); + } + + /// Get all notes for a book + Future> getNotesForBook(String bookId) async { + final db = await database; + + final result = await db.query( + DatabaseConfig.notesTable, + where: 'book_id = ?', + whereArgs: [bookId], + orderBy: 'char_start ASC', + ); + + return result.map((json) => Note.fromJson(json)).toList(); + } + + /// Get notes for a specific character range + Future> getNotesForCharRange( + String bookId, + int startChar, + int endChar, + ) async { + final db = await database; + + final result = await db.query( + DatabaseConfig.notesTable, + where: ''' + book_id = ? AND + ((char_start >= ? AND char_start <= ?) OR + (char_end >= ? AND char_end <= ?) OR + (char_start <= ? AND char_end >= ?)) + ''', + whereArgs: [bookId, startChar, endChar, startChar, endChar, startChar, endChar], + orderBy: 'char_start ASC', + ); + + return result.map((json) => Note.fromJson(json)).toList(); + } + + /// Update an existing note + Future updateNote(Note note) async { + final db = await database; + + final updatedNote = note.copyWith(updatedAt: DateTime.now()); + + await db.transaction((txn) async { + await txn.update( + DatabaseConfig.notesTable, + updatedNote.toJson(), + where: 'note_id = ?', + whereArgs: [note.id], + ); + }); + + return updatedNote; + } + + /// Delete a note + Future deleteNote(String noteId) async { + final db = await database; + + await db.transaction((txn) async { + await txn.delete( + DatabaseConfig.notesTable, + where: 'note_id = ?', + whereArgs: [noteId], + ); + }); + } + + /// Search notes using FTS + Future> searchNotes(String query, {String? bookId}) async { + final db = await database; + + String whereClause = 'notes_fts MATCH ?'; + List whereArgs = [query]; + + if (bookId != null) { + whereClause += ' AND notes.book_id = ?'; + whereArgs.add(bookId); + } + + final result = await db.rawQuery(''' + SELECT notes.* FROM notes_fts + JOIN notes ON notes.rowid = notes_fts.rowid + WHERE $whereClause + ORDER BY bm25(notes_fts) ASC + LIMIT 100 + ''', whereArgs); + + return result.map((json) => Note.fromJson(json)).toList(); + } + + /// Get notes by status + Future> getNotesByStatus(NoteStatus status, {String? bookId}) async { + final db = await database; + + String whereClause = 'status = ?'; + List whereArgs = [status.name]; + + if (bookId != null) { + whereClause += ' AND book_id = ?'; + whereArgs.add(bookId); + } + + final result = await db.query( + DatabaseConfig.notesTable, + where: whereClause, + whereArgs: whereArgs, + orderBy: 'updated_at DESC', + ); + + return result.map((json) => Note.fromJson(json)).toList(); + } + + /// Get orphan notes that need manual resolution + Future> getOrphanNotes({String? bookId}) async { + return getNotesByStatus(NoteStatus.orphan, bookId: bookId); + } + + /// Update note status (for re-anchoring) + Future updateNoteStatus(String noteId, NoteStatus status, { + int? newStart, + int? newEnd, + }) async { + final db = await database; + + final updateData = { + 'status': status.name, + 'updated_at': DateTime.now().toIso8601String(), + }; + + if (newStart != null) updateData['char_start'] = newStart; + if (newEnd != null) updateData['char_end'] = newEnd; + + await db.transaction((txn) async { + await txn.update( + DatabaseConfig.notesTable, + updateData, + where: 'note_id = ?', + whereArgs: [noteId], + ); + }); + } + + /// Batch update multiple notes (for re-anchoring) + Future batchUpdateNotes(List notes) async { + final db = await database; + + await db.transaction((txn) async { + for (final note in notes) { + await txn.update( + DatabaseConfig.notesTable, + note.toJson(), + where: 'note_id = ?', + whereArgs: [note.id], + ); + } + }); + } + + /// Get database statistics + Future> getDatabaseStats() async { + final db = await database; + + final notesCount = Sqflite.firstIntValue( + await db.rawQuery('SELECT COUNT(*) FROM notes'), + ) ?? 0; + + final canonicalDocsCount = Sqflite.firstIntValue( + await db.rawQuery('SELECT COUNT(*) FROM canonical_documents'), + ) ?? 0; + + final orphanNotesCount = Sqflite.firstIntValue( + await db.rawQuery("SELECT COUNT(*) FROM notes WHERE status = 'orphan'"), + ) ?? 0; + + return { + 'total_notes': notesCount, + 'canonical_documents': canonicalDocsCount, + 'orphan_notes': orphanNotesCount, + }; + } + + /// Close the database connection + Future close() async { + if (_database != null) { + await _database!.close(); + _database = null; + } + } + + /// Reset the database (for testing) + Future reset() async { + await close(); + final databasesPath = await getDatabasesPath(); + final path = join(databasesPath, NotesEnvironment.databasePath); + + if (await File(path).exists()) { + await File(path).delete(); + } + + _database = null; + } +} \ No newline at end of file diff --git a/lib/notes/models/anchor_models.dart b/lib/notes/models/anchor_models.dart new file mode 100644 index 000000000..a3eb8bc95 --- /dev/null +++ b/lib/notes/models/anchor_models.dart @@ -0,0 +1,288 @@ +import 'package:equatable/equatable.dart'; +import 'note.dart'; + +/// Represents a candidate location for anchoring a note. +/// +/// When the anchoring system cannot find an exact match for a note's original +/// location, it generates a list of candidate locations where the note might +/// belong. Each candidate has a similarity score and the strategy used to find it. +/// +/// ## Scoring +/// +/// Candidates are scored from 0.0 to 1.0: +/// - **1.0**: Perfect match (exact text and context) +/// - **0.9-0.99**: Very high confidence (minor changes) +/// - **0.8-0.89**: High confidence (moderate changes) +/// - **0.7-0.79**: Medium confidence (significant changes) +/// - **< 0.7**: Low confidence (major changes) +/// +/// ## Strategies +/// +/// Different strategies are used to find candidates: +/// - **"exact"**: Exact text hash match +/// - **"context"**: Context window matching +/// - **"fuzzy"**: Fuzzy text similarity +/// - **"semantic"**: Word-level semantic matching +/// +/// ## Usage +/// +/// ```dart +/// // Create a candidate +/// final candidate = AnchorCandidate(100, 150, 0.85, 'fuzzy'); +/// +/// // Check confidence level +/// if (candidate.score > 0.9) { +/// // High confidence - auto-anchor +/// } else if (candidate.score > 0.7) { +/// // Medium confidence - suggest to user +/// } else { +/// // Low confidence - manual review needed +/// } +/// ``` +class AnchorCandidate extends Equatable { + /// Start character position of the candidate + final int start; + + /// End character position of the candidate + final int end; + + /// Similarity score (0.0 to 1.0) + final double score; + + /// Strategy used to find this candidate + final String strategy; + + const AnchorCandidate( + this.start, + this.end, + this.score, + this.strategy, + ); + + @override + List get props => [start, end, score, strategy]; + + @override + String toString() { + return 'AnchorCandidate(start: $start, end: $end, score: ${score.toStringAsFixed(3)}, strategy: $strategy)'; + } +} + +/// Represents the result of an anchoring operation +class AnchorResult extends Equatable { + /// The resulting status of the anchoring + final NoteStatus status; + + /// Start position if successfully anchored + final int? start; + + /// End position if successfully anchored + final int? end; + + /// List of candidate positions found + final List candidates; + + /// Error message if anchoring failed + final String? errorMessage; + + const AnchorResult( + this.status, { + this.start, + this.end, + this.candidates = const [], + this.errorMessage, + }); + + /// Whether the anchoring was successful + bool get isSuccess => status != NoteStatus.orphan || candidates.isNotEmpty; + + /// Whether multiple candidates were found requiring user choice + bool get hasMultipleCandidates => candidates.length > 1; + + @override + List get props => [status, start, end, candidates, errorMessage]; + + @override + String toString() { + return 'AnchorResult(status: $status, candidates: ${candidates.length}, success: $isSuccess)'; + } +} + +/// Represents anchor data for a note +class AnchorData extends Equatable { + /// Character start position + final int charStart; + + /// Character end position + final int charEnd; + + /// Hash of the selected text + final String textHash; + + /// Context before the selection + final String contextBefore; + + /// Context after the selection + final String contextAfter; + + /// Hash of context before + final String contextBeforeHash; + + /// Hash of context after + final String contextAfterHash; + + /// Rolling hash before + final int rollingBefore; + + /// Rolling hash after + final int rollingAfter; + + /// Current status + final NoteStatus status; + + const AnchorData({ + required this.charStart, + required this.charEnd, + required this.textHash, + required this.contextBefore, + required this.contextAfter, + required this.contextBeforeHash, + required this.contextAfterHash, + required this.rollingBefore, + required this.rollingAfter, + required this.status, + }); + + @override + List get props => [ + charStart, + charEnd, + textHash, + contextBefore, + contextAfter, + contextBeforeHash, + contextAfterHash, + rollingBefore, + rollingAfter, + status, + ]; +} + +/// Represents a canonical document with search indexes +class CanonicalDocument extends Equatable { + /// Document identifier + final String id; + + /// Book identifier + final String bookId; + + /// Version identifier + final String versionId; + + /// The canonical text content + final String canonicalText; + + /// Index mapping text hashes to character positions + final Map> textHashIndex; + + /// Index mapping context hashes to character positions + final Map> contextHashIndex; + + /// Index mapping rolling hashes to character positions + final Map> rollingHashIndex; + + /// Logical structure of the document + final List? logicalStructure; + + /// When the document was created + final DateTime createdAt; + + /// When the document was last updated + final DateTime updatedAt; + + const CanonicalDocument({ + required this.id, + required this.bookId, + required this.versionId, + required this.canonicalText, + required this.textHashIndex, + required this.contextHashIndex, + required this.rollingHashIndex, + this.logicalStructure, + required this.createdAt, + required this.updatedAt, + }); + + @override + List get props => [ + id, + bookId, + versionId, + canonicalText, + textHashIndex, + contextHashIndex, + rollingHashIndex, + logicalStructure, + createdAt, + updatedAt, + ]; +} + +/// Represents a visible character range in the text +class VisibleCharRange extends Equatable { + /// Start character position + final int start; + + /// End character position + final int end; + + const VisibleCharRange(this.start, this.end); + + /// Length of the range + int get length => end - start; + + /// Whether this range contains the given position + bool contains(int position) => position >= start && position <= end; + + /// Whether this range overlaps with another range + bool overlaps(VisibleCharRange other) { + return start <= other.end && end >= other.start; + } + + @override + List get props => [start, end]; + + @override + String toString() { + return 'VisibleCharRange($start-$end)'; + } +} + +/// Types of anchoring errors +enum AnchoringError { + documentNotFound, + multipleMatches, + noMatchFound, + corruptedAnchor, + versionMismatch, +} + +/// Exception thrown during anchoring operations +class AnchoringException implements Exception { + final AnchoringError type; + final String message; + final Note? note; + final List? candidates; + + const AnchoringException( + this.type, + this.message, { + this.note, + this.candidates, + }); + + @override + String toString() { + return 'AnchoringException: $type - $message'; + } +} \ No newline at end of file diff --git a/lib/notes/models/note.dart b/lib/notes/models/note.dart new file mode 100644 index 000000000..21d861dac --- /dev/null +++ b/lib/notes/models/note.dart @@ -0,0 +1,341 @@ +import 'package:equatable/equatable.dart'; + +/// Represents the status of a note's anchoring in the text. +/// +/// The anchoring status indicates how well the note's original location +/// has been preserved after text changes in the document. +enum NoteStatus { + /// Note is anchored to its exact original location. + /// + /// This is the ideal state - the text hash matches exactly and the note + /// appears at its original character positions. No re-anchoring was needed. + anchored, + + /// Note was re-anchored to a shifted but similar location. + /// + /// The original text was not found at the exact position, but the anchoring + /// system successfully found a highly similar location using context or + /// fuzzy matching. The note content is still relevant to the new location. + shifted, + + /// Note could not be anchored and requires manual resolution. + /// + /// The anchoring system could not find a suitable location for this note. + /// This happens when text is significantly changed or deleted. The note + /// needs manual review through the Orphan Manager. + orphan, +} + +/// Represents the privacy level of a note. +/// +/// Controls who can see and access the note content. +enum NotePrivacy { + /// Note is private to the user. + /// + /// Only the user who created the note can see it. This is the default + /// privacy level for new notes. + private, + + /// Note can be shared with others. + /// + /// The note can be exported and shared with other users. Future versions + /// may support collaborative note sharing. + shared, +} + +/// Represents a personal note attached to a specific text location. +/// +/// A note contains user-generated content (markdown) that is anchored to +/// a specific location in a book's text. The anchoring system ensures +/// notes stay connected to their relevant text even when the book content +/// changes. +/// +/// ## Core Properties +/// +/// - **Identity**: Unique ID and book association +/// - **Location**: Character positions and anchoring data +/// - **Content**: Markdown text and metadata (tags, privacy) +/// - **Anchoring**: Hashes and context for re-anchoring +/// - **Status**: Current anchoring state (anchored/shifted/orphan) +/// +/// ## Anchoring Data +/// +/// Each note stores multiple pieces of anchoring information: +/// - Text hash of the selected content +/// - Context hashes (before/after the selection) +/// - Rolling hashes for sliding window matching +/// - Normalization config used when creating hashes +/// +/// ## Usage +/// +/// ```dart +/// // Create a new note +/// final note = Note( +/// id: 'unique-id', +/// bookId: 'book-id', +/// charStart: 100, +/// charEnd: 150, +/// contentMarkdown: 'My note content', +/// // ... other required fields +/// ); +/// +/// // Check note status +/// if (note.status == NoteStatus.orphan) { +/// // Handle orphan note +/// } +/// +/// // Access note content +/// final content = note.contentMarkdown; +/// final tags = note.tags; +/// ``` +/// +/// ## Immutability +/// +/// Notes are immutable value objects. Use the `copyWith` method to create +/// modified versions: +/// +/// ```dart +/// final updatedNote = note.copyWith( +/// contentMarkdown: 'Updated content', +/// status: NoteStatus.shifted, +/// ); +/// ``` +/// +/// ## Equality +/// +/// Notes are compared by their ID only. Two notes with the same ID are +/// considered equal regardless of other field differences. +class Note extends Equatable { + /// Unique identifier for the note + final String id; + + /// ID of the book this note belongs to + final String bookId; + + /// Version ID of the document when note was created + final String docVersionId; + + /// Logical path within the document (e.g., ["chapter:3", "para:12"]) + final List? logicalPath; + + /// Character start position in the canonical text + final int charStart; + + /// Character end position in the canonical text + final int charEnd; + + /// Normalized text that was selected when creating the note + final String selectedTextNormalized; + + /// SHA-256 hash of the selected normalized text + final String textHash; + + /// Context text before the selection (40 chars) + final String contextBefore; + + /// Context text after the selection (40 chars) + final String contextAfter; + + /// SHA-256 hash of the context before + final String contextBeforeHash; + + /// SHA-256 hash of the context after + final String contextAfterHash; + + /// Rolling hash of the context before + final int rollingBefore; + + /// Rolling hash of the context after + final int rollingAfter; + + /// Current anchoring status of the note + final NoteStatus status; + + /// The actual note content in markdown format + final String contentMarkdown; + + /// ID of the user who created the note + final String authorUserId; + + /// Privacy level of the note + final NotePrivacy privacy; + + /// Tags associated with the note + final List tags; + + /// When the note was created + final DateTime createdAt; + + /// When the note was last updated + final DateTime updatedAt; + + /// Configuration used for text normalization when creating this note + final String normalizationConfig; + + const Note({ + required this.id, + required this.bookId, + required this.docVersionId, + this.logicalPath, + required this.charStart, + required this.charEnd, + required this.selectedTextNormalized, + required this.textHash, + required this.contextBefore, + required this.contextAfter, + required this.contextBeforeHash, + required this.contextAfterHash, + required this.rollingBefore, + required this.rollingAfter, + required this.status, + required this.contentMarkdown, + required this.authorUserId, + required this.privacy, + required this.tags, + required this.createdAt, + required this.updatedAt, + required this.normalizationConfig, + }); + + /// Creates a copy of this note with updated fields + Note copyWith({ + String? id, + String? bookId, + String? docVersionId, + List? logicalPath, + int? charStart, + int? charEnd, + String? selectedTextNormalized, + String? textHash, + String? contextBefore, + String? contextAfter, + String? contextBeforeHash, + String? contextAfterHash, + int? rollingBefore, + int? rollingAfter, + NoteStatus? status, + String? contentMarkdown, + String? authorUserId, + NotePrivacy? privacy, + List? tags, + DateTime? createdAt, + DateTime? updatedAt, + String? normalizationConfig, + }) { + return Note( + id: id ?? this.id, + bookId: bookId ?? this.bookId, + docVersionId: docVersionId ?? this.docVersionId, + logicalPath: logicalPath ?? this.logicalPath, + charStart: charStart ?? this.charStart, + charEnd: charEnd ?? this.charEnd, + selectedTextNormalized: selectedTextNormalized ?? this.selectedTextNormalized, + textHash: textHash ?? this.textHash, + contextBefore: contextBefore ?? this.contextBefore, + contextAfter: contextAfter ?? this.contextAfter, + contextBeforeHash: contextBeforeHash ?? this.contextBeforeHash, + contextAfterHash: contextAfterHash ?? this.contextAfterHash, + rollingBefore: rollingBefore ?? this.rollingBefore, + rollingAfter: rollingAfter ?? this.rollingAfter, + status: status ?? this.status, + contentMarkdown: contentMarkdown ?? this.contentMarkdown, + authorUserId: authorUserId ?? this.authorUserId, + privacy: privacy ?? this.privacy, + tags: tags ?? this.tags, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + normalizationConfig: normalizationConfig ?? this.normalizationConfig, + ); + } + + /// Converts the note to a JSON map for storage + Map toJson() { + return { + 'note_id': id, + 'book_id': bookId, + 'doc_version_id': docVersionId, + 'logical_path': logicalPath != null ? logicalPath!.join(',') : null, + 'char_start': charStart, + 'char_end': charEnd, + 'selected_text_normalized': selectedTextNormalized, + 'text_hash': textHash, + 'ctx_before': contextBefore, + 'ctx_after': contextAfter, + 'ctx_before_hash': contextBeforeHash, + 'ctx_after_hash': contextAfterHash, + 'rolling_before': rollingBefore, + 'rolling_after': rollingAfter, + 'status': status.name, + 'content_markdown': contentMarkdown, + 'author_user_id': authorUserId, + 'privacy': privacy.name, + 'tags': tags.join(','), + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + 'normalization_config': normalizationConfig, + }; + } + + /// Creates a note from a JSON map + factory Note.fromJson(Map json) { + return Note( + id: json['note_id'] as String, + bookId: json['book_id'] as String, + docVersionId: json['doc_version_id'] as String, + logicalPath: json['logical_path'] != null + ? (json['logical_path'] as String).split(',') + : null, + charStart: json['char_start'] as int, + charEnd: json['char_end'] as int, + selectedTextNormalized: json['selected_text_normalized'] as String, + textHash: json['text_hash'] as String, + contextBefore: json['ctx_before'] as String, + contextAfter: json['ctx_after'] as String, + contextBeforeHash: json['ctx_before_hash'] as String, + contextAfterHash: json['ctx_after_hash'] as String, + rollingBefore: json['rolling_before'] as int, + rollingAfter: json['rolling_after'] as int, + status: NoteStatus.values.byName(json['status'] as String), + contentMarkdown: json['content_markdown'] as String, + authorUserId: json['author_user_id'] as String, + privacy: NotePrivacy.values.byName(json['privacy'] as String), + tags: json['tags'] != null + ? (json['tags'] as String).split(',').where((t) => t.isNotEmpty).toList() + : [], + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + normalizationConfig: json['normalization_config'] as String, + ); + } + + @override + List get props => [ + id, + bookId, + docVersionId, + logicalPath, + charStart, + charEnd, + selectedTextNormalized, + textHash, + contextBefore, + contextAfter, + contextBeforeHash, + contextAfterHash, + rollingBefore, + rollingAfter, + status, + contentMarkdown, + authorUserId, + privacy, + tags, + createdAt, + updatedAt, + normalizationConfig, + ]; + + @override + String toString() { + return 'Note(id: $id, bookId: $bookId, status: $status, content: ${contentMarkdown.length} chars)'; + } +} \ No newline at end of file diff --git a/lib/notes/notes_system.dart b/lib/notes/notes_system.dart new file mode 100644 index 000000000..98e013df7 --- /dev/null +++ b/lib/notes/notes_system.dart @@ -0,0 +1,157 @@ +/// # Personal Notes System for Otzaria +/// +/// A comprehensive personal notes system that allows users to create, manage, +/// and organize notes attached to specific text locations in books. +/// +/// ## Key Features +/// +/// - **Smart Text Anchoring**: Notes automatically re-anchor when text changes +/// - **Hebrew & RTL Support**: Full support for Hebrew text with nikud and RTL languages +/// - **Advanced Search**: Multi-strategy search with fuzzy matching and semantic similarity +/// - **Performance Optimized**: Background processing and intelligent caching +/// - **Import/Export**: Backup and restore notes in JSON format +/// - **Orphan Management**: Smart tools for handling notes that lose their anchors +/// +/// ## Quick Start +/// +/// ```dart +/// // Initialize the notes system +/// final notesService = NotesIntegrationService.instance; +/// +/// // Create a note from text selection +/// final note = await notesService.createNoteFromSelection( +/// 'book-id', +/// 'selected text', +/// startPosition, +/// endPosition, +/// 'My note content', +/// tags: ['important', 'study'], +/// ); +/// +/// // Load notes for a book +/// final bookNotes = await notesService.loadNotesForBook('book-id', bookText); +/// +/// // Search notes +/// final results = await notesService.searchNotes('search query'); +/// ``` +/// +/// ## Architecture Overview +/// +/// The notes system is built with a layered architecture: +/// +/// - **UI Layer**: Widgets for note display, editing, and management +/// - **State Management**: BLoC pattern for reactive state management +/// - **Service Layer**: Business logic and integration services +/// - **Data Layer**: Repository pattern with SQLite database +/// - **Core Layer**: Text processing, anchoring algorithms, and utilities +/// +/// ## Performance Characteristics +/// +/// - **Note Creation**: < 100ms average +/// - **Re-anchoring**: < 50ms per note average +/// - **Search**: < 200ms for typical queries +/// - **Memory Usage**: < 50MB additional for 1000+ notes +/// - **Accuracy**: 98% after 5% text changes, 100% for whitespace changes +/// +/// ## Text Anchoring Technology +/// +/// The system uses a multi-strategy approach for anchoring notes to text: +/// +/// 1. **Exact Hash Matching**: Fast O(1) lookup for unchanged text +/// 2. **Context Matching**: Uses surrounding text for shifted content +/// 3. **Fuzzy Matching**: Levenshtein, Jaccard, and Cosine similarity +/// 4. **Semantic Matching**: Word-level similarity for restructured text +/// +/// ## Hebrew & RTL Support +/// +/// - **Grapheme Clusters**: Safe text slicing for complex scripts +/// - **Nikud Handling**: Configurable vowel point processing +/// - **Directional Marks**: Automatic cleanup of LTR/RTL markers +/// - **Quote Normalization**: Consistent handling of Hebrew quotes (״׳) +/// +/// ## Database Schema +/// +/// The system uses SQLite with FTS5 for full-text search: +/// +/// - **notes**: Main notes table with anchoring data +/// - **canonical_documents**: Document versions and indexes +/// - **notes_fts**: Full-text search index for Hebrew content +/// +/// ## Configuration +/// +/// Key configuration options in [NotesConfig]: +/// +/// - `enabled`: Master kill switch +/// - `fuzzyMatchingEnabled`: Enable/disable fuzzy matching +/// - `maxNotesPerBook`: Resource limits +/// - `reanchoringTimeoutMs`: Performance limits +/// +/// ## Error Handling +/// +/// The system provides comprehensive error handling: +/// +/// - **Graceful Degradation**: Notes become orphans instead of failing +/// - **Retry Logic**: Automatic retries for transient failures +/// - **User Feedback**: Clear error messages and recovery suggestions +/// - **Telemetry**: Performance monitoring and error tracking +/// +/// ## Security & Privacy +/// +/// - **Local Storage**: All data stored locally in SQLite +/// - **No Encryption**: Simple, transparent data storage (by design) +/// - **Privacy Controls**: Private/shared note visibility +/// - **Data Export**: Full user control over data +/// +/// ## Testing +/// +/// The system includes comprehensive testing: +/// +/// - **Unit Tests**: 101+ tests covering core functionality +/// - **Integration Tests**: End-to-end workflow validation +/// - **Performance Tests**: Benchmarks and regression testing +/// - **Acceptance Tests**: User story validation +/// +/// ## Migration & Integration +/// +/// - **Non-Destructive**: Bookmarks remain separate from notes +/// - **Gradual Adoption**: Can be enabled per-book or globally +/// - **Backward Compatible**: No changes to existing functionality +/// +/// ## Support & Troubleshooting +/// +/// Common issues and solutions: +/// +/// - **Orphan Notes**: Use the Orphan Manager to re-anchor +/// - **Performance Issues**: Check telemetry and run optimization +/// - **Search Problems**: Rebuild search index via performance optimizer +/// - **Memory Usage**: Clear caches or reduce batch sizes +/// +/// For detailed API documentation, see individual class documentation. + + +// Core exports +export 'services/notes_integration_service.dart'; +export 'services/import_export_service.dart'; +export 'services/advanced_orphan_manager.dart'; +export 'services/performance_optimizer.dart'; +export 'services/notes_telemetry.dart'; + +// UI exports +export 'widgets/notes_sidebar.dart'; +export 'widgets/note_editor_dialog.dart'; +export 'widgets/note_highlight.dart'; +export 'widgets/orphan_notes_manager.dart'; +export 'widgets/notes_performance_dashboard.dart'; +export 'widgets/notes_context_menu_extension.dart'; + +// BLoC exports +export 'bloc/notes_bloc.dart'; +export 'bloc/notes_event.dart'; +export 'bloc/notes_state.dart'; + +// Model exports +export 'models/note.dart'; +export 'models/anchor_models.dart'; + +// Config exports +export 'config/notes_config.dart'; diff --git a/lib/notes/repository/notes_repository.dart b/lib/notes/repository/notes_repository.dart new file mode 100644 index 000000000..79a2964a0 --- /dev/null +++ b/lib/notes/repository/notes_repository.dart @@ -0,0 +1,488 @@ +import 'dart:convert'; +import '../models/note.dart'; +import '../models/anchor_models.dart'; +import '../data/notes_data_provider.dart'; +import '../services/anchoring_service.dart'; +import '../services/canonical_text_service.dart'; +import '../services/background_processor.dart'; +import '../services/text_normalizer.dart'; +import '../config/notes_config.dart'; + +/// Repository for managing notes with business logic +class NotesRepository { + static NotesRepository? _instance; + final NotesDataProvider _dataProvider = NotesDataProvider.instance; + final AnchoringService _anchoringService = AnchoringService.instance; + final CanonicalTextService _canonicalService = CanonicalTextService.instance; + final BackgroundProcessor _backgroundProcessor = BackgroundProcessor.instance; + + NotesRepository._(); + + /// Singleton instance + static NotesRepository get instance { + _instance ??= NotesRepository._(); + return _instance!; + } + + /// Create a new note with automatic anchoring + Future createNote(CreateNoteRequest request) async { + try { + // Validate input + _validateCreateRequest(request); + + // Create canonical document for the book + final canonicalDoc = await _canonicalService.createCanonicalDocument(request.bookId); + + // Create anchor data + final anchorData = _anchoringService.createAnchor( + request.bookId, + canonicalDoc.canonicalText, + request.charStart, + request.charEnd, + ); + + // Create note with current normalization config + final config = TextNormalizer.createConfigFromSettings(); + final note = Note( + id: _generateNoteId(), + bookId: request.bookId, + docVersionId: canonicalDoc.versionId, + logicalPath: request.logicalPath, + charStart: anchorData.charStart, + charEnd: anchorData.charEnd, + selectedTextNormalized: canonicalDoc.canonicalText.substring( + anchorData.charStart, + anchorData.charEnd, + ), + textHash: anchorData.textHash, + contextBefore: anchorData.contextBefore, + contextAfter: anchorData.contextAfter, + contextBeforeHash: anchorData.contextBeforeHash, + contextAfterHash: anchorData.contextAfterHash, + rollingBefore: anchorData.rollingBefore, + rollingAfter: anchorData.rollingAfter, + status: anchorData.status, + contentMarkdown: request.contentMarkdown, + authorUserId: request.authorUserId, + privacy: request.privacy, + tags: request.tags, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + normalizationConfig: config.toConfigString(), + ); + + // Save to database + return await _dataProvider.createNote(note); + } catch (e) { + throw RepositoryException('Failed to create note: $e'); + } + } + + /// Update an existing note + Future updateNote(String noteId, UpdateNoteRequest request) async { + try { + // Get existing note + final existingNote = await _dataProvider.getNoteById(noteId); + if (existingNote == null) { + throw RepositoryException('Note not found: $noteId'); + } + + // Update only provided fields + final updatedNote = existingNote.copyWith( + contentMarkdown: request.contentMarkdown ?? existingNote.contentMarkdown, + privacy: request.privacy ?? existingNote.privacy, + tags: request.tags ?? existingNote.tags, + updatedAt: DateTime.now(), + ); + + return await _dataProvider.updateNote(updatedNote); + } catch (e) { + throw RepositoryException('Failed to update note: $e'); + } + } + + /// Delete a note + Future deleteNote(String noteId) async { + try { + await _dataProvider.deleteNote(noteId); + } catch (e) { + throw RepositoryException('Failed to delete note: $e'); + } + } + + /// Get a note by ID + Future getNoteById(String noteId) async { + try { + return await _dataProvider.getNoteById(noteId); + } catch (e) { + throw RepositoryException('Failed to get note: $e'); + } + } + + /// Get all notes for a book + Future> getNotesForBook(String bookId) async { + try { + return await _dataProvider.getNotesForBook(bookId); + } catch (e) { + throw RepositoryException('Failed to get notes for book: $e'); + } + } + + /// Get notes for a visible character range + Future> getNotesForVisibleRange(String bookId, VisibleCharRange range) async { + try { + return await _dataProvider.getNotesForCharRange(bookId, range.start, range.end); + } catch (e) { + throw RepositoryException('Failed to get notes for range: $e'); + } + } + + /// Search notes using full-text search + Future> searchNotes(String query, {String? bookId}) async { + try { + if (query.trim().isEmpty) { + return []; + } + + return await _dataProvider.searchNotes(query, bookId: bookId); + } catch (e) { + throw RepositoryException('Failed to search notes: $e'); + } + } + + /// Get orphan notes that need manual resolution + Future> getOrphanNotes({String? bookId}) async { + try { + return await _dataProvider.getOrphanNotes(bookId: bookId); + } catch (e) { + throw RepositoryException('Failed to get orphan notes: $e'); + } + } + + /// Re-anchor notes for a book (when book content changes) + Future reanchorNotesForBook(String bookId) async { + try { + // Get all notes for the book + final notes = await _dataProvider.getNotesForBook(bookId); + if (notes.isEmpty) { + return ReanchoringResult( + totalNotes: 0, + successCount: 0, + failureCount: 0, + orphanCount: 0, + duration: Duration.zero, + ); + } + + // Create new canonical document + final canonicalDoc = await _canonicalService.createCanonicalDocument(bookId); + + // Process re-anchoring in background + final stopwatch = Stopwatch()..start(); + final results = await _backgroundProcessor.processReanchoring(notes, canonicalDoc); + stopwatch.stop(); + + // Update notes with new anchoring results + final updatedNotes = []; + int successCount = 0; + int failureCount = 0; + int orphanCount = 0; + + for (int i = 0; i < notes.length; i++) { + final note = notes[i]; + final result = results[i]; + + final updatedNote = note.copyWith( + docVersionId: canonicalDoc.versionId, + charStart: result.start ?? note.charStart, + charEnd: result.end ?? note.charEnd, + status: result.status, + updatedAt: DateTime.now(), + ); + + updatedNotes.add(updatedNote); + + switch (result.status) { + case NoteStatus.anchored: + successCount++; + break; + case NoteStatus.shifted: + successCount++; + break; + case NoteStatus.orphan: + orphanCount++; + break; + } + } + + // Batch update all notes + await _dataProvider.batchUpdateNotes(updatedNotes); + + return ReanchoringResult( + totalNotes: notes.length, + successCount: successCount, + failureCount: failureCount, + orphanCount: orphanCount, + duration: stopwatch.elapsed, + ); + } catch (e) { + throw RepositoryException('Failed to re-anchor notes: $e'); + } + } + + /// Resolve an orphan note by selecting a candidate position + Future resolveOrphanNote(String noteId, AnchorCandidate selectedCandidate) async { + try { + final note = await _dataProvider.getNoteById(noteId); + if (note == null) { + throw RepositoryException('Note not found: $noteId'); + } + + if (note.status != NoteStatus.orphan) { + throw RepositoryException('Note is not an orphan: $noteId'); + } + + // Update note with selected position + final resolvedNote = note.copyWith( + charStart: selectedCandidate.start, + charEnd: selectedCandidate.end, + status: NoteStatus.shifted, // Mark as shifted since it was manually resolved + updatedAt: DateTime.now(), + ); + + return await _dataProvider.updateNote(resolvedNote); + } catch (e) { + throw RepositoryException('Failed to resolve orphan note: $e'); + } + } + + /// Export notes to JSON format + Future exportNotes(ExportOptions options) async { + try { + List notes; + + if (options.bookId != null) { + notes = await _dataProvider.getNotesForBook(options.bookId!); + } else { + // Get all notes (this would need to be implemented in data provider) + // For now, return empty JSON - can be implemented later + return '[]'; + } + + final exportData = { + 'version': '1.0', + 'exported_at': DateTime.now().toIso8601String(), + 'book_id': options.bookId, + 'include_orphans': options.includeOrphans, + 'notes': notes + .where((note) => options.includeOrphans || note.status != NoteStatus.orphan) + .map((note) => note.toJson()) + .toList(), + }; + + return jsonEncode(exportData); + } catch (e) { + throw RepositoryException('Failed to export notes: $e'); + } + } + + /// Import notes from JSON format + Future importNotes(String jsonData, ImportOptions options) async { + try { + final data = jsonDecode(jsonData) as Map; + final notesData = data['notes'] as List; + + int importedCount = 0; + int skippedCount = 0; + int errorCount = 0; + + for (final noteData in notesData) { + try { + final note = Note.fromJson(noteData as Map); + + // Check if note already exists + final existing = await _dataProvider.getNoteById(note.id); + if (existing != null) { + if (options.overwriteExisting) { + await _dataProvider.updateNote(note); + importedCount++; + } else { + skippedCount++; + } + } else { + await _dataProvider.createNote(note); + importedCount++; + } + } catch (e) { + errorCount++; + } + } + + return ImportResult( + totalNotes: notesData.length, + importedCount: importedCount, + skippedCount: skippedCount, + errorCount: errorCount, + ); + } catch (e) { + throw RepositoryException('Failed to import notes: $e'); + } + } + + /// Get repository statistics + Future> getRepositoryStats() async { + try { + final dbStats = await _dataProvider.getDatabaseStats(); + final processingStats = _backgroundProcessor.getProcessingStats(); + + return { + ...dbStats, + ...processingStats, + 'repository_version': '1.0', + }; + } catch (e) { + throw RepositoryException('Failed to get repository stats: $e'); + } + } + + /// Validate create note request + void _validateCreateRequest(CreateNoteRequest request) { + if (request.bookId.isEmpty) { + throw RepositoryException('Book ID cannot be empty'); + } + + if (request.charStart < 0 || request.charEnd <= request.charStart) { + throw RepositoryException('Invalid character range'); + } + + if (request.contentMarkdown.isEmpty) { + throw RepositoryException('Note content cannot be empty'); + } + + if (request.contentMarkdown.length > NotesConfig.maxNoteSize) { + throw RepositoryException('Note content exceeds maximum size'); + } + + if (request.authorUserId.isEmpty) { + throw RepositoryException('Author user ID cannot be empty'); + } + } + + /// Generate unique note ID + String _generateNoteId() { + final timestamp = DateTime.now().millisecondsSinceEpoch; + final random = (timestamp % 10000).toString().padLeft(4, '0'); + return 'note_${timestamp}_$random'; + } +} + +/// Request for creating a new note +class CreateNoteRequest { + final String bookId; + final int charStart; + final int charEnd; + final String contentMarkdown; + final String authorUserId; + final NotePrivacy privacy; + final List tags; + final List? logicalPath; + + const CreateNoteRequest({ + required this.bookId, + required this.charStart, + required this.charEnd, + required this.contentMarkdown, + required this.authorUserId, + this.privacy = NotePrivacy.private, + this.tags = const [], + this.logicalPath, + }); +} + +/// Request for updating an existing note +class UpdateNoteRequest { + final String? contentMarkdown; + final NotePrivacy? privacy; + final List? tags; + final int? charStart; + final int? charEnd; + final NoteStatus? status; + + const UpdateNoteRequest({ + this.contentMarkdown, + this.privacy, + this.tags, + this.charStart, + this.charEnd, + this.status, + }); +} + +/// Options for exporting notes +class ExportOptions { + final String? bookId; + final bool includeOrphans; + final bool encryptData; + + const ExportOptions({ + this.bookId, + this.includeOrphans = false, + this.encryptData = false, + }); +} + +/// Options for importing notes +class ImportOptions { + final bool overwriteExisting; + final bool validateAnchors; + + const ImportOptions({ + this.overwriteExisting = false, + this.validateAnchors = true, + }); +} + +/// Result of re-anchoring operation +class ReanchoringResult { + final int totalNotes; + final int successCount; + final int failureCount; + final int orphanCount; + final Duration duration; + + const ReanchoringResult({ + required this.totalNotes, + required this.successCount, + required this.failureCount, + required this.orphanCount, + required this.duration, + }); + + double get successRate => totalNotes > 0 ? successCount / totalNotes : 0.0; + double get orphanRate => totalNotes > 0 ? orphanCount / totalNotes : 0.0; +} + +/// Result of import operation +class ImportResult { + final int totalNotes; + final int importedCount; + final int skippedCount; + final int errorCount; + + const ImportResult({ + required this.totalNotes, + required this.importedCount, + required this.skippedCount, + required this.errorCount, + }); +} + +/// Repository exception +class RepositoryException implements Exception { + final String message; + + const RepositoryException(this.message); + + @override + String toString() => 'RepositoryException: $message'; +} \ No newline at end of file diff --git a/lib/notes/services/advanced_orphan_manager.dart b/lib/notes/services/advanced_orphan_manager.dart new file mode 100644 index 000000000..087c8dc8b --- /dev/null +++ b/lib/notes/services/advanced_orphan_manager.dart @@ -0,0 +1,432 @@ +import 'dart:async'; +import '../models/note.dart'; +import '../models/anchor_models.dart'; +import '../services/fuzzy_matcher.dart'; +import '../services/notes_telemetry.dart'; +import '../config/notes_config.dart'; +import '../utils/text_utils.dart'; + +/// Advanced service for managing orphaned notes with smart re-anchoring +class AdvancedOrphanManager { + static AdvancedOrphanManager? _instance; + + + AdvancedOrphanManager._(); + + /// Singleton instance + static AdvancedOrphanManager get instance { + _instance ??= AdvancedOrphanManager._(); + return _instance!; + } + + /// Find potential anchor candidates for an orphan note using multiple strategies + Future> findCandidatesForOrphan( + Note orphan, + CanonicalDocument document, + ) async { + final stopwatch = Stopwatch()..start(); + final candidates = []; + + try { + // Strategy 1: Exact text match (highest priority) + final exactCandidates = await _findExactMatches(orphan, document); + candidates.addAll(exactCandidates); + + // Strategy 2: Context-based matching + final contextCandidates = await _findContextMatches(orphan, document); + candidates.addAll(contextCandidates); + + // Strategy 3: Fuzzy matching (if enabled) + if (NotesConfig.fuzzyMatchingEnabled) { + final fuzzyCandidates = await _findFuzzyMatches(orphan, document); + candidates.addAll(fuzzyCandidates); + } + + // Strategy 4: Semantic similarity (advanced) + final semanticCandidates = await _findSemanticMatches(orphan, document); + candidates.addAll(semanticCandidates); + + // Remove duplicates and sort by score + final uniqueCandidates = _removeDuplicatesAndSort(candidates); + + // Apply confidence scoring + final scoredCandidates = _applyConfidenceScoring(uniqueCandidates, orphan); + + NotesTelemetry.trackPerformanceMetric('orphan_candidate_search', stopwatch.elapsed); + + return scoredCandidates.take(10).toList(); // Limit to top 10 candidates + } catch (e) { + NotesTelemetry.trackPerformanceMetric('orphan_candidate_search_error', stopwatch.elapsed); + rethrow; + } + } + + /// Find exact text matches + Future> _findExactMatches( + Note orphan, + CanonicalDocument document, + ) async { + final candidates = []; + final searchText = orphan.selectedTextNormalized; + + // Search for exact matches in the document + int startIndex = 0; + while (true) { + final index = document.canonicalText.indexOf(searchText, startIndex); + if (index == -1) break; + + candidates.add(AnchorCandidate( + index, + index + searchText.length, + 1.0, // Perfect score for exact match + 'exact', + )); + + startIndex = index + 1; + } + + return candidates; + } + + /// Find context-based matches + Future> _findContextMatches( + Note orphan, + CanonicalDocument document, + ) async { + final candidates = []; + final contextBefore = orphan.contextBefore; + final contextAfter = orphan.contextAfter; + + if (contextBefore.isEmpty && contextAfter.isEmpty) { + return candidates; + } + + // Search for context patterns + final beforeMatches = _findContextPattern(document.canonicalText, contextBefore); + final afterMatches = _findContextPattern(document.canonicalText, contextAfter); + + // Combine context matches to find potential positions + for (final beforeMatch in beforeMatches) { + for (final afterMatch in afterMatches) { + final distance = afterMatch - beforeMatch; + if (distance > 0 && distance < AnchoringConstants.maxContextDistance) { + final score = _calculateContextScore(distance, contextBefore.length, contextAfter.length); + + candidates.add(AnchorCandidate( + beforeMatch + contextBefore.length, + afterMatch, + score, + 'context', + )); + } + } + } + + return candidates; + } + + /// Find fuzzy matches using advanced algorithms + Future> _findFuzzyMatches( + Note orphan, + CanonicalDocument document, + ) async { + final searchText = orphan.selectedTextNormalized; + return FuzzyMatcher.findFuzzyMatches(searchText, document.canonicalText); + } + + /// Find semantic matches using word similarity + Future> _findSemanticMatches( + Note orphan, + CanonicalDocument document, + ) async { + final candidates = []; + final searchWords = TextUtils.extractWords(orphan.selectedTextNormalized); + + if (searchWords.isEmpty) return candidates; + + final documentWords = TextUtils.extractWords(document.canonicalText); + final windowSize = searchWords.length; + + // Sliding window approach for semantic matching + for (int i = 0; i <= documentWords.length - windowSize; i++) { + final window = documentWords.sublist(i, i + windowSize); + final similarity = _calculateSemanticSimilarity(searchWords, window); + + if (similarity >= 0.6) { // Threshold for semantic similarity + final startPos = _findWordPosition(document.canonicalText, documentWords, i); + final endPos = _findWordPosition(document.canonicalText, documentWords, i + windowSize - 1); + + if (startPos != -1 && endPos != -1) { + candidates.add(AnchorCandidate( + startPos, + endPos, + similarity, + 'semantic', + )); + } + } + } + + return candidates; + } + + /// Find positions of context patterns + List _findContextPattern(String text, String pattern) { + final positions = []; + if (pattern.isEmpty) return positions; + + int startIndex = 0; + while (true) { + final index = text.indexOf(pattern, startIndex); + if (index == -1) break; + + positions.add(index); + startIndex = index + 1; + } + + return positions; + } + + /// Calculate context-based score + double _calculateContextScore(int distance, int beforeLength, int afterLength) { + // Prefer shorter distances and longer context + final distanceScore = 1.0 - (distance / AnchoringConstants.maxContextDistance); + final contextScore = (beforeLength + afterLength) / 100.0; // Normalize context length + + return (distanceScore * 0.7 + contextScore.clamp(0.0, 1.0) * 0.3); + } + + /// Calculate semantic similarity between word lists + double _calculateSemanticSimilarity(List words1, List words2) { + if (words1.isEmpty || words2.isEmpty) return 0.0; + + final set1 = words1.map((w) => w.toLowerCase()).toSet(); + final set2 = words2.map((w) => w.toLowerCase()).toSet(); + + final intersection = set1.intersection(set2); + final union = set1.union(set2); + + return intersection.length / union.length; // Jaccard similarity + } + + /// Find position of a word in text + int _findWordPosition(String text, List words, int wordIndex) { + if (wordIndex >= words.length) return -1; + + // Find position of target word + int currentPos = 0; + + for (int i = 0; i <= wordIndex; i++) { + final index = text.indexOf(words[i], currentPos); + if (index == -1) return -1; + + if (i == wordIndex) return index; + currentPos = index + words[i].length; + } + + return -1; + } + + /// Remove duplicate candidates and sort by score + List _removeDuplicatesAndSort(List candidates) { + final uniqueMap = {}; + + for (final candidate in candidates) { + final key = '${candidate.start}-${candidate.end}'; + final existing = uniqueMap[key]; + + if (existing == null || candidate.score > existing.score) { + uniqueMap[key] = candidate; + } + } + + final uniqueCandidates = uniqueMap.values.toList(); + uniqueCandidates.sort((a, b) => b.score.compareTo(a.score)); + + return uniqueCandidates; + } + + /// Apply confidence scoring based on multiple factors + List _applyConfidenceScoring( + List candidates, + Note orphan, + ) { + return candidates.map((candidate) { + double confidence = candidate.score; + + // Boost confidence for exact matches + if (candidate.strategy == 'exact') { + confidence = (confidence * 1.2).clamp(0.0, 1.0); + } + + // Reduce confidence for very short or very long matches + final length = candidate.end - candidate.start; + final originalLength = orphan.selectedTextNormalized.length; + final lengthRatio = length / originalLength; + + if (lengthRatio < 0.5 || lengthRatio > 2.0) { + confidence *= 0.8; + } + + // Boost confidence for matches with similar length + if (lengthRatio >= 0.8 && lengthRatio <= 1.2) { + confidence = (confidence * 1.1).clamp(0.0, 1.0); + } + + return AnchorCandidate( + candidate.start, + candidate.end, + confidence, + candidate.strategy, + ); + }).toList(); + } + + /// Auto-reanchor orphans with high confidence scores + Future> autoReanchorOrphans( + List orphans, + CanonicalDocument document, { + double confidenceThreshold = 0.9, + }) async { + final results = []; + final stopwatch = Stopwatch()..start(); + + for (final orphan in orphans) { + try { + final candidates = await findCandidatesForOrphan(orphan, document); + + if (candidates.isNotEmpty && candidates.first.score >= confidenceThreshold) { + final bestCandidate = candidates.first; + + results.add(AutoReanchorResult( + orphan: orphan, + candidate: bestCandidate, + success: true, + )); + } else { + results.add(AutoReanchorResult( + orphan: orphan, + candidate: null, + success: false, + reason: candidates.isEmpty + ? 'No candidates found' + : 'Low confidence (${(candidates.first.score * 100).toStringAsFixed(1)}%)', + )); + } + } catch (e) { + results.add(AutoReanchorResult( + orphan: orphan, + candidate: null, + success: false, + reason: 'Error: $e', + )); + } + } + + final successCount = results.where((r) => r.success).length; + NotesTelemetry.trackBatchReanchoring( + 'auto_reanchor_${DateTime.now().millisecondsSinceEpoch}', + orphans.length, + successCount, + stopwatch.elapsed, + ); + + return results; + } + + /// Get orphan statistics and recommendations + OrphanAnalysis analyzeOrphans(List orphans) { + final byAge = {}; + final byLength = {}; + final byTags = {}; + + final now = DateTime.now(); + + for (final orphan in orphans) { + // Age analysis + final age = now.difference(orphan.createdAt).inDays; + final ageGroup = age < 7 ? 'recent' : age < 30 ? 'medium' : 'old'; + byAge[ageGroup] = (byAge[ageGroup] ?? 0) + 1; + + // Length analysis + final length = orphan.selectedTextNormalized.length; + final lengthGroup = length < 20 ? 'short' : length < 100 ? 'medium' : 'long'; + byLength[lengthGroup] = (byLength[lengthGroup] ?? 0) + 1; + + // Tags analysis + for (final tag in orphan.tags) { + byTags[tag] = (byTags[tag] ?? 0) + 1; + } + } + + return OrphanAnalysis( + totalOrphans: orphans.length, + byAge: byAge, + byLength: byLength, + byTags: byTags, + recommendations: _generateRecommendations(orphans), + ); + } + + /// Generate recommendations for orphan management + List _generateRecommendations(List orphans) { + final recommendations = []; + + if (orphans.isEmpty) { + recommendations.add('אין הערות יתומות - מצוין!'); + return recommendations; + } + + final oldOrphans = orphans.where((o) => + DateTime.now().difference(o.createdAt).inDays > 30).length; + + if (oldOrphans > 0) { + recommendations.add('יש $oldOrphans הערות יתומות ישנות - שקול למחוק אותן'); + } + + final shortOrphans = orphans.where((o) => + o.selectedTextNormalized.length < 10).length; + + if (shortOrphans > orphans.length * 0.3) { + recommendations.add('הרבה הערות יתומות קצרות - ייתכן שהטקסט השתנה משמעותית'); + } + + if (orphans.length > 20) { + recommendations.add('מספר גבוה של הערות יתומות - שקול להריץ עיגון אוטומטי'); + } + + return recommendations; + } +} + +/// Result of auto re-anchoring operation +class AutoReanchorResult { + final Note orphan; + final AnchorCandidate? candidate; + final bool success; + final String? reason; + + const AutoReanchorResult({ + required this.orphan, + required this.candidate, + required this.success, + this.reason, + }); +} + +/// Analysis of orphan notes +class OrphanAnalysis { + final int totalOrphans; + final Map byAge; + final Map byLength; + final Map byTags; + final List recommendations; + + const OrphanAnalysis({ + required this.totalOrphans, + required this.byAge, + required this.byLength, + required this.byTags, + required this.recommendations, + }); +} \ No newline at end of file diff --git a/lib/notes/services/advanced_search_engine.dart b/lib/notes/services/advanced_search_engine.dart new file mode 100644 index 000000000..4943c49d3 --- /dev/null +++ b/lib/notes/services/advanced_search_engine.dart @@ -0,0 +1,617 @@ +import 'dart:async'; +import '../models/note.dart'; +import '../services/fuzzy_matcher.dart'; +import '../services/notes_telemetry.dart'; +import '../utils/text_utils.dart'; +import '../config/notes_config.dart'; + +/// Advanced search engine with multiple search strategies and ranking +class AdvancedSearchEngine { + static AdvancedSearchEngine? _instance; + // SearchIndex integration can be added later + + AdvancedSearchEngine._(); + + /// Singleton instance + static AdvancedSearchEngine get instance { + _instance ??= AdvancedSearchEngine._(); + return _instance!; + } + + /// Perform advanced search with multiple strategies + Future search( + String query, + List notes, { + SearchOptions? options, + }) async { + final opts = options ?? const SearchOptions(); + final stopwatch = Stopwatch()..start(); + + try { + if (query.trim().isEmpty) { + return SearchResults( + query: query, + results: [], + totalResults: 0, + searchTime: stopwatch.elapsed, + strategy: 'empty_query', + ); + } + + // Parse search query + final parsedQuery = _parseSearchQuery(query); + + // Apply filters + final filteredNotes = _applyFilters(notes, opts); + + // Perform search using multiple strategies + final searchResults = await _performMultiStrategySearch( + parsedQuery, + filteredNotes, + opts, + ); + + // Rank and sort results + final rankedResults = _rankSearchResults(searchResults, parsedQuery, opts); + + // Apply pagination + final paginatedResults = _applyPagination(rankedResults, opts); + + final results = SearchResults( + query: query, + results: paginatedResults, + totalResults: rankedResults.length, + searchTime: stopwatch.elapsed, + strategy: 'multi_strategy', + facets: _generateFacets(rankedResults), + suggestions: _generateSuggestions(query, rankedResults), + ); + + // Track search performance + NotesTelemetry.trackSearchPerformance( + query, + results.totalResults, + stopwatch.elapsed, + ); + + return results; + + } catch (e) { + NotesTelemetry.trackPerformanceMetric('search_error', stopwatch.elapsed); + rethrow; + } + } + + /// Parse search query into components + ParsedSearchQuery _parseSearchQuery(String query) { + final terms = []; + final phrases = []; + final tags = []; + final excludeTerms = []; + final filters = {}; + + // Simple query parsing (can be enhanced with proper parser) + final words = query.split(' '); + String currentPhrase = ''; + bool inPhrase = false; + + for (final word in words) { + final trimmed = word.trim(); + if (trimmed.isEmpty) continue; + + if (trimmed.startsWith('"')) { + inPhrase = true; + currentPhrase = trimmed.substring(1); + } else if (trimmed.endsWith('"') && inPhrase) { + currentPhrase += ' ${trimmed.substring(0, trimmed.length - 1)}'; + phrases.add(currentPhrase); + currentPhrase = ''; + inPhrase = false; + } else if (inPhrase) { + currentPhrase += ' $trimmed'; + } else if (trimmed.startsWith('#')) { + tags.add(trimmed.substring(1)); + } else if (trimmed.startsWith('-')) { + excludeTerms.add(trimmed.substring(1)); + } else if (trimmed.contains(':')) { + final parts = trimmed.split(':'); + if (parts.length == 2) { + filters[parts[0]] = parts[1]; + } + } else { + terms.add(trimmed); + } + } + + return ParsedSearchQuery( + originalQuery: query, + terms: terms, + phrases: phrases, + tags: tags, + excludeTerms: excludeTerms, + filters: filters, + ); + } + + /// Apply search filters + List _applyFilters(List notes, SearchOptions options) { + var filtered = notes; + + // Status filter + if (options.statusFilter != null) { + filtered = filtered.where((note) => note.status == options.statusFilter).toList(); + } + + // Privacy filter + if (options.privacyFilter != null) { + filtered = filtered.where((note) => note.privacy == options.privacyFilter).toList(); + } + + // Date range filter + if (options.dateFrom != null) { + filtered = filtered.where((note) => note.createdAt.isAfter(options.dateFrom!)).toList(); + } + if (options.dateTo != null) { + filtered = filtered.where((note) => note.createdAt.isBefore(options.dateTo!)).toList(); + } + + // Book filter + if (options.bookIds != null && options.bookIds!.isNotEmpty) { + filtered = filtered.where((note) => options.bookIds!.contains(note.bookId)).toList(); + } + + return filtered; + } + + /// Perform multi-strategy search + Future> _performMultiStrategySearch( + ParsedSearchQuery query, + List notes, + SearchOptions options, + ) async { + final results = []; + + // Strategy 1: Exact phrase matching + if (query.phrases.isNotEmpty) { + final exactResults = await _searchExactPhrases(query.phrases, notes); + results.addAll(exactResults); + } + + // Strategy 2: Term matching + if (query.terms.isNotEmpty) { + final termResults = await _searchTerms(query.terms, notes); + results.addAll(termResults); + } + + // Strategy 3: Tag matching + if (query.tags.isNotEmpty) { + final tagResults = await _searchTags(query.tags, notes); + results.addAll(tagResults); + } + + // Strategy 4: Fuzzy matching (if enabled and no exact matches) + if (NotesConfig.fuzzyMatchingEnabled && results.isEmpty) { + final fuzzyResults = await _searchFuzzy(query.originalQuery, notes); + results.addAll(fuzzyResults); + } + + // Strategy 5: Semantic search (basic word similarity) + if (options.enableSemanticSearch && results.length < 5) { + final semanticResults = await _searchSemantic(query.terms, notes); + results.addAll(semanticResults); + } + + // Apply exclusions + return _applyExclusions(results, query.excludeTerms); + } + + /// Search for exact phrases + Future> _searchExactPhrases( + List phrases, + List notes, + ) async { + final results = []; + + for (final note in notes) { + double totalScore = 0.0; + final matches = []; + + for (final phrase in phrases) { + final content = '${note.contentMarkdown} ${note.selectedTextNormalized}'.toLowerCase(); + final phraseIndex = content.indexOf(phrase.toLowerCase()); + + if (phraseIndex != -1) { + totalScore += 1.0; // Perfect score for exact phrase match + matches.add(SearchMatch( + field: phraseIndex < note.contentMarkdown.length ? 'content' : 'selected_text', + position: phraseIndex, + length: phrase.length, + score: 1.0, + )); + } + } + + if (matches.isNotEmpty) { + results.add(SearchResult( + note: note, + score: totalScore / phrases.length, + matches: matches, + strategy: 'exact_phrase', + )); + } + } + + return results; + } + + /// Search for individual terms + Future> _searchTerms( + List terms, + List notes, + ) async { + final results = []; + + for (final note in notes) { + double totalScore = 0.0; + final matches = []; + + final content = '${note.contentMarkdown} ${note.selectedTextNormalized}'.toLowerCase(); + final words = TextUtils.extractWords(content); + + for (final term in terms) { + final termLower = term.toLowerCase(); + double termScore = 0.0; + + // Exact word matches + final exactMatches = words.where((word) => word.toLowerCase() == termLower).length; + termScore += exactMatches * 1.0; + + // Partial matches + final partialMatches = words.where((word) => word.toLowerCase().contains(termLower)).length; + termScore += partialMatches * 0.5; + + if (termScore > 0) { + totalScore += termScore; + matches.add(SearchMatch( + field: 'content', + position: content.indexOf(termLower), + length: term.length, + score: termScore, + )); + } + } + + if (matches.isNotEmpty) { + results.add(SearchResult( + note: note, + score: totalScore / terms.length, + matches: matches, + strategy: 'term_matching', + )); + } + } + + return results; + } + + /// Search by tags + Future> _searchTags( + List searchTags, + List notes, + ) async { + final results = []; + + for (final note in notes) { + final matchingTags = note.tags.where((tag) => + searchTags.any((searchTag) => tag.toLowerCase().contains(searchTag.toLowerCase()))).toList(); + + if (matchingTags.isNotEmpty) { + final score = matchingTags.length / searchTags.length; + results.add(SearchResult( + note: note, + score: score, + matches: [SearchMatch( + field: 'tags', + position: 0, + length: matchingTags.join(', ').length, + score: score, + )], + strategy: 'tag_matching', + )); + } + } + + return results; + } + + /// Fuzzy search + Future> _searchFuzzy( + String query, + List notes, + ) async { + final results = []; + + for (final note in notes) { + final content = '${note.contentMarkdown} ${note.selectedTextNormalized}'; + final similarity = FuzzyMatcher.calculateCombinedSimilarity(query, content); + + if (similarity >= 0.3) { // Threshold for fuzzy matching + results.add(SearchResult( + note: note, + score: similarity, + matches: [SearchMatch( + field: 'content', + position: 0, + length: content.length, + score: similarity, + )], + strategy: 'fuzzy_matching', + )); + } + } + + return results; + } + + /// Semantic search using word similarity + Future> _searchSemantic( + List terms, + List notes, + ) async { + final results = []; + + for (final note in notes) { + final noteWords = TextUtils.extractWords('${note.contentMarkdown} ${note.selectedTextNormalized}'); + double semanticScore = 0.0; + + for (final term in terms) { + for (final noteWord in noteWords) { + final similarity = TextUtils.calculateSimilarity(term, noteWord); + if (similarity > 0.7) { // Threshold for semantic similarity + semanticScore += similarity; + } + } + } + + if (semanticScore > 0) { + final normalizedScore = semanticScore / (terms.length * noteWords.length); + results.add(SearchResult( + note: note, + score: normalizedScore, + matches: [SearchMatch( + field: 'content', + position: 0, + length: 0, + score: normalizedScore, + )], + strategy: 'semantic_matching', + )); + } + } + + return results; + } + + /// Apply exclusions to search results + List _applyExclusions( + List results, + List excludeTerms, + ) { + if (excludeTerms.isEmpty) return results; + + return results.where((result) { + final content = '${result.note.contentMarkdown} ${result.note.selectedTextNormalized}'.toLowerCase(); + return !excludeTerms.any((term) => content.contains(term.toLowerCase())); + }).toList(); + } + + /// Rank search results using multiple factors + List _rankSearchResults( + List results, + ParsedSearchQuery query, + SearchOptions options, + ) { + // Remove duplicates (same note from different strategies) + final uniqueResults = {}; + + for (final result in results) { + final key = result.note.id; + final existing = uniqueResults[key]; + + if (existing == null || result.score > existing.score) { + uniqueResults[key] = result; + } + } + + final rankedResults = uniqueResults.values.toList(); + + // Apply ranking factors + for (final result in rankedResults) { + double rankingScore = result.score; + + // Boost recent notes + final age = DateTime.now().difference(result.note.updatedAt).inDays; + final recencyBoost = (30 - age.clamp(0, 30)) / 30.0 * 0.1; + rankingScore += recencyBoost; + + // Boost notes with more content + final contentLength = result.note.contentMarkdown.length; + final contentBoost = (contentLength / 1000.0).clamp(0.0, 0.1); + rankingScore += contentBoost; + + // Boost anchored notes slightly + if (result.note.status == NoteStatus.anchored) { + rankingScore += 0.05; + } + + // Update score + result.score = rankingScore.clamp(0.0, 1.0); + } + + // Sort by score + rankedResults.sort((a, b) => b.score.compareTo(a.score)); + + return rankedResults; + } + + /// Apply pagination to results + List _applyPagination( + List results, + SearchOptions options, + ) { + final offset = options.offset ?? 0; + final limit = options.limit ?? 50; + + if (offset >= results.length) return []; + + final end = (offset + limit).clamp(0, results.length); + return results.sublist(offset, end); + } + + /// Generate search facets + Map> _generateFacets(List results) { + final facets = >{}; + + // Status facets + final statusCounts = {}; + for (final result in results) { + final status = result.note.status.name; + statusCounts[status] = (statusCounts[status] ?? 0) + 1; + } + facets['status'] = statusCounts; + + // Tag facets + final tagCounts = {}; + for (final result in results) { + for (final tag in result.note.tags) { + tagCounts[tag] = (tagCounts[tag] ?? 0) + 1; + } + } + facets['tags'] = tagCounts; + + // Book facets + final bookCounts = {}; + for (final result in results) { + final bookId = result.note.bookId; + bookCounts[bookId] = (bookCounts[bookId] ?? 0) + 1; + } + facets['books'] = bookCounts; + + return facets; + } + + /// Generate search suggestions + List _generateSuggestions(String query, List results) { + final suggestions = []; + + if (results.isEmpty) { + // Suggest common search terms + suggestions.addAll(['הערות', 'תגיות', 'טקסט']); + } else { + // Suggest related tags + final allTags = {}; + for (final result in results.take(10)) { + allTags.addAll(result.note.tags); + } + + suggestions.addAll(allTags.take(5)); + } + + return suggestions; + } +} + +/// Parsed search query components +class ParsedSearchQuery { + final String originalQuery; + final List terms; + final List phrases; + final List tags; + final List excludeTerms; + final Map filters; + + const ParsedSearchQuery({ + required this.originalQuery, + required this.terms, + required this.phrases, + required this.tags, + required this.excludeTerms, + required this.filters, + }); +} + +/// Search options +class SearchOptions { + final NoteStatus? statusFilter; + final NotePrivacy? privacyFilter; + final DateTime? dateFrom; + final DateTime? dateTo; + final List? bookIds; + final int? offset; + final int? limit; + final bool enableSemanticSearch; + final bool enableFuzzySearch; + + const SearchOptions({ + this.statusFilter, + this.privacyFilter, + this.dateFrom, + this.dateTo, + this.bookIds, + this.offset, + this.limit, + this.enableSemanticSearch = false, + this.enableFuzzySearch = true, + }); +} + +/// Search result for a single note +class SearchResult { + final Note note; + double score; + final List matches; + final String strategy; + + SearchResult({ + required this.note, + required this.score, + required this.matches, + required this.strategy, + }); +} + +/// Individual search match within a note +class SearchMatch { + final String field; + final int position; + final int length; + final double score; + + const SearchMatch({ + required this.field, + required this.position, + required this.length, + required this.score, + }); +} + +/// Complete search results +class SearchResults { + final String query; + final List results; + final int totalResults; + final Duration searchTime; + final String strategy; + final Map>? facets; + final List? suggestions; + + const SearchResults({ + required this.query, + required this.results, + required this.totalResults, + required this.searchTime, + required this.strategy, + this.facets, + this.suggestions, + }); +} \ No newline at end of file diff --git a/lib/notes/services/anchoring_service.dart b/lib/notes/services/anchoring_service.dart new file mode 100644 index 000000000..8a6d67ef4 --- /dev/null +++ b/lib/notes/services/anchoring_service.dart @@ -0,0 +1,133 @@ +import '../models/note.dart'; +import '../models/anchor_models.dart'; +import 'text_normalizer.dart'; +import 'hash_generator.dart'; +import 'canonical_text_service.dart'; +import 'notes_telemetry.dart'; + +/// Service for anchoring notes to text locations using multi-strategy approach. +class AnchoringService { + static AnchoringService? _instance; + final CanonicalTextService _canonicalService = CanonicalTextService.instance; + + AnchoringService._(); + + /// Singleton instance + static AnchoringService get instance { + _instance ??= AnchoringService._(); + return _instance!; + } + + /// Create an anchor for a new note + AnchorData createAnchor( + String bookId, + String canonicalText, + int charStart, + int charEnd, + ) { + try { + // Create normalization config + final config = TextNormalizer.createConfigFromSettings(); + + // Extract and normalize the selected text + final selectedText = canonicalText.substring(charStart, charEnd); + final normalizedSelected = TextNormalizer.normalize(selectedText, config); + + // Extract context window + final contextWindow = _canonicalService.extractContextWindow( + canonicalText, + charStart, + charEnd, + ); + + // Normalize context + final normalizedContext = + TextNormalizer.normalizeContextWindow(contextWindow, config); + + // Generate hashes + final textHashes = HashGenerator.generateTextHashes(normalizedSelected); + final contextHashes = HashGenerator.generateContextHashes( + normalizedContext.before, + normalizedContext.after, + ); + + return AnchorData( + charStart: charStart, + charEnd: charEnd, + textHash: textHashes.textHash, + contextBefore: normalizedContext.before, + contextAfter: normalizedContext.after, + contextBeforeHash: contextHashes.beforeHash, + contextAfterHash: contextHashes.afterHash, + rollingBefore: contextHashes.beforeRollingHash, + rollingAfter: contextHashes.afterRollingHash, + status: NoteStatus.anchored, + ); + } catch (e) { + throw AnchoringException( + AnchoringError.corruptedAnchor, + 'Failed to create anchor: $e', + ); + } + } + + /// Re-anchor a note to a new document version + Future reanchorNote( + Note note, CanonicalDocument document) async { + final stopwatch = Stopwatch()..start(); + final requestId = 'reanchor_${DateTime.now().millisecondsSinceEpoch}'; + + try { + // Step 1: Check if document version is the same (O(1) operation) + if (note.docVersionId == document.versionId) { + final result = AnchorResult( + NoteStatus.anchored, + start: note.charStart, + end: note.charEnd, + ); + + // Track telemetry if available + try { + NotesTelemetry.trackAnchoringResult( + requestId, + NoteStatus.anchored, + stopwatch.elapsed, + 'version_match', + ); + } catch (e) { + // Telemetry failure shouldn't break anchoring + } + + return result; + } + + // For now, just mark as orphan - full implementation will come later + final result = AnchorResult( + NoteStatus.orphan, + errorMessage: 'Re-anchoring not fully implemented yet', + ); + + try { + NotesTelemetry.trackAnchoringResult( + requestId, + NoteStatus.orphan, + stopwatch.elapsed, + 'failed', + ); + } catch (e) { + // Telemetry failure shouldn't break anchoring + } + + return result; + } catch (e) { + final result = AnchorResult( + NoteStatus.orphan, + errorMessage: 'Re-anchoring failed: $e', + ); + + return result; + } finally { + stopwatch.stop(); + } + } +} \ No newline at end of file diff --git a/lib/notes/services/background_processor.dart b/lib/notes/services/background_processor.dart new file mode 100644 index 000000000..3460b89c2 --- /dev/null +++ b/lib/notes/services/background_processor.dart @@ -0,0 +1,248 @@ +import 'dart:isolate'; +import 'dart:async'; +import '../models/note.dart'; +import '../models/anchor_models.dart'; +import '../services/anchoring_service.dart'; +import '../services/notes_telemetry.dart'; +import '../config/notes_config.dart'; + +/// Service for processing heavy note operations in background isolates +class BackgroundProcessor { + static BackgroundProcessor? _instance; + final Map>> _activeRequests = {}; + int _requestCounter = 0; + + BackgroundProcessor._(); + + /// Singleton instance + static BackgroundProcessor get instance { + _instance ??= BackgroundProcessor._(); + return _instance!; + } + + /// Process re-anchoring for multiple notes in background isolate + Future> processReanchoring( + List notes, + CanonicalDocument document, + ) async { + final requestId = _generateRequestId(); + final stopwatch = Stopwatch()..start(); + + try { + // Create completer for this request + final completer = Completer>(); + _activeRequests[requestId] = completer; + + // Prepare data for isolate + final isolateData = IsolateReanchoringData( + requestId: requestId, + notes: notes, + document: document, + config: _createProcessingConfig(), + ); + + // Spawn isolate for heavy computation + final receivePort = ReceivePort(); + await Isolate.spawn(_reanchorNotesIsolate, [receivePort.sendPort, isolateData]); + + // Listen for results + receivePort.listen((message) { + if (message is IsolateReanchoringResult) { + final activeCompleter = _activeRequests.remove(message.requestId); + if (activeCompleter != null && !activeCompleter.isCompleted) { + if (message.error != null) { + activeCompleter.completeError(message.error!); + } else { + activeCompleter.complete(message.results); + } + } + } + receivePort.close(); + }); + + // Wait for completion with timeout + final results = await completer.future.timeout( + Duration(milliseconds: NotesConfig.reanchoringTimeoutMs * notes.length), + onTimeout: () { + _activeRequests.remove(requestId); + throw TimeoutException('Re-anchoring timed out', + Duration(milliseconds: NotesConfig.reanchoringTimeoutMs * notes.length)); + }, + ); + + // Track batch performance + final successCount = results.where((r) => r.isSuccess).length; + NotesTelemetry.trackBatchReanchoring( + requestId, + notes.length, + successCount, + stopwatch.elapsed, + ); + + return results; + } catch (e) { + _activeRequests.remove(requestId); + rethrow; + } + } + + /// Cancel an active re-anchoring request + void cancelRequest(String requestId) { + final completer = _activeRequests.remove(requestId); + if (completer != null && !completer.isCompleted) { + completer.completeError('Request cancelled'); + } + } + + /// Cancel all active requests + void cancelAllRequests() { + for (final entry in _activeRequests.entries) { + if (!entry.value.isCompleted) { + entry.value.completeError('All requests cancelled'); + } + } + _activeRequests.clear(); + } + + /// Generate unique request ID with epoch for stale work detection + String _generateRequestId() { + final epoch = DateTime.now().millisecondsSinceEpoch; + return '${++_requestCounter}_$epoch'; + } + + /// Create processing configuration + ProcessingConfig _createProcessingConfig() { + return ProcessingConfig( + maxReanchoringTimeMs: NotesConfig.reanchoringTimeoutMs, + maxBatchSize: NotesConfig.maxReanchoringBatchSize, + fuzzyMatchingEnabled: NotesConfig.fuzzyMatchingEnabled, + ); + } + + /// Get statistics about active requests + Map getProcessingStats() { + return { + 'active_requests': _activeRequests.length, + 'request_counter': _requestCounter, + 'oldest_request_age': _getOldestRequestAge(), + }; + } + + /// Get age of oldest active request in milliseconds + int? _getOldestRequestAge() { + if (_activeRequests.isEmpty) return null; + + final now = DateTime.now().millisecondsSinceEpoch; + int? oldestEpoch; + + for (final requestId in _activeRequests.keys) { + final parts = requestId.split('_'); + if (parts.length >= 2) { + final epoch = int.tryParse(parts.last); + if (epoch != null) { + oldestEpoch = oldestEpoch == null ? epoch : (epoch < oldestEpoch ? epoch : oldestEpoch); + } + } + } + + return oldestEpoch != null ? now - oldestEpoch : null; + } +} + +/// Static method to run in isolate for re-anchoring notes +void _reanchorNotesIsolate(List args) async { + final sendPort = args[0] as SendPort; + final data = args[1] as IsolateReanchoringData; + + try { + final results = []; + final anchoringService = AnchoringService.instance; + + // Process each note with timeout + for (final note in data.notes) { + try { + final stopwatch = Stopwatch()..start(); + + final result = await anchoringService.reanchorNote(note, data.document) + .timeout(Duration(milliseconds: data.config.maxReanchoringTimeMs)); + + results.add(result); + + // Track individual performance + NotesTelemetry.trackPerformanceMetric( + 'isolate_reanchor', + stopwatch.elapsed, + ); + } catch (e) { + results.add(AnchorResult( + NoteStatus.orphan, + errorMessage: 'Re-anchoring failed: $e', + )); + } + } + + // Send results back + sendPort.send(IsolateReanchoringResult( + requestId: data.requestId, + results: results, + )); + } catch (e) { + // Send error back + sendPort.send(IsolateReanchoringResult( + requestId: data.requestId, + error: e.toString(), + )); + } +} + +/// Data structure for isolate communication +class IsolateReanchoringData { + final String requestId; + final List notes; + final CanonicalDocument document; + final ProcessingConfig config; + + const IsolateReanchoringData({ + required this.requestId, + required this.notes, + required this.document, + required this.config, + }); +} + +/// Result structure for isolate communication +class IsolateReanchoringResult { + final String requestId; + final List? results; + final String? error; + + const IsolateReanchoringResult({ + required this.requestId, + this.results, + this.error, + }); +} + +/// Configuration for background processing +class ProcessingConfig { + final int maxReanchoringTimeMs; + final int maxBatchSize; + final bool fuzzyMatchingEnabled; + + const ProcessingConfig({ + required this.maxReanchoringTimeMs, + required this.maxBatchSize, + required this.fuzzyMatchingEnabled, + }); +} + +/// Exception for timeout operations +class TimeoutException implements Exception { + final String message; + final Duration timeout; + + const TimeoutException(this.message, this.timeout); + + @override + String toString() => 'TimeoutException: $message (timeout: $timeout)'; +} \ No newline at end of file diff --git a/lib/notes/services/canonical_text_service.dart b/lib/notes/services/canonical_text_service.dart new file mode 100644 index 000000000..c6603cd6b --- /dev/null +++ b/lib/notes/services/canonical_text_service.dart @@ -0,0 +1,90 @@ +import '../models/anchor_models.dart'; +import 'text_normalizer.dart'; +import 'hash_generator.dart'; +import '../config/notes_config.dart'; + +/// Service for creating and managing canonical text documents. +class CanonicalTextService { + static CanonicalTextService? _instance; + + CanonicalTextService._(); + + /// Singleton instance + static CanonicalTextService get instance { + _instance ??= CanonicalTextService._(); + return _instance!; + } + + /// Create a canonical document from book text + Future createCanonicalDocument(String bookId) async { + try { + // Get raw text from book (this would normally come from FileSystemData) + final rawText = await _getRawTextForBook(bookId); + + // Create normalization config + final config = TextNormalizer.createConfigFromSettings(); + + // Normalize the text + final canonicalText = TextNormalizer.normalize(rawText, config); + + // Calculate version ID + final versionId = calculateDocumentVersion(rawText); + + // Create indexes (simplified for now) + final textHashIndex = >{}; + final contextHashIndex = >{}; + final rollingHashIndex = >{}; + + return CanonicalDocument( + id: 'canonical_${bookId}_${DateTime.now().millisecondsSinceEpoch}', + bookId: bookId, + versionId: versionId, + canonicalText: canonicalText, + textHashIndex: textHashIndex, + contextHashIndex: contextHashIndex, + rollingHashIndex: rollingHashIndex, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + } catch (e) { + throw CanonicalTextException('Failed to create canonical document: $e'); + } + } + + /// Extract context window around a text position + ContextWindow extractContextWindow( + String text, + int start, + int end, { + int windowSize = AnchoringConstants.contextWindowSize, + }) { + return TextNormalizer.extractContextWindow(text, start, end, + windowSize: windowSize); + } + + /// Calculate document version ID from raw text + String calculateDocumentVersion(String rawText) { + return HashGenerator.generateTextHash(rawText); + } + + /// Get raw text for a book (placeholder implementation) + Future _getRawTextForBook(String bookId) async { + // This is a placeholder - in real implementation this would: + // 1. Load text from FileSystemData + // 2. Handle different book formats + // 3. Cache frequently accessed books + + // For now, return a simple placeholder + return 'Sample text for book $bookId. This would be the actual book content.'; + } +} + +/// Exception thrown by canonical text service +class CanonicalTextException implements Exception { + final String message; + + const CanonicalTextException(this.message); + + @override + String toString() => 'CanonicalTextException: $message'; +} diff --git a/lib/notes/services/filesystem_notes_extension.dart b/lib/notes/services/filesystem_notes_extension.dart new file mode 100644 index 000000000..123049375 --- /dev/null +++ b/lib/notes/services/filesystem_notes_extension.dart @@ -0,0 +1,313 @@ +import 'dart:async'; +import '../models/anchor_models.dart'; +import '../services/canonical_text_service.dart'; +import '../services/notes_telemetry.dart'; + +/// Extension service for integrating notes with the existing FileSystemData +class FileSystemNotesExtension { + static FileSystemNotesExtension? _instance; + final CanonicalTextService _canonicalService = CanonicalTextService.instance; + + // Cache for canonical documents + final Map _canonicalCache = {}; + final Map _cacheTimestamps = {}; + final Map _bookVersions = {}; + + FileSystemNotesExtension._(); + + /// Singleton instance + static FileSystemNotesExtension get instance { + _instance ??= FileSystemNotesExtension._(); + return _instance!; + } + + /// Get or create canonical document for a book + Future getCanonicalDocument( + String bookId, + String bookText, + ) async { + final stopwatch = Stopwatch()..start(); + + try { + // Check if we have a cached version + final cached = _getCachedCanonicalDocument(bookId, bookText); + if (cached != null) { + NotesTelemetry.trackPerformanceMetric('canonical_doc_cache_hit', stopwatch.elapsed); + return cached; + } + + // Create new canonical document + final canonicalDoc = await _canonicalService.createCanonicalDocument(bookId); + + // Cache the result + _cacheCanonicalDocument(bookId, canonicalDoc, bookText); + + NotesTelemetry.trackPerformanceMetric('canonical_doc_creation', stopwatch.elapsed); + + return canonicalDoc; + + } catch (e) { + NotesTelemetry.trackPerformanceMetric('canonical_doc_error', stopwatch.elapsed); + rethrow; + } + } + + /// Check if book content has changed since last canonical document creation + bool hasBookContentChanged(String bookId, String currentBookText) { + final cachedVersion = _bookVersions[bookId]; + if (cachedVersion == null) return true; + + final currentVersion = _calculateBookVersion(currentBookText); + return cachedVersion != currentVersion; + } + + /// Get book version information + BookVersionInfo getBookVersionInfo(String bookId, String bookText) { + final currentVersion = _calculateBookVersion(bookText); + final cachedVersion = _bookVersions[bookId]; + final hasChanged = cachedVersion != null && cachedVersion != currentVersion; + + return BookVersionInfo( + bookId: bookId, + currentVersion: currentVersion, + cachedVersion: cachedVersion, + hasChanged: hasChanged, + textLength: bookText.length, + lastChecked: DateTime.now(), + ); + } + + /// Preload canonical documents for multiple books + Future> preloadCanonicalDocuments( + Map booksData, { + Function(int current, int total)? onProgress, + }) async { + final results = {}; + final total = booksData.length; + int current = 0; + + for (final entry in booksData.entries) { + try { + final canonicalDoc = await getCanonicalDocument(entry.key, entry.value); + results[entry.key] = canonicalDoc; + + current++; + onProgress?.call(current, total); + + } catch (e) { + // Log error but continue with other books + NotesTelemetry.trackPerformanceMetric('preload_canonical_error', Duration.zero); + } + } + + return results; + } + + /// Clear cache for specific book or all books + void clearCanonicalCache({String? bookId}) { + if (bookId != null) { + _canonicalCache.remove(bookId); + _cacheTimestamps.remove(bookId); + _bookVersions.remove(bookId); + } else { + _canonicalCache.clear(); + _cacheTimestamps.clear(); + _bookVersions.clear(); + } + } + + /// Get cache statistics + Map getCacheStats() { + final now = DateTime.now(); + final cacheAges = _cacheTimestamps.values.map((timestamp) => + now.difference(timestamp).inMinutes).toList(); + + return { + 'cached_documents': _canonicalCache.length, + 'average_cache_age_minutes': cacheAges.isEmpty + ? 0 + : cacheAges.reduce((a, b) => a + b) / cacheAges.length, + 'oldest_cache_minutes': cacheAges.isEmpty ? 0 : cacheAges.reduce((a, b) => a > b ? a : b), + 'cache_memory_estimate_mb': _estimateCacheMemoryUsage() / (1024 * 1024), + }; + } + + /// Optimize cache by removing old entries + void optimizeCache() { + final now = DateTime.now(); + final expiredKeys = []; + + // Find expired entries (older than 2 hours) + for (final entry in _cacheTimestamps.entries) { + if (now.difference(entry.value) > const Duration(hours: 2)) { + expiredKeys.add(entry.key); + } + } + + // Remove expired entries + for (final key in expiredKeys) { + _canonicalCache.remove(key); + _cacheTimestamps.remove(key); + _bookVersions.remove(key); + } + + // If cache is still too large, remove oldest entries + while (_canonicalCache.length > 50) { // Max 50 cached documents + final oldestKey = _cacheTimestamps.entries + .reduce((a, b) => a.value.isBefore(b.value) ? a : b) + .key; + + _canonicalCache.remove(oldestKey); + _cacheTimestamps.remove(oldestKey); + _bookVersions.remove(oldestKey); + } + } + + /// Export cache data for backup + Map exportCacheData() { + return { + 'version': '1.0', + 'exported_at': DateTime.now().toIso8601String(), + 'book_versions': _bookVersions, + 'cache_timestamps': _cacheTimestamps.map( + (key, value) => MapEntry(key, value.toIso8601String()), + ), + }; + } + + /// Import cache data from backup + void importCacheData(Map data) { + try { + final bookVersions = data['book_versions'] as Map?; + if (bookVersions != null) { + _bookVersions.clear(); + _bookVersions.addAll(bookVersions.cast()); + } + + final cacheTimestamps = data['cache_timestamps'] as Map?; + if (cacheTimestamps != null) { + _cacheTimestamps.clear(); + for (final entry in cacheTimestamps.entries) { + _cacheTimestamps[entry.key] = DateTime.parse(entry.value as String); + } + } + + } catch (e) { + // If import fails, just clear the cache + clearCanonicalCache(); + } + } + + // Private helper methods + + CanonicalDocument? _getCachedCanonicalDocument(String bookId, String bookText) { + final timestamp = _cacheTimestamps[bookId]; + if (timestamp == null) return null; + + // Check if cache is expired (1 hour) + if (DateTime.now().difference(timestamp) > const Duration(hours: 1)) { + _canonicalCache.remove(bookId); + _cacheTimestamps.remove(bookId); + _bookVersions.remove(bookId); + return null; + } + + // Check if book content has changed + if (hasBookContentChanged(bookId, bookText)) { + _canonicalCache.remove(bookId); + _cacheTimestamps.remove(bookId); + _bookVersions.remove(bookId); + return null; + } + + return _canonicalCache[bookId]; + } + + void _cacheCanonicalDocument( + String bookId, + CanonicalDocument canonicalDoc, + String bookText, + ) { + _canonicalCache[bookId] = canonicalDoc; + _cacheTimestamps[bookId] = DateTime.now(); + _bookVersions[bookId] = _calculateBookVersion(bookText); + + // Optimize cache if it gets too large + if (_canonicalCache.length > 100) { + optimizeCache(); + } + } + + String _calculateBookVersion(String bookText) { + // Simple hash-based version calculation + // In a real implementation, this might use a more sophisticated algorithm + return bookText.hashCode.toString(); + } + + int _estimateCacheMemoryUsage() { + int totalSize = 0; + + for (final doc in _canonicalCache.values) { + // Estimate memory usage for each canonical document + totalSize += doc.canonicalText.length * 2; // UTF-16 encoding + totalSize += doc.textHashIndex.length * 50; // Rough estimate for hash index + totalSize += doc.contextHashIndex.length * 50; // Rough estimate for context index + totalSize += doc.rollingHashIndex.length * 20; // Rough estimate for rolling hash index + } + + return totalSize; + } +} + +/// Information about book version and changes +class BookVersionInfo { + final String bookId; + final String currentVersion; + final String? cachedVersion; + final bool hasChanged; + final int textLength; + final DateTime lastChecked; + + const BookVersionInfo({ + required this.bookId, + required this.currentVersion, + this.cachedVersion, + required this.hasChanged, + required this.textLength, + required this.lastChecked, + }); + + /// Check if this is the first time we're seeing this book + bool get isFirstTime => cachedVersion == null; + + /// Get a summary of the version status + String get statusSummary { + if (isFirstTime) return 'First load'; + if (hasChanged) return 'Content changed'; + return 'Up to date'; + } + + /// Convert to JSON for serialization + Map toJson() { + return { + 'book_id': bookId, + 'current_version': currentVersion, + 'cached_version': cachedVersion, + 'has_changed': hasChanged, + 'text_length': textLength, + 'last_checked': lastChecked.toIso8601String(), + }; + } + + /// Create from JSON + factory BookVersionInfo.fromJson(Map json) { + return BookVersionInfo( + bookId: json['book_id'] as String, + currentVersion: json['current_version'] as String, + cachedVersion: json['cached_version'] as String?, + hasChanged: json['has_changed'] as bool, + textLength: json['text_length'] as int, + lastChecked: DateTime.parse(json['last_checked'] as String), + ); + } +} \ No newline at end of file diff --git a/lib/notes/services/fuzzy_matcher.dart b/lib/notes/services/fuzzy_matcher.dart new file mode 100644 index 000000000..87993ab9b --- /dev/null +++ b/lib/notes/services/fuzzy_matcher.dart @@ -0,0 +1,225 @@ +import '../utils/text_utils.dart'; +import '../config/notes_config.dart'; +import '../models/anchor_models.dart'; + +/// Service for fuzzy text matching using multiple similarity algorithms. +/// +/// This service implements various string similarity algorithms used by the +/// anchoring system when exact matches are not possible. It combines multiple +/// approaches to provide robust matching for changed text. +/// +/// ## Similarity Algorithms +/// +/// ### 1. Levenshtein Distance +/// - **Type**: Edit distance (character-level) +/// - **Best for**: Small character changes, typos +/// - **Range**: 0.0 (no similarity) to 1.0 (identical) +/// - **Complexity**: O(m×n) where m,n are string lengths +/// +/// ### 2. Jaccard Similarity +/// - **Type**: Set-based similarity using n-grams +/// - **Best for**: Word reordering, partial matches +/// - **Range**: 0.0 (no overlap) to 1.0 (identical sets) +/// - **Complexity**: O(m+n) for n-gram generation +/// +/// ### 3. Cosine Similarity +/// - **Type**: Vector-based similarity with n-gram frequency +/// - **Best for**: Semantic similarity, different word frequencies +/// - **Range**: 0.0 (orthogonal) to 1.0 (identical direction) +/// - **Complexity**: O(m+n) with frequency counting +/// +/// ## Composite Scoring +/// +/// The service can combine multiple algorithms using weighted averages: +/// +/// ``` +/// final_score = (levenshtein × 0.4) + (jaccard × 0.3) + (cosine × 0.3) +/// ``` +/// +/// ## Usage +/// +/// ```dart +/// // Individual algorithm scores +/// final levenshtein = FuzzyMatcher.calculateLevenshteinSimilarity(text1, text2); +/// final jaccard = FuzzyMatcher.calculateJaccardSimilarity(text1, text2); +/// final cosine = FuzzyMatcher.calculateCosineSimilarity(text1, text2); +/// +/// // Composite score for anchoring decisions +/// final composite = FuzzyMatcher.calculateCompositeSimilarity( +/// text1, text2, candidate +/// ); +/// +/// // Find best match from candidates +/// final bestMatch = FuzzyMatcher.findBestMatch(targetText, candidates); +/// ``` +/// +/// ## Performance Optimization +/// +/// - **Early termination**: Stop calculation if similarity drops below threshold +/// - **N-gram caching**: Reuse n-gram sets for multiple comparisons +/// - **Length filtering**: Skip candidates with very different lengths +/// - **Batch processing**: Optimize for multiple candidate evaluation +/// +/// ## Thresholds +/// +/// Default similarity thresholds from [AnchoringConstants]: +/// - Levenshtein: 0.82 (82% similarity required) +/// - Jaccard: 0.82 (82% n-gram overlap required) +/// - Cosine: 0.82 (82% vector similarity required) +/// +/// ## Hebrew & RTL Considerations +/// +/// - Uses grapheme-aware text processing +/// - Handles Hebrew nikud in n-gram generation +/// - RTL-safe character counting and slicing +/// - Consistent with [TextNormalizer] output +class FuzzyMatcher { + /// Calculate Levenshtein similarity ratio + static double calculateLevenshteinSimilarity(String a, String b) { + if (a.isEmpty && b.isEmpty) return 1.0; + if (a.isEmpty || b.isEmpty) return 0.0; + + final distance = TextUtils.levenshteinDistance(a, b); + final maxLength = a.length > b.length ? a.length : b.length; + + return 1.0 - (distance / maxLength); + } + + /// Calculate Jaccard similarity using n-grams + static double calculateJaccardSimilarity(String a, String b, {int ngramSize = 3}) { + return TextUtils.calculateJaccardSimilarity(a, b, ngramSize: ngramSize); + } + + /// Calculate true Cosine similarity using n-grams with frequency + static double calculateCosineSimilarity(String a, String b, {int ngramSize = 3}) { + return TextUtils.calculateCosineSimilarity(a, b, ngramSize: ngramSize); + } + + /// Generate n-grams from text + static List generateNGrams(String text, int n) { + return TextUtils.generateNGrams(text, n); + } + + /// Find fuzzy matches in a text using sliding window + static List findFuzzyMatches( + String searchText, + String targetText, { + double levenshteinThreshold = AnchoringConstants.levenshteinThreshold, + double jaccardThreshold = AnchoringConstants.jaccardThreshold, + double cosineThreshold = AnchoringConstants.cosineThreshold, + int ngramSize = AnchoringConstants.ngramSize, + }) { + final candidates = []; + final searchLength = searchText.length; + + if (searchLength > targetText.length) { + return candidates; + } + + // Use adaptive step size based on text length + final stepSize = (searchLength / 4).clamp(1, 10).round(); + + for (int i = 0; i <= targetText.length - searchLength; i += stepSize) { + final candidateText = targetText.substring(i, i + searchLength); + + // Calculate multiple similarity scores + final levenshteinSim = calculateLevenshteinSimilarity(searchText, candidateText); + final jaccardSim = calculateJaccardSimilarity(searchText, candidateText, ngramSize: ngramSize); + final cosineSim = calculateCosineSimilarity(searchText, candidateText, ngramSize: ngramSize); + + // Check if any similarity meets the threshold + final meetsLevenshtein = levenshteinSim >= (1.0 - levenshteinThreshold); + final meetsJaccard = jaccardSim >= jaccardThreshold; + final meetsCosine = cosineSim >= cosineThreshold; + + if (meetsLevenshtein && (meetsJaccard || meetsCosine)) { + // Use the highest similarity score + final maxScore = [levenshteinSim, jaccardSim, cosineSim].reduce((a, b) => a > b ? a : b); + + candidates.add(AnchorCandidate( + i, + i + searchLength, + maxScore, + 'fuzzy', + )); + } + } + + // Sort by score (highest first) and remove duplicates + candidates.sort((a, b) => b.score.compareTo(a.score)); + + return _removeDuplicateCandidates(candidates); + } + + /// Remove duplicate candidates that are too close to each other + static List _removeDuplicateCandidates(List candidates) { + if (candidates.length <= 1) return candidates; + + final filtered = []; + const minDistance = 10; // Minimum distance between candidates + + for (final candidate in candidates) { + bool tooClose = false; + + for (final existing in filtered) { + if ((candidate.start - existing.start).abs() < minDistance) { + tooClose = true; + break; + } + } + + if (!tooClose) { + filtered.add(candidate); + } + } + + return filtered; + } + + /// Find the best match using combined scoring + static AnchorCandidate? findBestMatch( + String searchText, + String targetText, { + double minScore = 0.7, + }) { + final candidates = findFuzzyMatches(searchText, targetText); + + if (candidates.isEmpty) return null; + + final best = candidates.first; + return best.score >= minScore ? best : null; + } + + /// Calculate combined similarity score using locked weights + static double calculateCombinedSimilarity(String a, String b) { + final levenshteinSim = calculateLevenshteinSimilarity(a, b); + final jaccardSim = calculateJaccardSimilarity(a, b); + final cosineSim = calculateCosineSimilarity(a, b); + + return (levenshteinSim * AnchoringConstants.levenshteinWeight) + + (jaccardSim * AnchoringConstants.jaccardWeight) + + (cosineSim * AnchoringConstants.cosineWeight); + } + + /// Validate similarity thresholds + static bool validateSimilarityThresholds({ + required double levenshteinThreshold, + required double jaccardThreshold, + required double cosineThreshold, + }) { + return levenshteinThreshold >= 0.0 && levenshteinThreshold <= 1.0 && + jaccardThreshold >= 0.0 && jaccardThreshold <= 1.0 && + cosineThreshold >= 0.0 && cosineThreshold <= 1.0; + } + + /// Get similarity statistics for debugging + static Map getSimilarityStats(String a, String b) { + return { + 'levenshtein': calculateLevenshteinSimilarity(a, b), + 'jaccard': calculateJaccardSimilarity(a, b), + 'cosine': calculateCosineSimilarity(a, b), + 'combined': calculateCombinedSimilarity(a, b), + 'length_ratio': a.length / b.length.clamp(1, double.infinity), + }; + } +} \ No newline at end of file diff --git a/lib/notes/services/hash_generator.dart b/lib/notes/services/hash_generator.dart new file mode 100644 index 000000000..8c5ab2c77 --- /dev/null +++ b/lib/notes/services/hash_generator.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; +import 'package:crypto/crypto.dart'; + +/// Service for generating various types of hashes for text anchoring. +class HashGenerator { + /// Generate SHA-256 hash of normalized text. + static String generateTextHash(String normalizedText) { + final bytes = utf8.encode(normalizedText); + final digest = sha256.convert(bytes); + return digest.toString(); + } + + /// Generate context hashes for before and after text + static ContextHashes generateContextHashes(String before, String after) { + return ContextHashes( + beforeHash: generateTextHash(before), + afterHash: generateTextHash(after), + beforeRollingHash: generateRollingHash(before), + afterRollingHash: generateRollingHash(after), + ); + } + + /// Generate text hashes for selected content + static TextHashes generateTextHashes(String normalizedText) { + return TextHashes( + textHash: generateTextHash(normalizedText), + rollingHash: generateRollingHash(normalizedText), + ); + } + + /// Generate rolling hash for sliding window operations + static int generateRollingHash(String text) { + if (text.isEmpty) return 0; + + const int base = 31; + const int mod = 1000000007; + + int hash = 0; + int power = 1; + + for (int i = 0; i < text.length; i++) { + hash = (hash + (text.codeUnitAt(i) * power)) % mod; + power = (power * base) % mod; + } + + return hash; + } +} + +/// Container for text hashes +class TextHashes { + final String textHash; + final int rollingHash; + + const TextHashes({ + required this.textHash, + required this.rollingHash, + }); +} + +/// Container for context hashes +class ContextHashes { + final String beforeHash; + final String afterHash; + final int beforeRollingHash; + final int afterRollingHash; + + const ContextHashes({ + required this.beforeHash, + required this.afterHash, + required this.beforeRollingHash, + required this.afterRollingHash, + }); +} \ No newline at end of file diff --git a/lib/notes/services/import_export_service.dart b/lib/notes/services/import_export_service.dart new file mode 100644 index 000000000..2516f9819 --- /dev/null +++ b/lib/notes/services/import_export_service.dart @@ -0,0 +1,452 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:async'; +import '../models/note.dart'; +import '../repository/notes_repository.dart'; +import '../services/notes_telemetry.dart'; + +/// Service for importing and exporting notes +class ImportExportService { + static ImportExportService? _instance; + final NotesRepository _repository = NotesRepository.instance; + + ImportExportService._(); + + /// Singleton instance + static ImportExportService get instance { + _instance ??= ImportExportService._(); + return _instance!; + } + + /// Export notes to JSON format + Future exportNotes({ + String? bookId, + List? noteIds, + bool includeOrphans = true, + bool includePrivateNotes = true, + String? filePath, + }) async { + final stopwatch = Stopwatch()..start(); + + try { + // Determine which notes to export + List notesToExport; + + if (noteIds != null && noteIds.isNotEmpty) { + // Export specific notes + notesToExport = []; + for (final noteId in noteIds) { + final note = await _repository.getNoteById(noteId); + if (note != null) { + notesToExport.add(note); + } + } + } else if (bookId != null) { + // Export all notes for a specific book + notesToExport = await _repository.getNotesForBook(bookId); + } else { + // This would require a method to get all notes across all books + throw UnsupportedError('Exporting all notes across all books is not yet supported'); + } + + // Apply filters + final filteredNotes = notesToExport.where((note) { + if (!includeOrphans && note.status == NoteStatus.orphan) { + return false; + } + if (!includePrivateNotes && note.privacy == NotePrivacy.private) { + return false; + } + return true; + }).toList(); + + // Create export data structure + final exportData = { + 'version': '1.0', + 'exported_at': DateTime.now().toIso8601String(), + 'export_metadata': { + 'book_id': bookId, + 'total_notes': filteredNotes.length, + 'include_orphans': includeOrphans, + 'include_private': includePrivateNotes, + 'app_version': '1.0.0', // This should come from package info + }, + 'notes': filteredNotes.map((note) => _noteToExportJson(note)).toList(), + }; + + // Convert to JSON + final jsonString = const JsonEncoder.withIndent(' ').convert(exportData); + + // Save to file if path provided + String? savedPath; + if (filePath != null) { + final file = File(filePath); + await file.writeAsString(jsonString); + savedPath = filePath; + } + + // Track export + NotesTelemetry.trackUserAction('notes_exported', { + 'note_count': filteredNotes.length, + 'book_id_length': bookId?.length ?? 0, + 'include_orphans': includeOrphans, + 'include_private': includePrivateNotes, + }); + + return ExportResult( + success: true, + notesCount: filteredNotes.length, + filePath: savedPath, + jsonData: jsonString, + duration: stopwatch.elapsed, + ); + + } catch (e) { + NotesTelemetry.trackPerformanceMetric('export_error', stopwatch.elapsed); + + return ExportResult( + success: false, + notesCount: 0, + error: e.toString(), + duration: stopwatch.elapsed, + ); + } + } + + /// Import notes from JSON format + Future importNotes( + String jsonData, { + bool overwriteExisting = false, + bool validateAnchors = true, + String? targetBookId, + Function(int current, int total)? onProgress, + }) async { + final stopwatch = Stopwatch()..start(); + + try { + // Parse JSON + final Map data; + try { + data = jsonDecode(jsonData) as Map; + } catch (e) { + throw ImportException('Invalid JSON format: $e'); + } + + // Validate format + _validateImportFormat(data); + + // Extract notes data + final notesData = data['notes'] as List; + final totalNotes = notesData.length; + + if (totalNotes == 0) { + return ImportResult( + success: true, + totalNotes: 0, + importedCount: 0, + skippedCount: 0, + errorCount: 0, + duration: stopwatch.elapsed, + ); + } + + // Process notes + int importedCount = 0; + int skippedCount = 0; + int errorCount = 0; + final errors = []; + + for (int i = 0; i < notesData.length; i++) { + try { + final noteData = notesData[i] as Map; + + // Convert to Note object + final note = _noteFromImportJson(noteData, targetBookId); + + // Check if note already exists + final existingNote = await _repository.getNoteById(note.id); + + if (existingNote != null) { + if (overwriteExisting) { + await _repository.updateNote(note.id, UpdateNoteRequest( + contentMarkdown: note.contentMarkdown, + tags: note.tags, + privacy: note.privacy, + charStart: note.charStart, + charEnd: note.charEnd, + status: note.status, + )); + importedCount++; + } else { + skippedCount++; + } + } else { + // Create new note + await _repository.createNote(CreateNoteRequest( + bookId: note.bookId, + charStart: note.charStart, + charEnd: note.charEnd, + contentMarkdown: note.contentMarkdown, + authorUserId: note.authorUserId, + privacy: note.privacy, + tags: note.tags, + )); + importedCount++; + } + + // Report progress + onProgress?.call(i + 1, totalNotes); + + } catch (e) { + errorCount++; + errors.add('Note ${i + 1}: $e'); + } + } + + // Track import + NotesTelemetry.trackUserAction('notes_imported', { + 'total_notes': totalNotes, + 'imported_count': importedCount, + 'skipped_count': skippedCount, + 'error_count': errorCount, + 'overwrite_existing': overwriteExisting, + }); + + return ImportResult( + success: errorCount < totalNotes, // Success if not all failed + totalNotes: totalNotes, + importedCount: importedCount, + skippedCount: skippedCount, + errorCount: errorCount, + errors: errors, + duration: stopwatch.elapsed, + ); + + } catch (e) { + NotesTelemetry.trackPerformanceMetric('import_error', stopwatch.elapsed); + + return ImportResult( + success: false, + totalNotes: 0, + importedCount: 0, + skippedCount: 0, + errorCount: 1, + errors: [e.toString()], + duration: stopwatch.elapsed, + ); + } + } + + /// Import notes from file + Future importNotesFromFile( + String filePath, { + bool overwriteExisting = false, + bool validateAnchors = true, + String? targetBookId, + Function(int current, int total)? onProgress, + }) async { + try { + final file = File(filePath); + if (!await file.exists()) { + throw ImportException('File not found: $filePath'); + } + + final jsonData = await file.readAsString(); + + return await importNotes( + jsonData, + overwriteExisting: overwriteExisting, + validateAnchors: validateAnchors, + targetBookId: targetBookId, + onProgress: onProgress, + ); + + } catch (e) { + return ImportResult( + success: false, + totalNotes: 0, + importedCount: 0, + skippedCount: 0, + errorCount: 1, + errors: [e.toString()], + duration: Duration.zero, + ); + } + } + + /// Get export/import statistics + Map getOperationStats() { + // This would typically be stored in a service or database + // For now, return empty stats + return { + 'total_exports': 0, + 'total_imports': 0, + 'last_export': null, + 'last_import': null, + }; + } + + // Private helper methods + + Map _noteToExportJson(Note note) { + return { + 'id': note.id, + 'book_id': note.bookId, + 'doc_version_id': note.docVersionId, + 'logical_path': note.logicalPath, + 'char_start': note.charStart, + 'char_end': note.charEnd, + 'selected_text_normalized': note.selectedTextNormalized, + 'text_hash': note.textHash, + 'context_before': note.contextBefore, + 'context_after': note.contextAfter, + 'context_before_hash': note.contextBeforeHash, + 'context_after_hash': note.contextAfterHash, + 'rolling_before': note.rollingBefore, + 'rolling_after': note.rollingAfter, + 'status': note.status.name, + 'content_markdown': note.contentMarkdown, + 'author_user_id': note.authorUserId, + 'privacy': note.privacy.name, + 'tags': note.tags, + 'created_at': note.createdAt.toIso8601String(), + 'updated_at': note.updatedAt.toIso8601String(), + 'normalization_config': note.normalizationConfig, + }; + } + + Note _noteFromImportJson(Map data, String? targetBookId) { + return Note( + id: data['id'] as String, + bookId: targetBookId ?? (data['book_id'] as String), + docVersionId: data['doc_version_id'] as String, + logicalPath: (data['logical_path'] as List?)?.cast(), + charStart: data['char_start'] as int, + charEnd: data['char_end'] as int, + selectedTextNormalized: data['selected_text_normalized'] as String, + textHash: data['text_hash'] as String, + contextBefore: data['context_before'] as String, + contextAfter: data['context_after'] as String, + contextBeforeHash: data['context_before_hash'] as String, + contextAfterHash: data['context_after_hash'] as String, + rollingBefore: data['rolling_before'] as int, + rollingAfter: data['rolling_after'] as int, + status: NoteStatus.values.firstWhere( + (s) => s.name == data['status'], + orElse: () => NoteStatus.orphan, + ), + contentMarkdown: data['content_markdown'] as String, + authorUserId: data['author_user_id'] as String, + privacy: NotePrivacy.values.firstWhere( + (p) => p.name == data['privacy'], + orElse: () => NotePrivacy.private, + ), + tags: (data['tags'] as List).cast(), + createdAt: DateTime.parse(data['created_at'] as String), + updatedAt: DateTime.parse(data['updated_at'] as String), + normalizationConfig: data['normalization_config'] as String, + ); + } + + void _validateImportFormat(Map data) { + // Check required fields + if (!data.containsKey('version')) { + throw ImportException('Missing version field'); + } + + if (!data.containsKey('notes')) { + throw ImportException('Missing notes field'); + } + + final version = data['version'] as String; + if (version != '1.0') { + throw ImportException('Unsupported version: $version'); + } + + final notes = data['notes']; + if (notes is! List) { + throw ImportException('Notes field must be an array'); + } + } +} + +/// Result of export operation +class ExportResult { + final bool success; + final int notesCount; + final String? filePath; + final String? jsonData; + final Duration duration; + final String? error; + + const ExportResult({ + required this.success, + required this.notesCount, + this.filePath, + this.jsonData, + required this.duration, + this.error, + }); + + /// Get file size in bytes (if data is available) + int? get fileSizeBytes => jsonData?.length; + + /// Get human-readable file size + String get fileSizeFormatted { + final bytes = fileSizeBytes; + if (bytes == null) return 'Unknown'; + + if (bytes < 1024) return '${bytes}B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)}KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)}MB'; + } +} + +/// Result of import operation +class ImportResult { + final bool success; + final int totalNotes; + final int importedCount; + final int skippedCount; + final int errorCount; + final List errors; + final Duration duration; + + const ImportResult({ + required this.success, + required this.totalNotes, + required this.importedCount, + required this.skippedCount, + required this.errorCount, + this.errors = const [], + required this.duration, + }); + + /// Get success rate as percentage + double get successRate { + if (totalNotes == 0) return 100.0; + return (importedCount / totalNotes) * 100.0; + } + + /// Get summary message + String get summary { + if (totalNotes == 0) return 'No notes to import'; + + final parts = []; + if (importedCount > 0) parts.add('$importedCount imported'); + if (skippedCount > 0) parts.add('$skippedCount skipped'); + if (errorCount > 0) parts.add('$errorCount errors'); + + return parts.join(', '); + } +} + +/// Exception thrown during import operations +class ImportException implements Exception { + final String message; + + const ImportException(this.message); + + @override + String toString() => 'ImportException: $message'; +} \ No newline at end of file diff --git a/lib/notes/services/notes_integration_service.dart b/lib/notes/services/notes_integration_service.dart new file mode 100644 index 000000000..07001607c --- /dev/null +++ b/lib/notes/services/notes_integration_service.dart @@ -0,0 +1,429 @@ +import 'dart:async'; +import '../models/note.dart'; +import '../models/anchor_models.dart'; +import '../repository/notes_repository.dart'; +import '../services/canonical_text_service.dart'; +import '../services/notes_telemetry.dart'; + +/// Service for integrating notes with the existing book system +class NotesIntegrationService { + static NotesIntegrationService? _instance; + final NotesRepository _repository = NotesRepository.instance; + final CanonicalTextService _canonicalService = CanonicalTextService.instance; + + // Cache for loaded notes by book + final Map> _notesCache = {}; + final Map _cacheTimestamps = {}; + + NotesIntegrationService._(); + + /// Singleton instance + static NotesIntegrationService get instance { + _instance ??= NotesIntegrationService._(); + return _instance!; + } + + /// Load notes for a book and integrate with text display + Future loadNotesForBook(String bookId, String bookText) async { + final stopwatch = Stopwatch()..start(); + + try { + // Check cache first + final cachedNotes = _getCachedNotes(bookId); + if (cachedNotes != null) { + return BookNotesData( + bookId: bookId, + notes: cachedNotes, + visibleRange: null, + loadTime: stopwatch.elapsed, + fromCache: true, + ); + } + + // Load notes from repository + final notes = await _repository.getNotesForBook(bookId); + + // Create canonical document for re-anchoring if needed + final canonicalDoc = await _canonicalService.createCanonicalDocument(bookId); + + // Check if re-anchoring is needed + final needsReanchoring = notes.any((note) => note.docVersionId != canonicalDoc.versionId); + + List finalNotes = notes; + if (needsReanchoring) { + finalNotes = await _reanchorNotesIfNeeded(notes, canonicalDoc); + } + + // Cache the results + _cacheNotes(bookId, finalNotes); + + // Track performance + NotesTelemetry.trackPerformanceMetric('book_notes_load', stopwatch.elapsed); + + return BookNotesData( + bookId: bookId, + notes: finalNotes, + visibleRange: null, + loadTime: stopwatch.elapsed, + fromCache: false, + reanchoringPerformed: needsReanchoring, + ); + + } catch (e) { + NotesTelemetry.trackPerformanceMetric('book_notes_load_error', stopwatch.elapsed); + rethrow; + } + } + + /// Get notes for a specific visible range (for performance) + List getNotesForVisibleRange(String bookId, VisibleCharRange range) { + final allNotes = _notesCache[bookId] ?? []; + + return allNotes.where((note) { + // Check if note overlaps with visible range + return !(note.charEnd < range.start || note.charStart > range.end); + }).toList(); + } + + /// Create highlight data for text rendering + List createHighlightsForRange( + String bookId, + VisibleCharRange range, + ) { + final visibleNotes = getNotesForVisibleRange(bookId, range); + final highlights = []; + + for (final note in visibleNotes) { + // Ensure highlight is within the visible range + final highlightStart = note.charStart.clamp(range.start, range.end); + final highlightEnd = note.charEnd.clamp(range.start, range.end); + + if (highlightStart < highlightEnd) { + highlights.add(TextHighlight( + start: highlightStart, + end: highlightEnd, + noteId: note.id, + status: note.status, + color: _getHighlightColor(note.status), + opacity: _getHighlightOpacity(note.status), + )); + } + } + + // Sort by start position for consistent rendering + highlights.sort((a, b) => a.start.compareTo(b.start)); + + return highlights; + } + + /// Handle text selection for note creation + Future createNoteFromSelection( + String bookId, + String selectedText, + int charStart, + int charEnd, + String noteContent, { + List tags = const [], + NotePrivacy privacy = NotePrivacy.private, + }) async { + final stopwatch = Stopwatch()..start(); + + try { + // Create note request + final request = CreateNoteRequest( + bookId: bookId, + charStart: charStart, + charEnd: charEnd, + contentMarkdown: noteContent, + authorUserId: 'current_user', // This should come from user service + privacy: privacy, + tags: tags, + ); + + // Create the note + final note = await _repository.createNote(request); + + // Update cache + _addNoteToCache(bookId, note); + + // Track user action + NotesTelemetry.trackUserAction('note_created_from_selection', { + 'book_id_length': bookId.length, + 'content_length': noteContent.length, + 'tags_count': tags.length, + 'privacy': privacy.name, + }); + + NotesTelemetry.trackPerformanceMetric('note_creation', stopwatch.elapsed); + + return note; + + } catch (e) { + NotesTelemetry.trackPerformanceMetric('note_creation_error', stopwatch.elapsed); + rethrow; + } + } + + /// Update an existing note + Future updateNote( + String noteId, + String? newContent, { + List? newTags, + NotePrivacy? newPrivacy, + }) async { + final stopwatch = Stopwatch()..start(); + + try { + final request = UpdateNoteRequest( + contentMarkdown: newContent, + tags: newTags, + privacy: newPrivacy, + ); + + final updatedNote = await _repository.updateNote(noteId, request); + + // Update cache + _updateNoteInCache(updatedNote); + + NotesTelemetry.trackPerformanceMetric('note_update', stopwatch.elapsed); + + return updatedNote; + + } catch (e) { + NotesTelemetry.trackPerformanceMetric('note_update_error', stopwatch.elapsed); + rethrow; + } + } + + /// Delete a note + Future deleteNote(String noteId) async { + final stopwatch = Stopwatch()..start(); + + try { + await _repository.deleteNote(noteId); + + // Remove from cache + _removeNoteFromCache(noteId); + + NotesTelemetry.trackPerformanceMetric('note_deletion', stopwatch.elapsed); + + } catch (e) { + NotesTelemetry.trackPerformanceMetric('note_deletion_error', stopwatch.elapsed); + rethrow; + } + } + + /// Search notes across all books or specific book + Future> searchNotes(String query, {String? bookId}) async { + final stopwatch = Stopwatch()..start(); + + try { + final results = await _repository.searchNotes(query, bookId: bookId); + + NotesTelemetry.trackSearchPerformance( + query, + results.length, + stopwatch.elapsed, + ); + + return results; + + } catch (e) { + NotesTelemetry.trackPerformanceMetric('note_search_error', stopwatch.elapsed); + rethrow; + } + } + + /// Clear cache for a specific book or all books + void clearCache({String? bookId}) { + if (bookId != null) { + _notesCache.remove(bookId); + _cacheTimestamps.remove(bookId); + } else { + _notesCache.clear(); + _cacheTimestamps.clear(); + } + } + + /// Get cache statistics + Map getCacheStats() { + final totalNotes = _notesCache.values.fold(0, (sum, notes) => sum + notes.length); + final oldestCache = _cacheTimestamps.values.isEmpty + ? null + : _cacheTimestamps.values.reduce((a, b) => a.isBefore(b) ? a : b); + + return { + 'cached_books': _notesCache.length, + 'total_cached_notes': totalNotes, + 'oldest_cache_age_minutes': oldestCache != null + ? DateTime.now().difference(oldestCache).inMinutes + : null, + }; + } + + // Private helper methods + + List? _getCachedNotes(String bookId) { + final timestamp = _cacheTimestamps[bookId]; + if (timestamp == null) return null; + + // Cache expires after 1 hour + if (DateTime.now().difference(timestamp) > const Duration(hours: 1)) { + _notesCache.remove(bookId); + _cacheTimestamps.remove(bookId); + return null; + } + + return _notesCache[bookId]; + } + + void _cacheNotes(String bookId, List notes) { + _notesCache[bookId] = List.from(notes); + _cacheTimestamps[bookId] = DateTime.now(); + } + + void _addNoteToCache(String bookId, Note note) { + final cachedNotes = _notesCache[bookId]; + if (cachedNotes != null) { + cachedNotes.add(note); + // Keep sorted by position + cachedNotes.sort((a, b) => a.charStart.compareTo(b.charStart)); + } + } + + void _updateNoteInCache(Note updatedNote) { + for (final notes in _notesCache.values) { + final index = notes.indexWhere((note) => note.id == updatedNote.id); + if (index != -1) { + notes[index] = updatedNote; + break; + } + } + } + + void _removeNoteFromCache(String noteId) { + for (final notes in _notesCache.values) { + notes.removeWhere((note) => note.id == noteId); + } + } + + Future> _reanchorNotesIfNeeded( + List notes, + CanonicalDocument canonicalDoc, + ) async { + if (notes.isEmpty) return notes; + + final stopwatch = Stopwatch()..start(); + + try { + final reanchoringResults = await _repository.reanchorNotesForBook(canonicalDoc.bookId); + + NotesTelemetry.trackBatchReanchoring( + 'integration_reanchor_${DateTime.now().millisecondsSinceEpoch}', + notes.length, + reanchoringResults.successCount, + stopwatch.elapsed, + ); + + // Return updated notes + return await _repository.getNotesForBook(canonicalDoc.bookId); + + } catch (e) { + NotesTelemetry.trackPerformanceMetric('reanchoring_error', stopwatch.elapsed); + // Return original notes if re-anchoring fails + return notes; + } + } + + int _getHighlightColor(NoteStatus status) { + switch (status) { + case NoteStatus.anchored: + return 0xFF4CAF50; // Green + case NoteStatus.shifted: + return 0xFFFF9800; // Orange + case NoteStatus.orphan: + return 0xFFF44336; // Red + } + } + + double _getHighlightOpacity(NoteStatus status) { + switch (status) { + case NoteStatus.anchored: + return 0.3; + case NoteStatus.shifted: + return 0.4; + case NoteStatus.orphan: + return 0.5; + } + } +} + +/// Data structure for book notes integration +class BookNotesData { + final String bookId; + final List notes; + final VisibleCharRange? visibleRange; + final Duration loadTime; + final bool fromCache; + final bool reanchoringPerformed; + + const BookNotesData({ + required this.bookId, + required this.notes, + this.visibleRange, + required this.loadTime, + this.fromCache = false, + this.reanchoringPerformed = false, + }); + + /// Get notes by status + List getNotesByStatus(NoteStatus status) { + return notes.where((note) => note.status == status).toList(); + } + + /// Get notes count by status + Map getNotesCountByStatus() { + final counts = {}; + for (final note in notes) { + counts[note.status] = (counts[note.status] ?? 0) + 1; + } + return counts; + } + + /// Check if any notes need attention (orphans) + bool get hasOrphanNotes => notes.any((note) => note.status == NoteStatus.orphan); + + /// Get performance summary + String get performanceSummary { + final source = fromCache ? 'cache' : 'database'; + final reanchor = reanchoringPerformed ? ' (re-anchored)' : ''; + return 'Loaded ${notes.length} notes from $source in ${loadTime.inMilliseconds}ms$reanchor'; + } +} + +/// Text highlight data for rendering +class TextHighlight { + final int start; + final int end; + final String noteId; + final NoteStatus status; + final int color; + final double opacity; + + const TextHighlight({ + required this.start, + required this.end, + required this.noteId, + required this.status, + required this.color, + required this.opacity, + }); + + /// Check if this highlight overlaps with another + bool overlapsWith(TextHighlight other) { + return !(end <= other.start || start >= other.end); + } + + /// Get the length of this highlight + int get length => end - start; +} \ No newline at end of file diff --git a/lib/notes/services/notes_telemetry.dart b/lib/notes/services/notes_telemetry.dart new file mode 100644 index 000000000..f1da03e18 --- /dev/null +++ b/lib/notes/services/notes_telemetry.dart @@ -0,0 +1,196 @@ +import '../config/notes_config.dart'; +import '../models/note.dart'; + +/// Service for tracking notes performance and usage metrics +class NotesTelemetry { + static NotesTelemetry? _instance; + final Map> _performanceData = {}; + + NotesTelemetry._(); + + /// Singleton instance + static NotesTelemetry get instance { + _instance ??= NotesTelemetry._(); + return _instance!; + } + + /// Track anchoring result (no sensitive data) + static void trackAnchoringResult( + String requestId, + NoteStatus status, + Duration duration, + String strategy, + ) { + if (!NotesConfig.telemetryEnabled || !NotesEnvironment.telemetryEnabled) return; + + // Log performance metrics without sensitive content + if (NotesEnvironment.performanceLogging) { + print('Anchoring: $requestId, status: ${status.name}, ' + 'strategy: $strategy, duration: ${duration.inMilliseconds}ms'); + } + + // Store aggregated metrics + instance._recordMetric('anchoring_${status.name}', duration.inMilliseconds); + instance._recordMetric('strategy_$strategy', duration.inMilliseconds); + } + + /// Track batch re-anchoring performance + static void trackBatchReanchoring( + String requestId, + int noteCount, + int successCount, + Duration totalDuration, + ) { + if (!NotesConfig.telemetryEnabled || !NotesEnvironment.telemetryEnabled) return; + + final avgDuration = totalDuration.inMilliseconds / noteCount; + final successRate = successCount / noteCount; + + if (NotesEnvironment.performanceLogging) { + print('Batch reanchoring: $requestId, notes: $noteCount, ' + 'success: $successCount, rate: ${(successRate * 100).toStringAsFixed(1)}%, ' + 'avg: ${avgDuration.toStringAsFixed(1)}ms'); + } + + instance._recordMetric('batch_reanchoring', totalDuration.inMilliseconds); + instance._recordMetric('batch_success_rate', (successRate * 100).round()); + } + + /// Track search performance + static void trackSearchPerformance( + String query, + int resultCount, + Duration duration, + ) { + if (!NotesConfig.telemetryEnabled || !NotesEnvironment.telemetryEnabled) return; + + if (NotesEnvironment.performanceLogging) { + print('Search: query_length=${query.length}, results=$resultCount, ' + 'duration=${duration.inMilliseconds}ms'); + } + + instance._recordMetric('search_performance', duration.inMilliseconds); + instance._recordMetric('search_results', resultCount); + } + + /// Track general performance metric + static void trackPerformanceMetric(String operation, Duration duration) { + if (!NotesEnvironment.performanceLogging) return; + + print('Performance: $operation took ${duration.inMilliseconds}ms'); + instance._recordMetric(operation, duration.inMilliseconds); + } + + /// Track user action (no sensitive data) + static void trackUserAction(String action, Map context) { + if (!NotesConfig.telemetryEnabled || !NotesEnvironment.telemetryEnabled) return; + + // Only log non-sensitive context data + final safeContext = {}; + for (final entry in context.entries) { + switch (entry.key) { + case 'note_count': + case 'book_id_length': + case 'content_length': + case 'tags_count': + case 'status': + case 'privacy': + safeContext[entry.key] = entry.value; + break; + // Skip sensitive data like actual content, text, etc. + } + } + + if (NotesEnvironment.performanceLogging) { + print('User action: $action, context: $safeContext'); + } + } + + /// Record a metric value + void _recordMetric(String metric, int value) { + _performanceData.putIfAbsent(metric, () => []).add(value); + + // Keep only last 100 values per metric to prevent memory bloat + final values = _performanceData[metric]!; + if (values.length > 100) { + values.removeAt(0); + } + } + + /// Get performance statistics + Map getPerformanceStats() { + final stats = {}; + + for (final entry in _performanceData.entries) { + final values = entry.value; + if (values.isNotEmpty) { + values.sort(); + final avg = values.reduce((a, b) => a + b) / values.length; + final p95Index = (values.length * 0.95).floor().clamp(0, values.length - 1); + final p99Index = (values.length * 0.99).floor().clamp(0, values.length - 1); + + stats[entry.key] = { + 'count': values.length, + 'avg': avg.round(), + 'min': values.first, + 'max': values.last, + 'p95': values[p95Index], + 'p99': values[p99Index], + }; + } + } + + return stats; + } + + /// Get aggregated metrics for reporting + Map getAggregatedMetrics() { + final stats = getPerformanceStats(); + + return { + 'anchoring_performance': { + 'anchored_avg_ms': stats['anchoring_anchored']?['avg'] ?? 0, + 'shifted_avg_ms': stats['anchoring_shifted']?['avg'] ?? 0, + 'orphan_avg_ms': stats['anchoring_orphan']?['avg'] ?? 0, + }, + 'search_performance': { + 'avg_ms': stats['search_performance']?['avg'] ?? 0, + 'p95_ms': stats['search_performance']?['p95'] ?? 0, + 'avg_results': stats['search_results']?['avg'] ?? 0, + }, + 'batch_performance': { + 'avg_ms': stats['batch_reanchoring']?['avg'] ?? 0, + 'success_rate': stats['batch_success_rate']?['avg'] ?? 0, + }, + 'strategy_usage': { + 'exact_avg_ms': stats['strategy_exact']?['avg'] ?? 0, + 'context_avg_ms': stats['strategy_context']?['avg'] ?? 0, + 'fuzzy_avg_ms': stats['strategy_fuzzy']?['avg'] ?? 0, + }, + }; + } + + /// Clear all metrics (for testing or privacy) + void clearMetrics() { + _performanceData.clear(); + } + + /// Check if performance is within acceptable limits + bool isPerformanceHealthy() { + final stats = getPerformanceStats(); + + // Check anchoring performance + final anchoringAvg = stats['anchoring_anchored']?['avg'] ?? 0; + if (anchoringAvg > AnchoringConstants.maxReanchoringTimeMs) { + return false; + } + + // Check search performance + final searchAvg = stats['search_performance']?['avg'] ?? 0; + if (searchAvg > 200) { // 200ms threshold for search + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/lib/notes/services/performance_optimizer.dart b/lib/notes/services/performance_optimizer.dart new file mode 100644 index 000000000..d19433cde --- /dev/null +++ b/lib/notes/services/performance_optimizer.dart @@ -0,0 +1,328 @@ +import 'dart:async'; +import '../services/notes_telemetry.dart'; +import '../config/notes_config.dart'; +import '../data/notes_data_provider.dart'; + +/// Service for optimizing notes system performance +class PerformanceOptimizer { + static PerformanceOptimizer? _instance; + Timer? _optimizationTimer; + DateTime? _lastOptimization; + + PerformanceOptimizer._(); + + /// Singleton instance + static PerformanceOptimizer get instance { + _instance ??= PerformanceOptimizer._(); + return _instance!; + } + + /// Start automatic performance optimization + void startAutoOptimization() { + if (_optimizationTimer?.isActive == true) return; + + _optimizationTimer = Timer.periodic( + const Duration(hours: 1), // Run every hour + (_) => _runOptimizationCycle(), + ); + } + + /// Stop automatic performance optimization + void stopAutoOptimization() { + _optimizationTimer?.cancel(); + _optimizationTimer = null; + } + + /// Run a complete optimization cycle + Future runOptimizationCycle() async { + return await _runOptimizationCycle(); + } + + /// Internal optimization cycle + Future _runOptimizationCycle() async { + final stopwatch = Stopwatch()..start(); + final results = {}; + + try { + // 1. Database optimization + final dbResult = await _optimizeDatabase(); + results['database'] = dbResult; + + // 2. Cache optimization + final cacheResult = await _optimizeCache(); + results['cache'] = cacheResult; + + // 3. Index optimization + final indexResult = await _optimizeSearchIndex(); + results['search_index'] = indexResult; + + // 4. Memory optimization + final memoryResult = await _optimizeMemory(); + results['memory'] = memoryResult; + + // 5. Performance analysis + final analysisResult = await _analyzePerformance(); + results['analysis'] = analysisResult; + + _lastOptimization = DateTime.now(); + + NotesTelemetry.trackPerformanceMetric('optimization_cycle', stopwatch.elapsed); + + return OptimizationResult( + success: true, + duration: stopwatch.elapsed, + results: results, + recommendations: _generateRecommendations(results), + ); + } catch (e) { + return OptimizationResult( + success: false, + duration: stopwatch.elapsed, + error: e.toString(), + results: results, + recommendations: ['שגיאה בתהליך האופטימיזציה: $e'], + ); + } + } + + /// Optimize database performance + Future> _optimizeDatabase() async { + final db = await NotesDataProvider.instance.database; + final results = {}; + + try { + // Run VACUUM to reclaim space + await db.execute('VACUUM;'); + results['vacuum'] = 'completed'; + + // Update statistics + await db.execute('ANALYZE;'); + results['analyze'] = 'completed'; + + // Check database size + final sizeResult = await db.rawQuery('PRAGMA page_count;'); + final pageCount = sizeResult.first['page_count'] as int; + results['page_count'] = pageCount; + results['estimated_size_mb'] = (pageCount * 4096 / 1024 / 1024).toStringAsFixed(2); + + // Check fragmentation + final fragmentResult = await db.rawQuery('PRAGMA freelist_count;'); + final freePages = fragmentResult.first['freelist_count'] as int; + results['free_pages'] = freePages; + results['fragmentation_percent'] = ((freePages / pageCount) * 100).toStringAsFixed(2); + + } catch (e) { + results['error'] = e.toString(); + } + + return results; + } + + /// Optimize cache performance + Future> _optimizeCache() async { + final results = {}; + + try { + // Clear expired cache entries (if we had a cache system) + results['cache_cleared'] = 'simulated'; + + // Memory usage estimation + final telemetryStats = NotesTelemetry.instance.getPerformanceStats(); + results['telemetry_entries'] = telemetryStats.length; + + // Clear old telemetry data if too much + if (telemetryStats.length > 1000) { + NotesTelemetry.instance.clearMetrics(); + results['telemetry_cleared'] = true; + } + + } catch (e) { + results['error'] = e.toString(); + } + + return results; + } + + /// Optimize search index + Future> _optimizeSearchIndex() async { + final db = await NotesDataProvider.instance.database; + final results = {}; + + try { + // Rebuild FTS index + await db.execute('INSERT INTO notes_fts(notes_fts) VALUES(\'rebuild\');'); + results['fts_rebuild'] = 'completed'; + + // Optimize FTS index + await db.execute('INSERT INTO notes_fts(notes_fts) VALUES(\'optimize\');'); + results['fts_optimize'] = 'completed'; + + } catch (e) { + results['error'] = e.toString(); + } + + return results; + } + + /// Optimize memory usage + Future> _optimizeMemory() async { + final results = {}; + + try { + // Force garbage collection (Dart will do this automatically, but we can suggest it) + results['gc_suggested'] = true; + + // Check telemetry memory usage + final stats = NotesTelemetry.instance.getPerformanceStats(); + final memoryEstimate = stats.length * 100; // Rough estimate + results['telemetry_memory_bytes'] = memoryEstimate; + + if (memoryEstimate > 1024 * 1024) { // > 1MB + results['recommendation'] = 'Consider clearing telemetry data'; + } + + } catch (e) { + results['error'] = e.toString(); + } + + return results; + } + + /// Analyze current performance + Future> _analyzePerformance() async { + final results = {}; + + try { + final telemetryStats = NotesTelemetry.instance.getPerformanceStats(); + final aggregated = NotesTelemetry.instance.getAggregatedMetrics(); + final isHealthy = NotesTelemetry.instance.isPerformanceHealthy(); + + results['health_status'] = isHealthy ? 'healthy' : 'needs_attention'; + results['metrics_count'] = telemetryStats.length; + + // Analyze anchoring performance + final anchoring = aggregated['anchoring_performance'] as Map? ?? {}; + final anchoredAvg = anchoring['anchored_avg_ms'] ?? 0; + + if (anchoredAvg > AnchoringConstants.maxReanchoringTimeMs) { + results['anchoring_warning'] = 'Average anchoring time exceeds threshold'; + } + + // Analyze search performance + final search = aggregated['search_performance'] as Map? ?? {}; + final searchAvg = search['avg_ms'] ?? 0; + + if (searchAvg > 200) { + results['search_warning'] = 'Search performance is slow'; + } + + } catch (e) { + results['error'] = e.toString(); + } + + return results; + } + + /// Generate optimization recommendations + List _generateRecommendations(Map results) { + final recommendations = []; + + // Database recommendations + final dbResults = results['database'] as Map? ?? {}; + final fragmentation = double.tryParse(dbResults['fragmentation_percent']?.toString() ?? '0') ?? 0; + + if (fragmentation > 10) { + recommendations.add('רמת פיצול גבוהה במסד הנתונים (${fragmentation.toStringAsFixed(1)}%) - הרץ VACUUM'); + } + + final sizeMb = double.tryParse(dbResults['estimated_size_mb']?.toString() ?? '0') ?? 0; + if (sizeMb > 100) { + recommendations.add('מסד הנתונים גדול (${sizeMb.toStringAsFixed(1)}MB) - שקול ארכוב הערות ישנות'); + } + + // Performance recommendations + final analysisResults = results['analysis'] as Map? ?? {}; + if (analysisResults['health_status'] == 'needs_attention') { + recommendations.add('ביצועי המערכת דורשים תשומת לב - בדוק מדדי ביצועים'); + } + + if (analysisResults.containsKey('anchoring_warning')) { + recommendations.add('ביצועי עיגון איטיים - שקול להפחית batch size'); + } + + if (analysisResults.containsKey('search_warning')) { + recommendations.add('ביצועי חיפוש איטיים - שקול לבנות מחדש את אינדקס החיפוש'); + } + + // Memory recommendations + final memoryResults = results['memory'] as Map? ?? {}; + final memoryBytes = memoryResults['telemetry_memory_bytes'] as int? ?? 0; + + if (memoryBytes > 5 * 1024 * 1024) { // > 5MB + recommendations.add('שימוש גבוה בזיכרון עבור טלמטריה - נקה נתונים ישנים'); + } + + if (recommendations.isEmpty) { + recommendations.add('המערכת פועלת בצורה אופטימלית'); + } + + return recommendations; + } + + /// Get optimization status + OptimizationStatus getOptimizationStatus() { + final isRunning = _optimizationTimer?.isActive == true; + final nextRun = isRunning && _lastOptimization != null + ? _lastOptimization!.add(const Duration(hours: 1)) + : null; + + return OptimizationStatus( + isAutoOptimizationEnabled: isRunning, + lastOptimization: _lastOptimization, + nextOptimization: nextRun, + isHealthy: NotesTelemetry.instance.isPerformanceHealthy(), + ); + } + + /// Force immediate optimization + Future forceOptimization() async { + return await _runOptimizationCycle(); + } + + /// Clean up resources + void dispose() { + stopAutoOptimization(); + } +} + +/// Result of optimization operation +class OptimizationResult { + final bool success; + final Duration duration; + final Map results; + final List recommendations; + final String? error; + + const OptimizationResult({ + required this.success, + required this.duration, + required this.results, + required this.recommendations, + this.error, + }); +} + +/// Status of optimization system +class OptimizationStatus { + final bool isAutoOptimizationEnabled; + final DateTime? lastOptimization; + final DateTime? nextOptimization; + final bool isHealthy; + + const OptimizationStatus({ + required this.isAutoOptimizationEnabled, + this.lastOptimization, + this.nextOptimization, + required this.isHealthy, + }); +} \ No newline at end of file diff --git a/lib/notes/services/search_index.dart b/lib/notes/services/search_index.dart new file mode 100644 index 000000000..2ebff482f --- /dev/null +++ b/lib/notes/services/search_index.dart @@ -0,0 +1,326 @@ +import '../models/anchor_models.dart'; +import '../config/notes_config.dart'; + +/// Fast search index for canonical documents with O(1) hash lookups. +/// +/// This service creates and manages high-performance indexes for canonical +/// documents, enabling fast lookups during the anchoring process. It uses +/// hash-based indexes to achieve O(1) average lookup time. +/// +/// ## Index Types +/// +/// ### 1. Text Hash Index +/// - **Key**: SHA-256 hash of normalized text chunks +/// - **Value**: Set of character positions where the text appears +/// - **Use**: Exact text matching (primary anchoring strategy) +/// +/// ### 2. Context Hash Index +/// - **Key**: SHA-256 hash of context windows (before/after text) +/// - **Value**: Set of character positions for context centers +/// - **Use**: Context-based matching when text changes slightly +/// +/// ### 3. Rolling Hash Index +/// - **Key**: Polynomial rolling hash of sliding windows +/// - **Value**: Set of character positions for window starts +/// - **Use**: Fast sliding window operations and fuzzy matching +/// +/// ## Performance Characteristics +/// +/// - **Build time**: O(n) where n is document length +/// - **Lookup time**: O(1) average, O(k) worst case (k = collision count) +/// - **Memory usage**: ~2-3x document size for all indexes +/// - **Update time**: O(1) for incremental updates +/// +/// ## Usage +/// +/// ```dart +/// final index = SearchIndex(); +/// +/// // Build indexes from canonical document +/// index.buildIndex(canonicalDocument); +/// +/// // Fast lookups during anchoring +/// final positions = index.findByTextHash(textHash); +/// final contextPositions = index.findByContextHash(contextHash); +/// final rollingPositions = index.findByRollingHash(rollingHash); +/// +/// // Check if indexes are ready +/// if (index.isBuilt) { +/// // Perform searches +/// } +/// ``` +/// +/// ## Index Building Strategy +/// +/// The index building process: +/// +/// 1. **Text Hash Index**: Slide window of various sizes, hash each chunk +/// 2. **Context Index**: Extract context windows around each position +/// 3. **Rolling Hash Index**: Use sliding window with polynomial hash +/// +/// ## Memory Management +/// +/// - Indexes use `Set` for position storage (efficient for duplicates) +/// - Hash collisions are handled gracefully with multiple positions +/// - Indexes can be cleared and rebuilt as needed +/// - No persistent storage - rebuilt from canonical documents +/// +/// ## Thread Safety +/// +/// - Index building is not thread-safe (single-threaded operation) +/// - Lookups are thread-safe after building is complete +/// - Use separate instances for concurrent operations +class SearchIndex { + final Map> _textHashIndex = {}; + final Map> _contextIndex = {}; + final Map> _rollingHashIndex = {}; + + bool _isBuilt = false; + + /// Build indexes from a canonical document + void buildIndex(CanonicalDocument document) { + _textHashIndex.clear(); + _contextIndex.clear(); + _rollingHashIndex.clear(); + + _buildTextHashIndex(document); + _buildContextIndex(document); + _buildRollingHashIndex(document); + + _isBuilt = true; + } + + /// Build text hash index from document + void _buildTextHashIndex(CanonicalDocument document) { + for (final entry in document.textHashIndex.entries) { + final hash = entry.key; + final positions = entry.value; + + _textHashIndex[hash] = positions.toSet(); + } + } + + /// Build context index from document + void _buildContextIndex(CanonicalDocument document) { + for (final entry in document.contextHashIndex.entries) { + final hash = entry.key; + final positions = entry.value; + + _contextIndex[hash] = positions.toSet(); + } + } + + /// Build rolling hash index from document + void _buildRollingHashIndex(CanonicalDocument document) { + for (final entry in document.rollingHashIndex.entries) { + final hash = entry.key; + final positions = entry.value; + + _rollingHashIndex[hash] = positions.toSet(); + } + } + + /// Find positions by text hash + List findByTextHash(String hash) { + _ensureBuilt(); + return (_textHashIndex[hash] ?? const {}).toList(); + } + + /// Find positions by context hash (before and after) + List findByContextHash(String beforeHash, String afterHash) { + _ensureBuilt(); + + final beforePositions = _contextIndex[beforeHash] ?? const {}; + final afterPositions = _contextIndex[afterHash] ?? const {}; + + return beforePositions.intersection(afterPositions).toList(); + } + + /// Find positions by single context hash + List findBySingleContextHash(String contextHash) { + _ensureBuilt(); + return (_contextIndex[contextHash] ?? const {}).toList(); + } + + /// Find positions by rolling hash + List findByRollingHash(int hash) { + _ensureBuilt(); + return (_rollingHashIndex[hash] ?? const {}).toList(); + } + + /// Find positions where before and after contexts are within distance + List findByContextProximity( + String beforeHash, + String afterHash, { + int maxDistance = AnchoringConstants.maxContextDistance, + }) { + _ensureBuilt(); + + final beforePositions = _contextIndex[beforeHash] ?? const {}; + final afterPositions = _contextIndex[afterHash] ?? const {}; + + final matches = []; + + for (final beforePos in beforePositions) { + for (final afterPos in afterPositions) { + final distance = (afterPos - beforePos).abs(); + if (distance <= maxDistance) { + // Use the position that's more likely to be the actual match + final matchPos = beforePos < afterPos ? beforePos : afterPos; + if (!matches.contains(matchPos)) { + matches.add(matchPos); + } + } + } + } + + return matches..sort(); + } + + /// Get all unique text hashes in the index + Set getAllTextHashes() { + _ensureBuilt(); + return _textHashIndex.keys.toSet(); + } + + /// Get all unique context hashes in the index + Set getAllContextHashes() { + _ensureBuilt(); + return _contextIndex.keys.toSet(); + } + + /// Get all unique rolling hashes in the index + Set getAllRollingHashes() { + _ensureBuilt(); + return _rollingHashIndex.keys.toSet(); + } + + /// Get statistics about the index + Map getIndexStats() { + return { + 'text_hash_entries': _textHashIndex.length, + 'context_hash_entries': _contextIndex.length, + 'rolling_hash_entries': _rollingHashIndex.length, + 'total_text_positions': _textHashIndex.values + .fold(0, (sum, positions) => sum + positions.length), + 'total_context_positions': _contextIndex.values + .fold(0, (sum, positions) => sum + positions.length), + 'total_rolling_positions': _rollingHashIndex.values + .fold(0, (sum, positions) => sum + positions.length), + }; + } + + /// Check if the index has been built + bool get isBuilt => _isBuilt; + + /// Clear all indexes + void clear() { + _textHashIndex.clear(); + _contextIndex.clear(); + _rollingHashIndex.clear(); + _isBuilt = false; + } + + /// Ensure the index has been built before use + void _ensureBuilt() { + if (!_isBuilt) { + throw StateError('SearchIndex must be built before use. Call buildIndex() first.'); + } + } + + /// Merge results from multiple hash lookups + List mergeResults(List> resultSets, {bool requireAll = false}) { + if (resultSets.isEmpty) return []; + if (resultSets.length == 1) return resultSets.first; + + if (requireAll) { + // Intersection - position must appear in all result sets + Set intersection = resultSets.first.toSet(); + for (int i = 1; i < resultSets.length; i++) { + intersection = intersection.intersection(resultSets[i].toSet()); + } + return intersection.toList()..sort(); + } else { + // Union - position appears in any result set + final union = {}; + for (final results in resultSets) { + union.addAll(results); + } + return union.toList()..sort(); + } + } + + /// Find the best matches by combining multiple search strategies + List findBestMatches( + String textHash, + String beforeHash, + String afterHash, + int rollingHash, + ) { + _ensureBuilt(); + + final matches = []; + + // Exact text hash matches (highest priority) + final exactMatches = findByTextHash(textHash); + for (final pos in exactMatches) { + matches.add(SearchMatch( + position: pos, + score: 1.0, + strategy: 'exact_text', + )); + } + + // Context proximity matches (medium priority) + final contextMatches = findByContextProximity(beforeHash, afterHash); + for (final pos in contextMatches) { + // Avoid duplicates from exact matches + if (!exactMatches.contains(pos)) { + matches.add(SearchMatch( + position: pos, + score: 0.8, + strategy: 'context_proximity', + )); + } + } + + // Rolling hash matches (lower priority) + final rollingMatches = findByRollingHash(rollingHash); + for (final pos in rollingMatches) { + // Avoid duplicates + if (!exactMatches.contains(pos) && !contextMatches.contains(pos)) { + matches.add(SearchMatch( + position: pos, + score: 0.6, + strategy: 'rolling_hash', + )); + } + } + + // Sort by score (highest first) then by position + matches.sort((a, b) { + final scoreComparison = b.score.compareTo(a.score); + return scoreComparison != 0 ? scoreComparison : a.position.compareTo(b.position); + }); + + return matches; + } +} + +/// Represents a search match with position and confidence score +class SearchMatch { + final int position; + final double score; + final String strategy; + + const SearchMatch({ + required this.position, + required this.score, + required this.strategy, + }); + + @override + String toString() { + return 'SearchMatch(pos: $position, score: ${score.toStringAsFixed(2)}, strategy: $strategy)'; + } +} \ No newline at end of file diff --git a/lib/notes/services/smart_batch_processor.dart b/lib/notes/services/smart_batch_processor.dart new file mode 100644 index 000000000..b69817155 --- /dev/null +++ b/lib/notes/services/smart_batch_processor.dart @@ -0,0 +1,343 @@ +import 'dart:async'; +import 'dart:math'; +import '../models/note.dart'; +import '../models/anchor_models.dart'; +import '../services/background_processor.dart'; +import '../services/notes_telemetry.dart'; +import '../config/notes_config.dart'; + +/// Smart batch processor that adapts batch sizes based on performance +class SmartBatchProcessor { + static SmartBatchProcessor? _instance; + final BackgroundProcessor _backgroundProcessor = BackgroundProcessor.instance; + + // Adaptive batch sizing + int _currentBatchSize = 50; + int _minBatchSize = 10; + int _maxBatchSize = NotesConfig.maxReanchoringBatchSize; + + // Performance tracking + final List _performanceHistory = []; + static const int _maxHistorySize = 20; + + // Load balancing + int _activeProcesses = 0; + final int _maxConcurrentProcesses = 3; + + SmartBatchProcessor._(); + + /// Singleton instance + static SmartBatchProcessor get instance { + _instance ??= SmartBatchProcessor._(); + return _instance!; + } + + /// Process notes in smart batches with adaptive sizing + Future> processNotesInSmartBatches( + List notes, + CanonicalDocument document, { + BatchProcessingOptions? options, + }) async { + final opts = options ?? const BatchProcessingOptions(); + final stopwatch = Stopwatch()..start(); + final allResults = []; + + try { + // Prioritize notes by importance + final prioritizedNotes = _prioritizeNotes(notes, opts); + + // Calculate optimal batch size + final batchSize = _calculateOptimalBatchSize(notes.length); + + // Process in batches + final batches = _createBatches(prioritizedNotes, batchSize); + + for (int i = 0; i < batches.length; i++) { + final batch = batches[i]; + final batchStopwatch = Stopwatch()..start(); + + // Wait for available processing slot + await _waitForProcessingSlot(); + + try { + _activeProcesses++; + + // Process batch + final batchResults = await _processBatch( + batch, + document, + i + 1, + batches.length, + ); + + allResults.addAll(batchResults); + + // Record performance metrics + _recordBatchPerformance(BatchPerformanceMetric( + batchSize: batch.length, + duration: batchStopwatch.elapsed, + successRate: batchResults.where((r) => r.isSuccess).length / batch.length, + memoryUsage: _estimateMemoryUsage(batch), + )); + + // Adapt batch size based on performance + _adaptBatchSize(batchStopwatch.elapsed, batch.length); + + // Yield control to prevent UI blocking + if (opts.yieldBetweenBatches) { + await Future.delayed(const Duration(milliseconds: 10)); + } + + } finally { + _activeProcesses--; + } + } + + // Track overall performance + final successCount = allResults.where((r) => r.isSuccess).length; + NotesTelemetry.trackBatchReanchoring( + 'smart_batch_${DateTime.now().millisecondsSinceEpoch}', + notes.length, + successCount, + stopwatch.elapsed, + ); + + return allResults; + + } catch (e) { + NotesTelemetry.trackPerformanceMetric('smart_batch_error', stopwatch.elapsed); + rethrow; + } + } + + /// Prioritize notes based on various factors + List _prioritizeNotes(List notes, BatchProcessingOptions options) { + final prioritized = notes.toList(); + + prioritized.sort((a, b) { + double scoreA = _calculateNotePriority(a, options); + double scoreB = _calculateNotePriority(b, options); + + return scoreB.compareTo(scoreA); // Higher score first + }); + + return prioritized; + } + + /// Calculate priority score for a note + double _calculateNotePriority(Note note, BatchProcessingOptions options) { + double score = 0.0; + + // Status priority + switch (note.status) { + case NoteStatus.anchored: + score += 1.0; // Lowest priority - already anchored + break; + case NoteStatus.shifted: + score += 3.0; // Medium priority - needs re-anchoring + break; + case NoteStatus.orphan: + score += 5.0; // Highest priority - needs attention + break; + } + + // Age factor (newer notes get higher priority) + final age = DateTime.now().difference(note.updatedAt).inDays; + score += max(0, 30 - age) / 30.0 * 2.0; + + // Content length factor (longer notes get slightly higher priority) + final contentLength = note.contentMarkdown.length; + score += min(contentLength / 1000.0, 1.0); + + // User priority (if specified in options) + if (options.priorityTags.isNotEmpty) { + final hasHighPriorityTag = note.tags.any((tag) => options.priorityTags.contains(tag)); + if (hasHighPriorityTag) { + score += 2.0; + } + } + + return score; + } + + /// Calculate optimal batch size based on current performance + int _calculateOptimalBatchSize(int totalNotes) { + if (_performanceHistory.isEmpty) { + return min(_currentBatchSize, totalNotes); + } + + // Analyze recent performance + final recentMetrics = _performanceHistory.take(5).toList(); + final avgDuration = recentMetrics.map((m) => m.duration.inMilliseconds).reduce((a, b) => a + b) / recentMetrics.length; + final avgSuccessRate = recentMetrics.map((m) => m.successRate).reduce((a, b) => a + b) / recentMetrics.length; + + // Adjust batch size based on performance + if (avgDuration > AnchoringConstants.maxReanchoringTimeMs * 2 && _currentBatchSize > _minBatchSize) { + // Too slow, reduce batch size + _currentBatchSize = max(_minBatchSize, (_currentBatchSize * 0.8).round()); + } else if (avgDuration < AnchoringConstants.maxReanchoringTimeMs && avgSuccessRate > 0.9 && _currentBatchSize < _maxBatchSize) { + // Good performance, can increase batch size + _currentBatchSize = min(_maxBatchSize, (_currentBatchSize * 1.2).round()); + } + + return min(_currentBatchSize, totalNotes); + } + + /// Create batches from notes list + List> _createBatches(List notes, int batchSize) { + final batches = >[]; + + for (int i = 0; i < notes.length; i += batchSize) { + final end = min(i + batchSize, notes.length); + batches.add(notes.sublist(i, end)); + } + + return batches; + } + + /// Wait for available processing slot + Future _waitForProcessingSlot() async { + while (_activeProcesses >= _maxConcurrentProcesses) { + await Future.delayed(const Duration(milliseconds: 100)); + } + } + + /// Process a single batch + Future> _processBatch( + List batch, + CanonicalDocument document, + int batchNumber, + int totalBatches, + ) async { + // Log batch processing if enabled + NotesTelemetry.trackPerformanceMetric( + 'batch_processing_start', + Duration.zero, + ); + + return await _backgroundProcessor.processReanchoring(batch, document); + } + + /// Record batch performance metrics + void _recordBatchPerformance(BatchPerformanceMetric metric) { + _performanceHistory.insert(0, metric); + + // Keep only recent history + if (_performanceHistory.length > _maxHistorySize) { + _performanceHistory.removeRange(_maxHistorySize, _performanceHistory.length); + } + } + + /// Adapt batch size based on performance + void _adaptBatchSize(Duration duration, int batchSize) { + final durationMs = duration.inMilliseconds; + final targetDurationMs = AnchoringConstants.maxReanchoringTimeMs; + + if (durationMs > targetDurationMs * 1.5) { + // Too slow, reduce batch size + _currentBatchSize = max(_minBatchSize, (batchSize * 0.8).round()); + } else if (durationMs < targetDurationMs * 0.5) { + // Fast enough, can increase batch size + _currentBatchSize = min(_maxBatchSize, (batchSize * 1.2).round()); + } + } + + /// Estimate memory usage for a batch + int _estimateMemoryUsage(List batch) { + // Rough estimation: each note takes about 1KB in memory + return batch.length * 1024; + } + + /// Get current batch processing statistics + BatchProcessingStats getProcessingStats() { + final recentMetrics = _performanceHistory.take(10).toList(); + + double avgDuration = 0; + double avgSuccessRate = 0; + int totalProcessed = 0; + + if (recentMetrics.isNotEmpty) { + avgDuration = recentMetrics.map((m) => m.duration.inMilliseconds).reduce((a, b) => a + b) / recentMetrics.length; + avgSuccessRate = recentMetrics.map((m) => m.successRate).reduce((a, b) => a + b) / recentMetrics.length; + totalProcessed = recentMetrics.map((m) => m.batchSize).reduce((a, b) => a + b); + } + + return BatchProcessingStats( + currentBatchSize: _currentBatchSize, + activeProcesses: _activeProcesses, + avgDurationMs: avgDuration.round(), + avgSuccessRate: avgSuccessRate, + totalProcessedRecently: totalProcessed, + performanceHistory: List.from(_performanceHistory), + ); + } + + /// Reset batch size to default + void resetBatchSize() { + _currentBatchSize = 50; + _performanceHistory.clear(); + } + + /// Set custom batch size limits + void setBatchSizeLimits({int? minSize, int? maxSize}) { + if (minSize != null && minSize > 0) { + _minBatchSize = minSize; + } + if (maxSize != null && maxSize > _minBatchSize) { + _maxBatchSize = maxSize; + } + + // Adjust current batch size if needed + _currentBatchSize = _currentBatchSize.clamp(_minBatchSize, _maxBatchSize); + } +} + +/// Options for batch processing +class BatchProcessingOptions { + final List priorityTags; + final bool yieldBetweenBatches; + final int? maxConcurrentBatches; + final Duration? timeoutPerBatch; + + const BatchProcessingOptions({ + this.priorityTags = const [], + this.yieldBetweenBatches = true, + this.maxConcurrentBatches, + this.timeoutPerBatch, + }); +} + +/// Performance metric for a single batch +class BatchPerformanceMetric { + final int batchSize; + final Duration duration; + final double successRate; + final int memoryUsage; + final DateTime timestamp; + + BatchPerformanceMetric({ + required this.batchSize, + required this.duration, + required this.successRate, + required this.memoryUsage, + }) : timestamp = DateTime.now(); +} + +/// Statistics for batch processing +class BatchProcessingStats { + final int currentBatchSize; + final int activeProcesses; + final int avgDurationMs; + final double avgSuccessRate; + final int totalProcessedRecently; + final List performanceHistory; + + const BatchProcessingStats({ + required this.currentBatchSize, + required this.activeProcesses, + required this.avgDurationMs, + required this.avgSuccessRate, + required this.totalProcessedRecently, + required this.performanceHistory, + }); +} \ No newline at end of file diff --git a/lib/notes/services/text_normalizer.dart b/lib/notes/services/text_normalizer.dart new file mode 100644 index 000000000..fca20947f --- /dev/null +++ b/lib/notes/services/text_normalizer.dart @@ -0,0 +1,212 @@ +import '../config/notes_config.dart'; +import '../utils/text_utils.dart'; + +/// Service for normalizing text to ensure consistent hashing and matching. +/// +/// Text normalization is critical for the anchoring system to work reliably +/// across different text versions. This service applies deterministic +/// transformations to create stable, comparable text representations. +/// +/// ## Normalization Steps +/// +/// The normalization process follows a specific order: +/// +/// 1. **Unicode Normalization**: Apply NFKC or NFC normalization +/// 2. **Quote Normalization**: Convert various quote marks to ASCII +/// 3. **Nikud Handling**: Remove or preserve Hebrew vowel points +/// 4. **Whitespace Normalization**: Standardize spacing and line breaks +/// 5. **RTL Marker Cleanup**: Remove directional formatting characters +/// +/// ## Configuration-Driven +/// +/// Normalization behavior is controlled by [NormalizationConfig]: +/// - `unicodeForm`: NFKC (default) or NFC normalization +/// - `removeNikud`: Whether to remove Hebrew vowel points +/// - `quoteStyle`: ASCII (default) or preserve original quotes +/// +/// ## Deterministic Output +/// +/// The same input text with the same configuration will always produce +/// identical output, ensuring hash stability across sessions. +/// +/// ## Usage +/// +/// ```dart +/// // Create configuration from user settings +/// final config = TextNormalizer.createConfigFromSettings(); +/// +/// // Normalize text for hashing +/// final normalized = TextNormalizer.normalize(rawText, config); +/// +/// // Get configuration string for storage +/// final configStr = TextNormalizer.configToString(config); +/// ``` +/// +/// ## Performance +/// +/// - Time complexity: O(n) where n is text length +/// - Memory usage: Creates new string, original unchanged +/// - Optimized for Hebrew and RTL text processing +/// +/// ## Hebrew & RTL Support +/// +/// Special handling for Hebrew text: +/// - Nikud (vowel points) removal/preservation +/// - Hebrew quote marks (״׳) normalization +/// - RTL/LTR embedding character cleanup +/// - Grapheme cluster awareness for complex scripts +class TextNormalizer { + /// Map of Unicode quote characters to ASCII equivalents + static final Map _quoteMap = { + '\u201C': '"', '\u201D': '"', // " " + '\u201E': '"', '\u00AB': '"', '\u00BB': '"', // „ « » + '\u2018': "'", '\u2019': "'", // ' ' + '\u05F4': '"', '\u05F3': "'", // ״ ׳ (Hebrew) + }; + + /// Normalize text according to the given configuration (deterministic) + static String normalize(String text, NormalizationConfig config) { + // Step 1: Apply Unicode normalization first (NFKC) + switch (config.unicodeForm) { + case 'NFKC': + text = _basicUnicodeNormalization(text); + break; + case 'NFC': + text = _basicUnicodeNormalization(text); + break; + } + + // Step 2: Remove directional marks (LTR/RTL marks, embedding controls) + text = text.replaceAll(RegExp(r'[\u200E\u200F\u202A-\u202E]'), ''); + + // Step 3: Handle Zero-Width Joiner and Non-Joiner + text = text.replaceAll(RegExp(r'[\u200C\u200D]'), ''); + + // Step 4: Normalize punctuation (quotes and apostrophes) + _quoteMap.forEach((from, to) { + text = text.replaceAll(from, to); + }); + + // Step 5: Collapse multiple whitespace to single space + text = text.replaceAll(RegExp(r'\s+'), ' '); + + // Step 6: Handle nikud (vowel points) based on configuration + if (config.removeNikud) { + text = TextUtils.removeNikud(text); + } + + // Step 7: Trim whitespace + return text.trim(); + } + + /// Basic Unicode normalization (simplified implementation) + static String _basicUnicodeNormalization(String text) { + // This is a simplified implementation + // In a production system, you might want to use a proper Unicode normalization library + return text + .replaceAll(RegExp(r'[\u0300-\u036F]'), '') // Remove combining diacritical marks + .replaceAll(RegExp(r'[\uFE00-\uFE0F]'), '') // Remove variation selectors + .replaceAll(RegExp(r'[\u200B-\u200D]'), ''); // Remove zero-width characters + } + + /// Create a normalization configuration from current settings + static NormalizationConfig createConfigFromSettings() { + // This would typically read from app settings + // For now, we'll use defaults + return const NormalizationConfig( + removeNikud: false, // This should come from Settings + quoteStyle: 'ascii', + unicodeForm: 'NFKC', + ); + } + + /// Validate that text normalization is stable + static bool validateNormalization(String text, NormalizationConfig config) { + final normalized1 = normalize(text, config); + final normalized2 = normalize(normalized1, config); + return normalized1 == normalized2; + } + + /// Extract context window around a text selection + static ContextWindow extractContextWindow( + String text, + int start, + int end, { + int windowSize = AnchoringConstants.contextWindowSize, + }) { + final beforeStart = (start - windowSize).clamp(0, text.length); + final afterEnd = (end + windowSize).clamp(0, text.length); + + final before = text.substring(beforeStart, start); + final after = text.substring(end, afterEnd); + final selected = text.substring(start, end); + + return ContextWindow( + before: before, + selected: selected, + after: after, + beforeStart: beforeStart, + selectedStart: start, + selectedEnd: end, + afterEnd: afterEnd, + ); + } + + /// Normalize context window text + static ContextWindow normalizeContextWindow( + ContextWindow window, + NormalizationConfig config, + ) { + return ContextWindow( + before: normalize(window.before, config), + selected: normalize(window.selected, config), + after: normalize(window.after, config), + beforeStart: window.beforeStart, + selectedStart: window.selectedStart, + selectedEnd: window.selectedEnd, + afterEnd: window.afterEnd, + ); + } +} + +/// Represents a context window around selected text +class ContextWindow { + /// Text before the selection + final String before; + + /// The selected text + final String selected; + + /// Text after the selection + final String after; + + /// Character position where 'before' starts + final int beforeStart; + + /// Character position where selection starts + final int selectedStart; + + /// Character position where selection ends + final int selectedEnd; + + /// Character position where 'after' ends + final int afterEnd; + + const ContextWindow({ + required this.before, + required this.selected, + required this.after, + required this.beforeStart, + required this.selectedStart, + required this.selectedEnd, + required this.afterEnd, + }); + + /// Total length of the context window + int get totalLength => before.length + selected.length + after.length; + + @override + String toString() { + return 'ContextWindow(before: "${before.length} chars", selected: "${selected.length} chars", after: "${after.length} chars")'; + } +} \ No newline at end of file diff --git a/lib/notes/utils/text_utils.dart b/lib/notes/utils/text_utils.dart new file mode 100644 index 000000000..b5f5efecc --- /dev/null +++ b/lib/notes/utils/text_utils.dart @@ -0,0 +1,231 @@ +import 'package:characters/characters.dart'; + +/// Utility functions for text processing with RTL and Hebrew support +class TextUtils { + /// Remove Hebrew nikud (vowel points) from text + static String removeNikud(String text) { + // Hebrew nikud Unicode ranges: + // U+0591-U+05BD, U+05BF, U+05C1-U+05C2, U+05C4-U+05C5, U+05C7 + return text.replaceAll(RegExp(r'[\u0591-\u05BD\u05BF\u05C1-\u05C2\u05C4-\u05C5\u05C7]'), ''); + } + + /// Check if text contains Hebrew characters + static bool containsHebrew(String text) { + return text.contains(RegExp(r'[\u0590-\u05FF]')); + } + + /// Check if text contains Arabic characters + static bool containsArabic(String text) { + return text.contains(RegExp(r'[\u0600-\u06FF]')); + } + + /// Check if text is right-to-left + static bool isRTL(String text) { + return containsHebrew(text) || containsArabic(text); + } + + /// Extract words from text (handles Hebrew and English) + static List extractWords(String text) { + // Split on whitespace and punctuation, but preserve Hebrew and English words + return text + .split(RegExp(r'[\s\p{P}]+', unicode: true)) + .where((word) => word.isNotEmpty) + .toList(); + } + + /// Calculate character-based edit distance (simplified Levenshtein) + static int levenshteinDistance(String a, String b) { + if (a.isEmpty) return b.length; + if (b.isEmpty) return a.length; + + final matrix = List.generate( + a.length + 1, + (i) => List.filled(b.length + 1, 0), + ); + + // Initialize first row and column + for (int i = 0; i <= a.length; i++) { + matrix[i][0] = i; + } + for (int j = 0; j <= b.length; j++) { + matrix[0][j] = j; + } + + // Fill the matrix + for (int i = 1; i <= a.length; i++) { + for (int j = 1; j <= b.length; j++) { + final cost = a[i - 1] == b[j - 1] ? 0 : 1; + matrix[i][j] = [ + matrix[i - 1][j] + 1, // deletion + matrix[i][j - 1] + 1, // insertion + matrix[i - 1][j - 1] + cost, // substitution + ].reduce((a, b) => a < b ? a : b); + } + } + + return matrix[a.length][b.length]; + } + + /// Calculate similarity ratio based on Levenshtein distance + static double calculateSimilarity(String a, String b) { + if (a.isEmpty && b.isEmpty) return 1.0; + if (a.isEmpty || b.isEmpty) return 0.0; + + final distance = levenshteinDistance(a, b); + final maxLength = a.length > b.length ? a.length : b.length; + + return 1.0 - (distance / maxLength); + } + + /// Generate n-grams from text + static List generateNGrams(String text, int n) { + if (text.length < n) return [text]; + + final ngrams = []; + for (int i = 0; i <= text.length - n; i++) { + ngrams.add(text.substring(i, i + n)); + } + return ngrams; + } + + /// Calculate Jaccard similarity using n-grams + static double calculateJaccardSimilarity(String a, String b, {int ngramSize = 3}) { + final ngramsA = generateNGrams(a, ngramSize).toSet(); + final ngramsB = generateNGrams(b, ngramSize).toSet(); + + if (ngramsA.isEmpty && ngramsB.isEmpty) return 1.0; + if (ngramsA.isEmpty || ngramsB.isEmpty) return 0.0; + + final intersection = ngramsA.intersection(ngramsB); + final union = ngramsA.union(ngramsB); + + return intersection.length / union.length; + } + + /// Calculate Cosine similarity using n-grams with frequency + static double calculateCosineSimilarity(String a, String b, {int ngramSize = 3}) { + Map freq(List grams) { + final m = {}; + for (final g in grams) { + m[g] = (m[g] ?? 0) + 1; + } + return m; + } + + final ga = generateNGrams(a, ngramSize); + final gb = generateNGrams(b, ngramSize); + final fa = freq(ga); + final fb = freq(gb); + final keys = {...fa.keys, ...fb.keys}; + + if (keys.isEmpty) return 1.0; + + double dot = 0, na = 0, nb = 0; + for (final k in keys) { + final va = (fa[k] ?? 0).toDouble(); + final vb = (fb[k] ?? 0).toDouble(); + dot += va * vb; + na += va * va; + nb += vb * vb; + } + + if (na == 0 || nb == 0) return 0.0; + return dot / (sqrt(na) * sqrt(nb)); + } + + /// Simple square root implementation + static double sqrt(double x) { + if (x < 0) return double.nan; + if (x == 0) return 0; + + double guess = x / 2; + double prev = 0; + + while ((guess - prev).abs() > 0.0001) { + prev = guess; + guess = (guess + x / guess) / 2; + } + + return guess; + } + + /// Slice text by grapheme clusters (safe for RTL and Hebrew with nikud) + static String sliceByGraphemes(String text, int start, int end) { + final characters = text.characters; + final length = characters.length; + + // Clamp indices to valid range + final safeStart = start.clamp(0, length); + final safeEnd = end.clamp(safeStart, length); + + return characters.skip(safeStart).take(safeEnd - safeStart).toString(); + } + + /// Get grapheme-aware length + static int getGraphemeLength(String text) { + return text.characters.length; + } + + /// Convert character index to grapheme index + static int charIndexToGraphemeIndex(String text, int charIndex) { + if (charIndex <= 0) return 0; + + final characters = text.characters; + int currentCharIndex = 0; + int graphemeIndex = 0; + + for (final char in characters) { + if (currentCharIndex >= charIndex) break; + currentCharIndex += char.length; + graphemeIndex++; + } + + return graphemeIndex; + } + + /// Convert grapheme index to character index + static int graphemeIndexToCharIndex(String text, int graphemeIndex) { + if (graphemeIndex <= 0) return 0; + + final characters = text.characters; + int charIndex = 0; + int currentGraphemeIndex = 0; + + for (final char in characters) { + if (currentGraphemeIndex >= graphemeIndex) break; + charIndex += char.length; + currentGraphemeIndex++; + } + + return charIndex; + } + + /// Truncate text to specified length with ellipsis (grapheme-aware) + static String truncate(String text, int maxLength, {String ellipsis = '...'}) { + final characters = text.characters; + if (characters.length <= maxLength) return text; + + final ellipsisLength = ellipsis.characters.length; + final truncateLength = maxLength - ellipsisLength; + + return characters.take(truncateLength).toString() + ellipsis; + } + + /// Clean text for display (remove excessive whitespace, control characters) + static String cleanForDisplay(String text) { + return text + .replaceAll(RegExp(r'\s+'), ' ') + .replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '') + .trim(); + } + + /// Highlight search terms in text (simple implementation) + static String highlightSearchTerms(String text, String searchTerm) { + if (searchTerm.isEmpty) return text; + + final regex = RegExp(RegExp.escape(searchTerm), caseSensitive: false); + return text.replaceAllMapped(regex, (match) { + return '${match.group(0)}'; + }); + } +} \ No newline at end of file diff --git a/lib/notes/widgets/note_editor_dialog.dart b/lib/notes/widgets/note_editor_dialog.dart new file mode 100644 index 000000000..bfbd7ac46 --- /dev/null +++ b/lib/notes/widgets/note_editor_dialog.dart @@ -0,0 +1,310 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../models/note.dart'; +import '../repository/notes_repository.dart'; + +/// Dialog for creating and editing notes +class NoteEditorDialog extends StatefulWidget { + final Note? existingNote; + final String? selectedText; + final String? bookId; + final int? charStart; + final int? charEnd; + final Function(CreateNoteRequest)? onSave; + final Function(String, UpdateNoteRequest)? onUpdate; + final VoidCallback? onDelete; + + const NoteEditorDialog({ + super.key, + this.existingNote, + this.selectedText, + this.bookId, + this.charStart, + this.charEnd, + this.onSave, + this.onUpdate, + this.onDelete, + }); + + @override + State createState() => _NoteEditorDialogState(); +} + +class _NoteEditorDialogState extends State { + late TextEditingController _contentController; + bool _isLoading = false; + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + + // Initialize controller with existing note data or defaults + _contentController = TextEditingController( + text: widget.existingNote?.contentMarkdown ?? '', + ); + } + + @override + void dispose() { + _contentController.dispose(); + super.dispose(); + } + + /// Check if this is an edit operation + bool get _isEditing => widget.existingNote != null; + + /// Get dialog title + String get _dialogTitle => _isEditing ? 'עריכת הערה' : 'הערה חדשה'; + + /// Handle save operation + Future _handleSave() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + try { + final content = _contentController.text.trim(); + + if (_isEditing) { + // Update existing note + final request = UpdateNoteRequest( + contentMarkdown: content, + privacy: NotePrivacy.private, // Always private + tags: [], // No tags + ); + + widget.onUpdate?.call(widget.existingNote!.id, request); + } else { + // Create new note + if (widget.bookId == null || widget.charStart == null || widget.charEnd == null) { + throw Exception('Missing required data for creating note'); + } + + final request = CreateNoteRequest( + bookId: widget.bookId!, + charStart: widget.charStart!, + charEnd: widget.charEnd!, + contentMarkdown: content, + authorUserId: 'default_user', // Default user for now + privacy: NotePrivacy.private, // Always private + tags: [], // No tags + ); + + widget.onSave?.call(request); + } + + if (mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('שגיאה בשמירת הערה: ${e.toString()}'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + /// Handle delete operation + Future _handleDelete() async { + if (!_isEditing) return; + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('מחיקת הערה'), + content: const Text('האם אתה בטוח שברצונך למחוק הערה זו?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('ביטול'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: const Text('מחק'), + ), + ], + ), + ); + + if (confirmed == true) { + widget.onDelete?.call(); + if (mounted) { + Navigator.of(context).pop(); + } + } + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Container( + width: 500, + constraints: const BoxConstraints( + maxHeight: 500, + minHeight: 300, + ), + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Row( + children: [ + Expanded( + child: Text( + _dialogTitle, + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + + const SizedBox(height: 16), + + // Selected text preview (for new notes) + if (!_isEditing && widget.selectedText != null) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'טקסט נבחר:', + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(height: 4), + Text( + widget.selectedText!, + style: Theme.of(context).textTheme.bodyMedium, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(height: 16), + ], + + // Content input - adaptive height + ConstrainedBox( + constraints: const BoxConstraints( + minHeight: 120, + maxHeight: 300, + ), + child: TextFormField( + controller: _contentController, + decoration: const InputDecoration( + labelText: 'תוכן ההערה', + hintText: 'כתוב את ההערה שלך כאן...', + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + maxLines: null, + minLines: 4, + textAlignVertical: TextAlignVertical.top, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'תוכן ההערה לא יכול להיות ריק'; + } + return null; + }, + inputFormatters: [ + LengthLimitingTextInputFormatter(32768), // Max note size + ], + ), + ), + + const SizedBox(height: 24), + + // Action buttons + Row( + children: [ + if (_isEditing) ...[ + TextButton.icon( + onPressed: _isLoading ? null : _handleDelete, + icon: const Icon(Icons.delete), + label: const Text('מחק'), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + ), + const Spacer(), + ] else + const Spacer(), + + TextButton( + onPressed: _isLoading ? null : () => Navigator.of(context).pop(), + child: const Text('ביטול'), + ), + + const SizedBox(width: 8), + + FilledButton( + onPressed: _isLoading ? null : _handleSave, + child: _isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(_isEditing ? 'עדכן' : 'שמור'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// Show note editor dialog +Future showNoteEditorDialog({ + required BuildContext context, + Note? existingNote, + String? selectedText, + String? bookId, + int? charStart, + int? charEnd, + Function(CreateNoteRequest)? onSave, + Function(String, UpdateNoteRequest)? onUpdate, + VoidCallback? onDelete, +}) { + return showDialog( + context: context, + builder: (context) => NoteEditorDialog( + existingNote: existingNote, + selectedText: selectedText, + bookId: bookId, + charStart: charStart, + charEnd: charEnd, + onSave: onSave, + onUpdate: onUpdate, + onDelete: onDelete, + ), + ); +} \ No newline at end of file diff --git a/lib/notes/widgets/note_highlight.dart b/lib/notes/widgets/note_highlight.dart new file mode 100644 index 000000000..410e8ae68 --- /dev/null +++ b/lib/notes/widgets/note_highlight.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; +import '../models/note.dart'; +import '../config/notes_config.dart'; + +/// Widget for highlighting text that has notes attached +class NoteHighlight extends StatefulWidget { + final Note note; + final Widget child; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final bool enabled; + + const NoteHighlight({ + super.key, + required this.note, + required this.child, + this.onTap, + this.onLongPress, + this.enabled = true, + }); + + @override + State createState() => _NoteHighlightState(); +} + +class _NoteHighlightState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _fadeAnimation; + bool _isHovered = false; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 200), + vsync: this, + ); + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + )); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + /// Get highlight color based on note status + Color _getHighlightColor(BuildContext context) { + if (!widget.enabled || !NotesConfig.highlightEnabled) { + return Colors.transparent; + } + + final colorScheme = Theme.of(context).colorScheme; + final baseColor = switch (widget.note.status) { + NoteStatus.anchored => colorScheme.primary, + NoteStatus.shifted => _getWarningColor(context), + NoteStatus.orphan => colorScheme.error, + }; + + final opacity = _isHovered ? 0.3 : 0.15; + return baseColor.withValues(alpha: opacity); + } + + /// Get warning color (orange-ish) + Color _getWarningColor(BuildContext context) { + return Theme.of(context).brightness == Brightness.dark + ? const Color(0xFFFF9800) + : const Color(0xFFFF6F00); + } + + /// Get status indicator color + Color _getStatusIndicatorColor(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return switch (widget.note.status) { + NoteStatus.anchored => colorScheme.primary, + NoteStatus.shifted => _getWarningColor(context), + NoteStatus.orphan => colorScheme.error, + }; + } + + /// Get status icon + IconData _getStatusIcon() { + return switch (widget.note.status) { + NoteStatus.anchored => Icons.check_circle, + NoteStatus.shifted => Icons.warning, + NoteStatus.orphan => Icons.error, + }; + } + + /// Get status tooltip text + String _getStatusTooltip() { + return switch (widget.note.status) { + NoteStatus.anchored => 'הערה מעוגנת במיקום מדויק', + NoteStatus.shifted => 'הערה מוזזת אך אותרה', + NoteStatus.orphan => 'הערה יתומה - נדרש אימות ידני', + }; + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) { + setState(() => _isHovered = true); + _animationController.forward(); + }, + onExit: (_) { + setState(() => _isHovered = false); + _animationController.reverse(); + }, + child: GestureDetector( + onTap: widget.onTap, + onLongPress: widget.onLongPress, + child: Container( + decoration: BoxDecoration( + color: _getHighlightColor(context), + borderRadius: BorderRadius.circular(2), + border: _isHovered + ? Border.all( + color: _getStatusIndicatorColor(context), + width: 1, + ) + : null, + ), + child: Stack( + children: [ + widget.child, + if (_isHovered && widget.enabled) + Positioned( + top: -2, + right: -2, + child: FadeTransition( + opacity: _fadeAnimation, + child: Tooltip( + message: _getStatusTooltip(), + child: Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: _getStatusIndicatorColor(context), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Icon( + _getStatusIcon(), + size: 10, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Extension to add warning color to ColorScheme +extension ColorSchemeExtension on ColorScheme { + Color get warning => const Color(0xFFFF9800); +} + +/// Widget for displaying a note indicator without highlighting text +class NoteIndicator extends StatelessWidget { + final Note note; + final double size; + final VoidCallback? onTap; + + const NoteIndicator({ + super.key, + required this.note, + this.size = 16, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final color = switch (note.status) { + NoteStatus.anchored => colorScheme.primary, + NoteStatus.shifted => colorScheme.warning, + NoteStatus.orphan => colorScheme.error, + }; + + final icon = switch (note.status) { + NoteStatus.anchored => Icons.note, + NoteStatus.shifted => Icons.note_outlined, + NoteStatus.orphan => Icons.error_outline, + }; + + return GestureDetector( + onTap: onTap, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: size * 0.7, + color: Colors.white, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/notes/widgets/notes_context_menu_extension.dart b/lib/notes/widgets/notes_context_menu_extension.dart new file mode 100644 index 000000000..165c1d7b0 --- /dev/null +++ b/lib/notes/widgets/notes_context_menu_extension.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../bloc/notes_bloc.dart'; +import '../bloc/notes_event.dart'; +import '../services/notes_telemetry.dart'; +import 'note_editor_dialog.dart'; + +/// Extension for adding notes context menu to text selection +class NotesContextMenuExtension { + /// Create note from selected text (simplified version) + static void createNoteFromSelection( + BuildContext context, + String selectedText, + int start, + int end, + String? bookId, + ) { + if (bookId == null || selectedText.trim().isEmpty) return; + + _createNoteFromSelection(context, selectedText, start, end); + } + + /// Create a note from the selected text + static void _createNoteFromSelection( + BuildContext context, + String selectedText, + int start, + int end, + ) { + // Track user action + NotesTelemetry.trackUserAction('note_create_from_selection', { + 'content_length': selectedText.length, + }); + + // Show note editor dialog + showDialog( + context: context, + builder: (context) => NoteEditorDialog( + selectedText: selectedText, + charStart: start, + charEnd: end, + onSave: (request) { + context.read().add(CreateNoteEvent(request)); + Navigator.of(context).pop(); + }, + ), + ); + } + + /// Highlight the selected text + static void highlightSelection( + BuildContext context, + String selectedText, + int start, + int end, + ) { + // Track user action + NotesTelemetry.trackUserAction('text_highlight', { + 'content_length': selectedText.length, + }); + + // For now, just show a snackbar + // In a full implementation, this would add highlighting to the text + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('הודגש: "${selectedText.length > 30 ? '${selectedText.substring(0, 30)}...' : selectedText}"'), + duration: const Duration(seconds: 2), + ), + ); + } + + /// Build simple wrapper with notes support + static Widget buildWithNotesSupport({ + required BuildContext context, + required Widget child, + required String? bookId, + }) { + return GestureDetector( + onLongPress: () { + if (bookId != null) { + _showQuickNoteDialog(context, bookId); + } + }, + child: child, + ); + } + + /// Show quick note creation dialog + static void _showQuickNoteDialog(BuildContext context, String? bookId) { + if (bookId == null) return; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('הערה מהירה'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('בחר טקסט כדי ליצור הערה'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + // This would trigger text selection mode + }, + child: const Text('בחר טקסט'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('ביטול'), + ), + ], + ), + ); + } +} + +/// Custom context menu button for notes +class NotesContextMenuButton extends StatelessWidget { + final String label; + final IconData icon; + final VoidCallback onPressed; + final Color? color; + + const NotesContextMenuButton({ + super.key, + required this.label, + required this.icon, + required this.onPressed, + this.color, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 18, + color: color ?? Theme.of(context).colorScheme.onSurface, + ), + const SizedBox(width: 8), + Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: color ?? Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Mixin for widgets that want to support notes context menu +mixin NotesContextMenuMixin on State { + /// Current book ID for context + String? get currentBookId; + + /// Build context menu with notes support + Widget buildWithNotesContextMenu(Widget child) { + return NotesContextMenuExtension.buildWithNotesSupport( + context: context, + bookId: currentBookId, + child: child, + ); + } + + /// Handle text selection for note creation + void handleTextSelectionForNote(String selectedText, int start, int end) { + if (selectedText.trim().isEmpty) return; + + showDialog( + context: context, + builder: (context) => NoteEditorDialog( + selectedText: selectedText, + charStart: start, + charEnd: end, + onSave: (request) { + context.read().add(CreateNoteEvent(request)); + Navigator.of(context).pop(); + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/notes/widgets/notes_performance_dashboard.dart b/lib/notes/widgets/notes_performance_dashboard.dart new file mode 100644 index 000000000..18248dccd --- /dev/null +++ b/lib/notes/widgets/notes_performance_dashboard.dart @@ -0,0 +1,319 @@ +import 'package:flutter/material.dart'; +import '../services/notes_telemetry.dart'; +import '../config/notes_config.dart'; + +/// Widget for displaying notes performance metrics and health status +class NotesPerformanceDashboard extends StatefulWidget { + const NotesPerformanceDashboard({super.key}); + + @override + State createState() => + _NotesPerformanceDashboardState(); +} + +class _NotesPerformanceDashboardState extends State { + Map _aggregatedMetrics = {}; + bool _isHealthy = true; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadPerformanceData(); + } + + void _loadPerformanceData() { + setState(() { + _isLoading = true; + }); + + try { + _aggregatedMetrics = NotesTelemetry.instance.getAggregatedMetrics(); + _isHealthy = NotesTelemetry.instance.isPerformanceHealthy(); + } catch (e) { + debugPrint('Error loading performance data: $e'); + } finally { + setState(() { + _isLoading = false; + }); + } + } + + void _clearMetrics() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('נקה מדדי ביצועים'), + content: const Text('האם אתה בטוח שברצונך לנקות את כל מדדי הביצועים?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('ביטול'), + ), + TextButton( + onPressed: () { + NotesTelemetry.instance.clearMetrics(); + Navigator.of(context).pop(); + _loadPerformanceData(); + }, + child: const Text('נקה'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + if (!NotesConfig.telemetryEnabled || !NotesEnvironment.telemetryEnabled) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Column( + children: [ + Icon(Icons.analytics_outlined, size: 48, color: Colors.grey), + SizedBox(height: 8), + Text( + 'מדדי ביצועים מבוטלים', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + Text( + 'הפעל telemetry כדי לראות מדדי ביצועים', + style: TextStyle(color: Colors.grey), + ), + ], + ), + ), + ); + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _isHealthy ? Icons.health_and_safety : Icons.warning, + color: _isHealthy ? Colors.green : Colors.orange, + ), + const SizedBox(width: 8), + Text( + 'מדדי ביצועים', + style: Theme.of(context).textTheme.headlineSmall, + ), + const Spacer(), + IconButton( + onPressed: _loadPerformanceData, + icon: const Icon(Icons.refresh), + tooltip: 'רענן נתונים', + ), + IconButton( + onPressed: _clearMetrics, + icon: const Icon(Icons.clear_all), + tooltip: 'נקה מדדים', + ), + ], + ), + const SizedBox(height: 16), + if (_isLoading) + const Center(child: CircularProgressIndicator()) + else ...[ + _buildHealthStatus(), + const SizedBox(height: 16), + _buildAnchoringMetrics(), + const SizedBox(height: 16), + _buildSearchMetrics(), + const SizedBox(height: 16), + _buildBatchMetrics(), + const SizedBox(height: 16), + _buildStrategyMetrics(), + ], + ], + ), + ), + ); + } + + Widget _buildHealthStatus() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _isHealthy + ? Colors.green.withValues(alpha: 0.1) + : Colors.orange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _isHealthy ? Colors.green : Colors.orange, + width: 1, + ), + ), + child: Row( + children: [ + Icon( + _isHealthy ? Icons.check_circle : Icons.warning, + color: _isHealthy ? Colors.green : Colors.orange, + ), + const SizedBox(width: 8), + Text( + _isHealthy ? 'ביצועים תקינים' : 'ביצועים דורשים תשומת לב', + style: TextStyle( + fontWeight: FontWeight.bold, + color: _isHealthy ? Colors.green : Colors.orange, + ), + ), + ], + ), + ); + } + + Widget _buildAnchoringMetrics() { + final anchoring = + _aggregatedMetrics['anchoring_performance'] as Map? ?? + {}; + + return _buildMetricSection( + 'עיגון הערות', + Icons.anchor, + [ + _buildMetricRow( + 'עוגן בהצלחה', '${anchoring['anchored_avg_ms'] ?? 0}ms'), + _buildMetricRow('הוזז', '${anchoring['shifted_avg_ms'] ?? 0}ms'), + _buildMetricRow('יתום', '${anchoring['orphan_avg_ms'] ?? 0}ms'), + ], + ); + } + + Widget _buildSearchMetrics() { + final search = + _aggregatedMetrics['search_performance'] as Map? ?? {}; + + return _buildMetricSection( + 'חיפוש', + Icons.search, + [ + _buildMetricRow('זמן ממוצע', '${search['avg_ms'] ?? 0}ms'), + _buildMetricRow('P95', '${search['p95_ms'] ?? 0}ms'), + _buildMetricRow('תוצאות ממוצעות', '${search['avg_results'] ?? 0}'), + ], + ); + } + + Widget _buildBatchMetrics() { + final batch = + _aggregatedMetrics['batch_performance'] as Map? ?? {}; + + return _buildMetricSection( + 'עיבוד אצווה', + Icons.batch_prediction, + [ + _buildMetricRow('זמן ממוצע', '${batch['avg_ms'] ?? 0}ms'), + _buildMetricRow('שיעור הצלחה', '${batch['success_rate'] ?? 0}%'), + ], + ); + } + + Widget _buildStrategyMetrics() { + final strategy = + _aggregatedMetrics['strategy_usage'] as Map? ?? {}; + + return _buildMetricSection( + 'אסטרטגיות עיגון', + Icons.psychology, + [ + _buildMetricRow('התאמה מדויקת', '${strategy['exact_avg_ms'] ?? 0}ms'), + _buildMetricRow('התאמה בהקשר', '${strategy['context_avg_ms'] ?? 0}ms'), + _buildMetricRow('התאמה מטושטשת', '${strategy['fuzzy_avg_ms'] ?? 0}ms'), + ], + ); + } + + Widget _buildMetricSection( + String title, IconData icon, List metrics) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.withValues(alpha: 0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 20, color: Colors.blue), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 8), + ...metrics, + ], + ), + ); + } + + Widget _buildMetricRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + Text( + value, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ); + } +} + +/// Compact version of performance dashboard for sidebar +class CompactPerformanceDashboard extends StatelessWidget { + const CompactPerformanceDashboard({super.key}); + + @override + Widget build(BuildContext context) { + if (!NotesConfig.telemetryEnabled || !NotesEnvironment.telemetryEnabled) { + return const SizedBox.shrink(); + } + + final isHealthy = NotesTelemetry.instance.isPerformanceHealthy(); + final aggregated = NotesTelemetry.instance.getAggregatedMetrics(); + final anchoring = + aggregated['anchoring_performance'] as Map? ?? {}; + + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isHealthy + ? Colors.green.withValues(alpha: 0.1) + : Colors.orange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isHealthy ? Icons.check_circle_outline : Icons.warning_outlined, + size: 16, + color: isHealthy ? Colors.green : Colors.orange, + ), + const SizedBox(width: 4), + Text( + '${anchoring['anchored_avg_ms'] ?? 0}ms', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + ], + ), + ); + } +} diff --git a/lib/notes/widgets/notes_sidebar.dart b/lib/notes/widgets/notes_sidebar.dart new file mode 100644 index 000000000..56cce7533 --- /dev/null +++ b/lib/notes/widgets/notes_sidebar.dart @@ -0,0 +1,663 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../bloc/notes_bloc.dart'; +import '../bloc/notes_event.dart'; +import '../bloc/notes_state.dart'; +import '../models/note.dart'; + +import '../services/notes_telemetry.dart'; + +/// Sidebar widget for displaying and managing notes +class NotesSidebar extends StatefulWidget { + final String? bookId; + final VoidCallback? onClose; + final Function(Note)? onNoteSelected; + final Function(int, int)? onNavigateToPosition; + + const NotesSidebar({ + super.key, + this.bookId, + this.onClose, + this.onNoteSelected, + this.onNavigateToPosition, + }); + + @override + State createState() => _NotesSidebarState(); +} + +class _NotesSidebarState extends State { + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + NoteSortOption _sortOption = NoteSortOption.dateDesc; + NoteStatusFilter _statusFilter = NoteStatusFilter.all; + + @override + void initState() { + super.initState(); + _loadNotes(); + + // רענון ההערות כל 2 שניות כדי לתפוס הערות חדשות + Timer.periodic(const Duration(seconds: 2), (timer) { + if (mounted && widget.bookId != null) { + try { + context.read().add(LoadNotesEvent(widget.bookId!)); + } catch (e) { + // אם ה-BLoC לא זמין, נעצור את הטיימר + timer.cancel(); + } + } else { + timer.cancel(); + } + }); + } + + @override + void didUpdateWidget(NotesSidebar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.bookId != oldWidget.bookId) { + _loadNotes(); + } + } + + void _loadNotes() { + if (widget.bookId != null) { + try { + context.read().add(LoadNotesEvent(widget.bookId!)); + } catch (e) { + // BLoC not available yet - will be handled in build method + print('NotesBloc not available: $e'); + } + } + } + + void _onSearchChanged(String query) { + setState(() { + _searchQuery = query.trim(); + }); + + if (_searchQuery.isNotEmpty) { + final stopwatch = Stopwatch()..start(); + context.read().add(SearchNotesEvent(_searchQuery)); + + // Track search performance + NotesTelemetry.trackSearchPerformance( + _searchQuery, + 0, // Will be updated when results arrive + stopwatch.elapsed, + ); + } else { + _loadNotes(); + } + } + + void _onSortChanged(NoteSortOption? option) { + if (option != null) { + setState(() { + _sortOption = option; + }); + } + } + + void _onStatusFilterChanged(NoteStatusFilter? filter) { + if (filter != null) { + setState(() { + _statusFilter = filter; + }); + } + } + + List _filterAndSortNotes(List notes) { + // Apply status filter + var filteredNotes = notes.where((note) { + switch (_statusFilter) { + case NoteStatusFilter.all: + return true; + case NoteStatusFilter.anchored: + return note.status == NoteStatus.anchored; + case NoteStatusFilter.shifted: + return note.status == NoteStatus.shifted; + case NoteStatusFilter.orphan: + return note.status == NoteStatus.orphan; + } + }).toList(); + + // Apply sorting + switch (_sortOption) { + case NoteSortOption.dateDesc: + filteredNotes.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + break; + case NoteSortOption.dateAsc: + filteredNotes.sort((a, b) => a.updatedAt.compareTo(b.updatedAt)); + break; + case NoteSortOption.status: + filteredNotes.sort((a, b) { + final statusOrder = { + NoteStatus.anchored: 0, + NoteStatus.shifted: 1, + NoteStatus.orphan: 2, + }; + return statusOrder[a.status]!.compareTo(statusOrder[b.status]!); + }); + break; + case NoteSortOption.relevance: + // For search results, keep original order (relevance-based) + // For regular notes, sort by date + if (_searchQuery.isEmpty) { + filteredNotes.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + } + break; + } + + return filteredNotes; + } + + void _onNotePressed(Note note) { + // Track user action + NotesTelemetry.trackUserAction('note_selected', { + 'note_count': 1, + 'status': note.status.name, + 'content_length': note.contentMarkdown.length, + }); + + // Navigate to note position if possible + if (note.status != NoteStatus.orphan && widget.onNavigateToPosition != null) { + widget.onNavigateToPosition!(note.charStart, note.charEnd); + } + + // Notify parent + widget.onNoteSelected?.call(note); + } + + void _onEditNote(Note note) { + context.read().add(EditNoteEvent(note)); + } + + void _onDeleteNote(Note note) { + // Show confirmation dialog + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('מחק הערה'), + content: const Text('האם אתה בטוח שברצונך למחוק הערה זו?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('ביטול'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + context.read().add(DeleteNoteEvent(note.id)); + + NotesTelemetry.trackUserAction('note_deleted', { + 'note_count': 1, + 'status': note.status.name, + }); + }, + child: const Text('מחק'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Header - עיצוב דומה למפרשים + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Expanded( + child: Text( + 'הערות אישיות', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + if (widget.onClose != null) + IconButton( + onPressed: widget.onClose, + icon: const Icon(Icons.close), + iconSize: 20, + tooltip: 'סגור', + ), + ], + ), + ), + + // Search and filters - עיצוב דומה למפרשים + Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + // Search field + TextField( + controller: _searchController, + onChanged: _onSearchChanged, + decoration: InputDecoration( + hintText: 'חפש הערות...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + onPressed: () { + _searchController.clear(); + _onSearchChanged(''); + }, + icon: const Icon(Icons.close), + ) + : null, + isDense: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + ), + ), + ), + + const SizedBox(height: 8), + + Row( + children: [ + Flexible( + flex: 3, + child: DropdownButtonFormField( + value: _sortOption, + onChanged: _onSortChanged, + decoration: const InputDecoration( + labelText: 'מיון', + isDense: true, + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + items: NoteSortOption.values.map((option) { + return DropdownMenuItem( + value: option, + child: Text( + _getSortOptionLabel(option), + style: const TextStyle(fontSize: 12), + ), + ); + }).toList(), + ), + ), + const SizedBox(width: 4), + Flexible( + flex: 2, + child: DropdownButtonFormField( + value: _statusFilter, + onChanged: _onStatusFilterChanged, + decoration: const InputDecoration( + labelText: 'סטטוס', + isDense: true, + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + items: NoteStatusFilter.values.map((filter) { + return DropdownMenuItem( + value: filter, + child: Text( + _getStatusFilterLabel(filter), + style: const TextStyle(fontSize: 12), + ), + ); + }).toList(), + ), + ), + ], + ), + ], + ), + ), + + // Notes list + Expanded( + child: Builder( + builder: (context) { + try { + return BlocBuilder( + builder: (context, state) { + if (state is NotesLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (state is NotesError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + 'שגיאה בטעינת הערות', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + state.message, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadNotes, + child: const Text('נסה שוב'), + ), + ], + ), + ); + } + + List notes = []; + if (state is NotesLoaded) { + notes = state.notes; + } else if (state is NotesSearchResults) { + notes = state.results; + } + + final filteredNotes = _filterAndSortNotes(notes); + + if (filteredNotes.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _searchQuery.isNotEmpty + ? Icons.search_off + : Icons.note_add_outlined, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + _searchQuery.isNotEmpty + ? 'לא נמצאו תוצאות' + : 'אין הערות עדיין', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + _searchQuery.isNotEmpty + ? 'נסה מילות חיפוש אחרות' + : 'בחר טקסט והוסף הערה ראשונה', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + return ListView.builder( + itemCount: filteredNotes.length, + itemBuilder: (context, index) { + final note = filteredNotes[index]; + return _NoteListItem( + note: note, + onPressed: () => _onNotePressed(note), + onEdit: () => _onEditNote(note), + onDelete: () => _onDeleteNote(note), + ); + }, + ); + }, + ); + } catch (e) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + 'שגיאה בטעינת הערות', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'NotesBloc לא זמין. נסה לעשות restart לאפליקציה.', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ); + } + }, + ), + ), + ], + ); + } + + String _getSortOptionLabel(NoteSortOption option) { + switch (option) { + case NoteSortOption.dateDesc: + return 'תאריך (חדש לישן)'; + case NoteSortOption.dateAsc: + return 'תאריך (ישן לחדש)'; + case NoteSortOption.status: + return 'סטטוס'; + case NoteSortOption.relevance: + return 'רלוונטיות'; + } + } + + String _getStatusFilterLabel(NoteStatusFilter filter) { + switch (filter) { + case NoteStatusFilter.all: + return 'הכל'; + case NoteStatusFilter.anchored: + return 'מעוגנות'; + case NoteStatusFilter.shifted: + return 'זזזו'; + case NoteStatusFilter.orphan: + return 'יתומות'; + } + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } +} + +/// Individual note item in the sidebar list +class _NoteListItem extends StatelessWidget { + final Note note; + final VoidCallback onPressed; + final VoidCallback onEdit; + final VoidCallback onDelete; + + const _NoteListItem({ + required this.note, + required this.onPressed, + required this.onEdit, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + elevation: 1, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header with status and actions + Row( + children: [ + _StatusIndicator(status: note.status), + const SizedBox(width: 8), + Expanded( + child: Text( + _formatDate(note.updatedAt), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + PopupMenuButton( + onSelected: (value) { + switch (value) { + case 'edit': + onEdit(); + break; + case 'delete': + onDelete(); + break; + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'edit', + child: Row( + children: [ + Icon(Icons.edit), + SizedBox(width: 8), + Text('ערוך'), + ], + ), + ), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete), + SizedBox(width: 8), + Text('מחק'), + ], + ), + ), + ], + ), + ], + ), + + const SizedBox(height: 8), + + // Note content preview + Text( + note.contentMarkdown, + style: Theme.of(context).textTheme.bodyMedium, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + + // Tags if any + if (note.tags.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 4, + runSpacing: 4, + children: note.tags.take(3).map((tag) { + return Chip( + label: Text( + tag, + style: Theme.of(context).textTheme.bodySmall, + ), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ); + }).toList(), + ), + ], + ], + ), + ), + ), + ); + } + + String _formatDate(DateTime date) { + final now = DateTime.now(); + final difference = now.difference(date); + + if (difference.inDays == 0) { + return 'היום ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + } else if (difference.inDays == 1) { + return 'אתמול'; + } else if (difference.inDays < 7) { + return '${difference.inDays} ימים'; + } else { + return '${date.day}/${date.month}/${date.year}'; + } + } +} + +/// Status indicator widget +class _StatusIndicator extends StatelessWidget { + final NoteStatus status; + + const _StatusIndicator({required this.status}); + + @override + Widget build(BuildContext context) { + Color color; + String tooltip; + + switch (status) { + case NoteStatus.anchored: + color = Colors.green; + tooltip = 'מעוגנת במיקום המדויק'; + break; + case NoteStatus.shifted: + color = Colors.orange; + tooltip = 'זזזה ממיקום המקורי'; + break; + case NoteStatus.orphan: + color = Colors.red; + tooltip = 'לא נמצא מיקום מתאים'; + break; + } + + return Tooltip( + message: tooltip, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + ); + } +} + +/// Sort options for notes +enum NoteSortOption { + dateDesc, + dateAsc, + status, + relevance, +} + +/// Status filter options +enum NoteStatusFilter { + all, + anchored, + shifted, + orphan, +} \ No newline at end of file diff --git a/lib/notes/widgets/orphan_notes_manager.dart b/lib/notes/widgets/orphan_notes_manager.dart new file mode 100644 index 000000000..219f24ec4 --- /dev/null +++ b/lib/notes/widgets/orphan_notes_manager.dart @@ -0,0 +1,557 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../bloc/notes_bloc.dart'; +import '../bloc/notes_event.dart'; +import '../bloc/notes_state.dart'; +import '../models/note.dart'; +import '../models/anchor_models.dart'; +import '../repository/notes_repository.dart'; +import '../services/notes_telemetry.dart'; + + +/// Widget for managing orphaned notes and helping re-anchor them +class OrphanNotesManager extends StatefulWidget { + final String bookId; + final VoidCallback? onClose; + + const OrphanNotesManager({ + super.key, + required this.bookId, + this.onClose, + }); + + @override + State createState() => _OrphanNotesManagerState(); +} + +class _OrphanNotesManagerState extends State { + Note? _selectedOrphan; + List _candidates = []; + bool _isSearching = false; + + @override + void initState() { + super.initState(); + _loadOrphanNotes(); + } + + void _loadOrphanNotes() { + context.read().add(LoadNotesEvent(widget.bookId)); + } + + void _selectOrphan(Note orphan) { + setState(() { + _selectedOrphan = orphan; + _isSearching = true; + _candidates = []; + }); + + // Find potential anchor candidates for this orphan + context.read().add(FindAnchorCandidatesEvent(orphan)); + } + + void _acceptCandidate(AnchorCandidate candidate) { + if (_selectedOrphan == null) return; + + // Track user action + NotesTelemetry.trackUserAction('orphan_reanchored', { + 'note_count': 1, + 'strategy': candidate.strategy, + 'score': (candidate.score * 100).round(), + }); + + // Update the note with new anchor position + final updateRequest = UpdateNoteRequest( + charStart: candidate.start, + charEnd: candidate.end, + status: NoteStatus.shifted, // Mark as shifted since it's re-anchored + ); + + context.read().add(UpdateNoteEvent(_selectedOrphan!.id, updateRequest)); + + // Clear selection + setState(() { + _selectedOrphan = null; + _candidates = []; + _isSearching = false; + }); + + // Show success message + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('הערה עוגנה מחדש בהצלחה'), + backgroundColor: Colors.green, + ), + ); + } + + void _rejectCandidate() { + setState(() { + _selectedOrphan = null; + _candidates = []; + _isSearching = false; + }); + } + + void _deleteOrphan(Note orphan) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('מחק הערה יתומה'), + content: const Text('האם אתה בטוח שברצונך למחוק הערה זו? פעולה זו לא ניתנת לביטול.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('ביטול'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + context.read().add(DeleteNoteEvent(orphan.id)); + + NotesTelemetry.trackUserAction('orphan_deleted', { + 'note_count': 1, + }); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('מחק'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Container( + width: 800, + height: 600, + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Icon( + Icons.help_outline, + color: Theme.of(context).colorScheme.primary, + size: 28, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'ניהול הערות יתומות', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + onPressed: widget.onClose ?? () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + + const SizedBox(height: 16), + + Text( + 'הערות יתומות הן הערות שלא ניתן למצוא עבורן מיקום מתאים בגרסה הנוכחית של הטקסט.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + + const SizedBox(height: 24), + + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Orphan notes list + Expanded( + flex: 1, + child: _buildOrphansList(), + ), + + const SizedBox(width: 24), + + // Candidates panel + Expanded( + flex: 2, + child: _buildCandidatesPanel(), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildOrphansList() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'הערות יתומות', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + + const SizedBox(height: 12), + + Expanded( + child: BlocBuilder( + builder: (context, state) { + if (state is NotesLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is NotesError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48), + const SizedBox(height: 16), + Text(state.message), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadOrphanNotes, + child: const Text('נסה שוב'), + ), + ], + ), + ); + } + + List orphans = []; + if (state is NotesLoaded) { + orphans = state.notes.where((note) => note.status == NoteStatus.orphan).toList(); + } + + if (orphans.isEmpty) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle_outline, size: 48, color: Colors.green), + SizedBox(height: 16), + Text('אין הערות יתומות!'), + SizedBox(height: 8), + Text('כל ההערות מעוגנות כראוי.'), + ], + ), + ); + } + + return ListView.builder( + itemCount: orphans.length, + itemBuilder: (context, index) { + final orphan = orphans[index]; + final isSelected = _selectedOrphan?.id == orphan.id; + + return Card( + color: isSelected + ? Theme.of(context).colorScheme.primaryContainer + : null, + child: ListTile( + title: Text( + orphan.contentMarkdown, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + 'נוצרה: ${_formatDate(orphan.createdAt)}', + style: Theme.of(context).textTheme.bodySmall, + ), + trailing: PopupMenuButton( + onSelected: (value) { + switch (value) { + case 'find': + _selectOrphan(orphan); + break; + case 'delete': + _deleteOrphan(orphan); + break; + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'find', + child: Row( + children: [ + Icon(Icons.search), + SizedBox(width: 8), + Text('חפש מיקום'), + ], + ), + ), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete, color: Colors.red), + SizedBox(width: 8), + Text('מחק'), + ], + ), + ), + ], + ), + onTap: () => _selectOrphan(orphan), + ), + ); + }, + ); + }, + ), + ), + ], + ); + } + + Widget _buildCandidatesPanel() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'מועמדים לעיגון מחדש', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + + const SizedBox(height: 12), + + Expanded( + child: _selectedOrphan == null + ? const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.arrow_back, size: 48), + SizedBox(height: 16), + Text('בחר הערה יתומה מהרשימה'), + SizedBox(height: 8), + Text('כדי לחפש מועמדים לעיגון מחדש'), + ], + ), + ) + : _isSearching + ? const Center(child: CircularProgressIndicator()) + : _buildCandidatesList(), + ), + ], + ); + } + + Widget _buildCandidatesList() { + if (_candidates.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.search_off, size: 48), + const SizedBox(height: 16), + const Text('לא נמצאו מועמדים מתאימים'), + const SizedBox(height: 8), + const Text('ייתכן שהטקסט השתנה משמעותית'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _rejectCandidate, + child: const Text('חזור'), + ), + ], + ), + ); + } + + return Column( + children: [ + // Selected orphan info + Card( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'הערה יתומה:', + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(height: 4), + Text( + _selectedOrphan!.contentMarkdown, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'טקסט מקורי: "${_selectedOrphan!.selectedTextNormalized}"', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 16), + + // Candidates list + Expanded( + child: ListView.builder( + itemCount: _candidates.length, + itemBuilder: (context, index) { + final candidate = _candidates[index]; + return _CandidateItem( + candidate: candidate, + onAccept: () => _acceptCandidate(candidate), + onReject: index == _candidates.length - 1 ? _rejectCandidate : null, + ); + }, + ), + ), + ], + ); + } + + String _formatDate(DateTime date) { + return '${date.day}/${date.month}/${date.year}'; + } +} + +/// Individual candidate item widget +class _CandidateItem extends StatelessWidget { + final AnchorCandidate candidate; + final VoidCallback onAccept; + final VoidCallback? onReject; + + const _CandidateItem({ + required this.candidate, + required this.onAccept, + this.onReject, + }); + + @override + Widget build(BuildContext context) { + final scorePercent = (candidate.score * 100).round(); + final scoreColor = _getScoreColor(candidate.score); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header with score and strategy + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: scoreColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: scoreColor), + ), + child: Text( + '$scorePercent%', + style: TextStyle( + color: scoreColor, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + const SizedBox(width: 8), + Chip( + label: Text( + _getStrategyLabel(candidate.strategy), + style: const TextStyle(fontSize: 12), + ), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + const Spacer(), + Text( + 'מיקום: ${candidate.start}-${candidate.end}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + + const SizedBox(height: 12), + + // Preview text (would be extracted from document) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'טקסט לדוגמה במיקום המוצע...', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + + const SizedBox(height: 16), + + // Action buttons + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: onAccept, + icon: const Icon(Icons.check), + label: const Text('אשר'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: onReject, + icon: const Icon(Icons.close), + label: const Text('דחה'), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Color _getScoreColor(double score) { + if (score >= 0.8) return Colors.green; + if (score >= 0.6) return Colors.orange; + return Colors.red; + } + + String _getStrategyLabel(String strategy) { + switch (strategy) { + case 'exact': + return 'התאמה מדויקת'; + case 'context': + return 'התאמת הקשר'; + case 'fuzzy': + return 'התאמה מטושטשת'; + default: + return strategy; + } + } +} \ No newline at end of file diff --git a/lib/text_book/bloc/text_book_bloc.dart b/lib/text_book/bloc/text_book_bloc.dart index 0fa5c329b..bf1a723f8 100644 --- a/lib/text_book/bloc/text_book_bloc.dart +++ b/lib/text_book/bloc/text_book_bloc.dart @@ -26,6 +26,9 @@ class TextBookBloc extends Bloc { on(_onUpdateSelectedIndex); on(_onTogglePinLeftPane); on(_onUpdateSearchText); + on(_onToggleNotesSidebar); + on(_onCreateNoteFromToolbar); + on(_onUpdateSelectedTextForNote); } Future _onLoadContent( @@ -103,6 +106,10 @@ class TextBookBloc extends Bloc { scrollController: scrollController, scrollOffsetController: scrollOffsetController, positionsListener: positionsListener, + showNotesSidebar: false, + selectedTextForNote: null, + selectedTextStart: null, + selectedTextEnd: null, )); } catch (e) { emit(TextBookError(e.toString(), book, initial.index, @@ -235,4 +242,38 @@ class TextBookBloc extends Bloc { )); } } + + void _onToggleNotesSidebar( + ToggleNotesSidebar event, + Emitter emit, + ) { + if (state is TextBookLoaded) { + final currentState = state as TextBookLoaded; + emit(currentState.copyWith( + showNotesSidebar: !currentState.showNotesSidebar, + )); + } + } + + void _onCreateNoteFromToolbar( + CreateNoteFromToolbar event, + Emitter emit, + ) { + // כרגע זה רק מציין שהאירוע התקבל + // הלוגיקה האמיתית תהיה בכפתור בשורת הכלים + } + + void _onUpdateSelectedTextForNote( + UpdateSelectedTextForNote event, + Emitter emit, + ) { + if (state is TextBookLoaded) { + final currentState = state as TextBookLoaded; + emit(currentState.copyWith( + selectedTextForNote: event.text, + selectedTextStart: event.start, + selectedTextEnd: event.end, + )); + } + } } diff --git a/lib/text_book/bloc/text_book_event.dart b/lib/text_book/bloc/text_book_event.dart index afe9a325e..68f1184a7 100644 --- a/lib/text_book/bloc/text_book_event.dart +++ b/lib/text_book/bloc/text_book_event.dart @@ -102,3 +102,28 @@ class UpdateSearchText extends TextBookEvent { @override List get props => [text]; } + +class ToggleNotesSidebar extends TextBookEvent { + const ToggleNotesSidebar(); + + @override + List get props => []; +} + +class CreateNoteFromToolbar extends TextBookEvent { + const CreateNoteFromToolbar(); + + @override + List get props => []; +} + +class UpdateSelectedTextForNote extends TextBookEvent { + final String? text; + final int? start; + final int? end; + + const UpdateSelectedTextForNote(this.text, this.start, this.end); + + @override + List get props => [text, start, end]; +} diff --git a/lib/text_book/bloc/text_book_state.dart b/lib/text_book/bloc/text_book_state.dart index 99c179d2c..1a5de9fbc 100644 --- a/lib/text_book/bloc/text_book_state.dart +++ b/lib/text_book/bloc/text_book_state.dart @@ -64,6 +64,10 @@ class TextBookLoaded extends TextBookState { final bool pinLeftPane; final String searchText; final String? currentTitle; + final bool showNotesSidebar; + final String? selectedTextForNote; + final int? selectedTextStart; + final int? selectedTextEnd; // Controllers final ItemScrollController scrollController; @@ -94,6 +98,10 @@ class TextBookLoaded extends TextBookState { required this.scrollOffsetController, required this.positionsListener, this.currentTitle, + required this.showNotesSidebar, + this.selectedTextForNote, + this.selectedTextStart, + this.selectedTextEnd, }) : super(book, selectedIndex ?? 0, showLeftPane, activeCommentators); factory TextBookLoaded.initial({ @@ -125,6 +133,10 @@ class TextBookLoaded extends TextBookState { scrollOffsetController: ScrollOffsetController(), positionsListener: ItemPositionsListener.create(), visibleIndices: [index], + showNotesSidebar: false, + selectedTextForNote: null, + selectedTextStart: null, + selectedTextEnd: null, ); } @@ -152,6 +164,10 @@ class TextBookLoaded extends TextBookState { ScrollOffsetController? scrollOffsetController, ItemPositionsListener? positionsListener, String? currentTitle, + bool? showNotesSidebar, + String? selectedTextForNote, + int? selectedTextStart, + int? selectedTextEnd, }) { return TextBookLoaded( book: book ?? this.book, @@ -179,6 +195,10 @@ class TextBookLoaded extends TextBookState { scrollOffsetController ?? this.scrollOffsetController, positionsListener: positionsListener ?? this.positionsListener, currentTitle: currentTitle ?? this.currentTitle, + showNotesSidebar: showNotesSidebar ?? this.showNotesSidebar, + selectedTextForNote: selectedTextForNote ?? this.selectedTextForNote, + selectedTextStart: selectedTextStart ?? this.selectedTextStart, + selectedTextEnd: selectedTextEnd ?? this.selectedTextEnd, ); } @@ -204,5 +224,9 @@ class TextBookLoaded extends TextBookState { pinLeftPane, searchText, currentTitle, + showNotesSidebar, + selectedTextForNote, + selectedTextStart, + selectedTextEnd, ]; } diff --git a/lib/text_book/view/combined_view/combined_book_screen.dart b/lib/text_book/view/combined_view/combined_book_screen.dart index 982465ae1..769c560c0 100644 --- a/lib/text_book/view/combined_view/combined_book_screen.dart +++ b/lib/text_book/view/combined_view/combined_book_screen.dart @@ -18,6 +18,7 @@ import 'package:otzaria/tabs/models/tab.dart'; import 'package:otzaria/models/books.dart'; import 'package:otzaria/utils/text_manipulation.dart' as utils; import 'package:otzaria/text_book/bloc/text_book_event.dart'; +import 'package:otzaria/notes/notes_system.dart'; class CombinedView extends StatefulWidget { CombinedView({ @@ -45,6 +46,18 @@ class _CombinedViewState extends State { final GlobalKey _selectionKey = GlobalKey(); + // הוסרנו את _showNotesSidebar המקומי - נשתמש ב-state מה-BLoC + + // מעקב אחר בחירת טקסט בלי setState + String? _selectedText; + int? _selectionStart; + int? _selectionEnd; + + // שמירת הבחירה האחרונה לשימוש בתפריט הקונטקסט + String? _lastSelectedText; + int? _lastSelectionStart; + int? _lastSelectionEnd; + /// helper קטן שמחזיר רשימת MenuEntry מקבוצה אחת, כולל כפתור הצג/הסתר הכל List> _buildGroup( String groupName, @@ -231,6 +244,20 @@ class _CombinedViewState extends State { .toList(), ), const ctx.MenuDivider(), + // הערות אישיות + ctx.MenuItem( + label: () { + final text = _lastSelectedText ?? _selectedText; + if (text == null || text.trim().isEmpty) { + return 'הוסף הערה'; + } + final preview = + text.length > 12 ? '${text.substring(0, 12)}...' : text; + return 'הוסף הערה ל: "$preview"'; + }(), + onSelected: () => _createNoteFromSelection(), + ), + const ctx.MenuDivider(), ctx.MenuItem( label: 'בחר את כל הטקסט', onSelected: () => @@ -240,6 +267,73 @@ class _CombinedViewState extends State { ); } + /// יצירת הערה מטקסט נבחר + void _createNoteFromSelection() { + // נשתמש בבחירה האחרונה שנשמרה, או בבחירה הנוכחית + final text = _lastSelectedText ?? _selectedText; + if (text == null || text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('אנא בחר טקסט ליצירת הערה'), + duration: Duration(seconds: 2), + ), + ); + return; + } + + final start = _lastSelectionStart ?? _selectionStart ?? 0; + final end = _lastSelectionEnd ?? _selectionEnd ?? text.length; + _showNoteEditor(text, start, end); + } + + /// הצגת עורך ההערות + void _showNoteEditor(String selectedText, int charStart, int charEnd) { + showDialog( + context: context, + builder: (context) => NoteEditorDialog( + selectedText: selectedText, + bookId: widget.tab.book.title, + charStart: charStart, + charEnd: charEnd, + onSave: (noteRequest) async { + try { + final notesService = NotesIntegrationService.instance; + final bookId = widget.tab.book.title; + await notesService.createNoteFromSelection( + bookId, + selectedText, + charStart, + charEnd, + noteRequest.contentMarkdown, + tags: noteRequest.tags, + privacy: noteRequest.privacy, + ); + + if (mounted) { + Navigator.of(context).pop(); + // הצגת סרגל ההערות אם הוא לא פתוח + final currentState = context.read().state; + if (currentState is TextBookLoaded && + !currentState.showNotesSidebar) { + context.read().add(const ToggleNotesSidebar()); + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('ההערה נוצרה והוצגה בסרגל')), + ); + } + } catch (e) { + if (mounted) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('שגיאה ביצירת הערה: $e')), + ); + } + } + }, + ), + ); + } + Widget buildKeyboardListener() { return BlocBuilder( bloc: context.read(), @@ -255,6 +349,29 @@ class _CombinedViewState extends State { child: SelectionArea( key: _selectionKey, contextMenuBuilder: (_, __) => const SizedBox.shrink(), + onSelectionChanged: (selection) { + final text = selection?.plainText ?? ''; + if (text.isEmpty) { + _selectedText = null; + _selectionStart = null; + _selectionEnd = null; + // עדכון ה-BLoC שאין טקסט נבחר + context.read().add(const UpdateSelectedTextForNote(null, null, null)); + } else { + _selectedText = text; + _selectionStart = 0; + _selectionEnd = text.length; + + // שמירת הבחירה האחרונה + _lastSelectedText = text; + _lastSelectionStart = 0; + _lastSelectionEnd = text.length; + + // עדכון ה-BLoC עם הטקסט הנבחר + context.read().add(UpdateSelectedTextForNote(text, 0, text.length)); + } + // בלי setState – כדי לא לרנדר את כל העץ תוך כדי גרירת הבחירה + }, child: ctx.ContextMenuRegion( // <-- ה-Region היחיד, במיקום הנכון contextMenu: _buildContextMenu(state), @@ -336,6 +453,44 @@ class _CombinedViewState extends State { @override Widget build(BuildContext context) { - return buildKeyboardListener(); + final bookView = buildKeyboardListener(); + + // אם סרגל ההערות פתוח, הצג אותו לצד התוכן + return BlocBuilder( + builder: (context, state) { + if (state is! TextBookLoaded) return bookView; + + if (state.showNotesSidebar) { + return Row( + children: [ + Expanded(flex: 3, child: bookView), + Container( + width: 1, + color: Theme.of(context).dividerColor, + ), + Expanded( + flex: 1, + child: NotesSidebar( + bookId: widget.tab.book.title, + onClose: () => context + .read() + .add(const ToggleNotesSidebar()), + onNavigateToPosition: (start, end) { + // ניווט למיקום ההערה בטקסט + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('ניווט למיקום $start-$end'), + ), + ); + }, + ), + ), + ], + ); + } + + return bookView; + }, + ); } } diff --git a/lib/text_book/view/splited_view/simple_book_view.dart b/lib/text_book/view/splited_view/simple_book_view.dart index 8b0fb3917..03a56b4cf 100644 --- a/lib/text_book/view/splited_view/simple_book_view.dart +++ b/lib/text_book/view/splited_view/simple_book_view.dart @@ -15,6 +15,7 @@ import 'package:otzaria/utils/text_manipulation.dart' as utils; import 'package:otzaria/text_book/view/links_screen.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:otzaria/models/books.dart'; +import 'package:otzaria/notes/notes_system.dart'; class SimpleBookView extends StatefulWidget { const SimpleBookView({ @@ -41,6 +42,18 @@ class SimpleBookView extends StatefulWidget { class _SimpleBookViewState extends State { final GlobalKey _selectionKey = GlobalKey(); + + // הוסרנו את _showNotesSidebar המקומי - נשתמש ב-state מה-BLoC + + // מעקב אחר בחירת טקסט בלי setState + String? _selectedText; + int? _selectionStart; + int? _selectionEnd; + + // שמירת הבחירה האחרונה לשימוש בתפריט הקונטקסט + String? _lastSelectedText; + int? _lastSelectionStart; + int? _lastSelectionEnd; /// helper קטן שמחזיר רשימת MenuEntry מקבוצה אחת, כולל כפתור הצג/הסתר הכל List> _buildGroup( @@ -206,6 +219,19 @@ class _SimpleBookViewState extends State { .toList(), ), const ctx.MenuDivider(), + // הערות אישיות + ctx.MenuItem( + label: () { + final text = _lastSelectedText ?? _selectedText; + if (text == null || text.trim().isEmpty) { + return 'הוסף הערה'; + } + final preview = text.length > 12 ? '${text.substring(0, 12)}...' : text; + return 'הוסף הערה ל: "$preview"'; + }(), + onSelected: () => _createNoteFromSelection(), + ), + const ctx.MenuDivider(), ctx.MenuItem( label: 'בחר את כל הטקסט', onSelected: () => @@ -215,12 +241,86 @@ class _SimpleBookViewState extends State { ); } + + + /// יצירת הערה מטקסט נבחר + void _createNoteFromSelection() { + // נשתמש בבחירה האחרונה שנשמרה, או בבחירה הנוכחית + final text = _lastSelectedText ?? _selectedText; + if (text == null || text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('אנא בחר טקסט ליצירת הערה'), + duration: Duration(seconds: 2), + ), + ); + return; + } + + final start = _lastSelectionStart ?? _selectionStart ?? 0; + final end = _lastSelectionEnd ?? _selectionEnd ?? text.length; + _showNoteEditor(text, start, end); + } + + /// הצגת עורך ההערות + void _showNoteEditor(String selectedText, int charStart, int charEnd) { + + showDialog( + context: context, + builder: (context) => NoteEditorDialog( + selectedText: selectedText, + bookId: widget.tab.book.title, + charStart: charStart, + charEnd: charEnd, + onSave: (noteRequest) async { + try { + final notesService = NotesIntegrationService.instance; + final bookId = widget.tab.book.title; + await notesService.createNoteFromSelection( + bookId, + selectedText, + charStart, + charEnd, + noteRequest.contentMarkdown, + tags: noteRequest.tags, + privacy: noteRequest.privacy, + ); + + if (mounted) { + Navigator.of(context).pop(); + // הצגת סרגל ההערות אם הוא לא פתוח + final currentState = context.read().state; + if (currentState is TextBookLoaded && !currentState.showNotesSidebar) { + context.read().add(const ToggleNotesSidebar()); + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('ההערה נוצרה והוצגה בסרגל')), + ); + } + } catch (e) { + if (mounted) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('שגיאה ביצירת הערה: $e')), + ); + } + } + }, + ), + ); + } + + + + + @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { if (state is! TextBookLoaded) return const Center(); - return ProgressiveScroll( + + final bookView = ProgressiveScroll( scrollController: state.scrollOffsetController, maxSpeed: 10000.0, curve: 10.0, @@ -228,6 +328,30 @@ class _SimpleBookViewState extends State { child: SelectionArea( key: _selectionKey, contextMenuBuilder: (_, __) => const SizedBox.shrink(), + onSelectionChanged: (selection) { + final text = selection?.plainText ?? ''; + if (text.isEmpty) { + _selectedText = null; + _selectionStart = null; + _selectionEnd = null; + // עדכון ה-BLoC שאין טקסט נבחר + context.read().add(const UpdateSelectedTextForNote(null, null, null)); + } else { + _selectedText = text; + // בינתיים אינדקסים פשוטים (אפשר לעדכן בעתיד למיפוי אמיתי במסמך) + _selectionStart = 0; + _selectionEnd = text.length; + + // שמירת הבחירה האחרונה + _lastSelectedText = text; + _lastSelectionStart = 0; + _lastSelectionEnd = text.length; + + // עדכון ה-BLoC עם הטקסט הנבחר + context.read().add(UpdateSelectedTextForNote(text, 0, text.length)); + } + // חשוב: לא קוראים ל-setState כאן כדי לא לפגוע בחוויית הבחירה + }, child: ctx.ContextMenuRegion( contextMenu: _buildContextMenu(state), child: ScrollablePositionedList.builder( @@ -275,6 +399,38 @@ class _SimpleBookViewState extends State { ), ), ); + + // אם סרגל ההערות פתוח, הצג אותו לצד התוכן + if (state.showNotesSidebar) { + return Row( + children: [ + Expanded(flex: 3, child: bookView), + Container( + width: 1, + color: Theme.of(context).dividerColor, + ), + Expanded( + flex: 1, + child: NotesSidebar( + bookId: widget.tab.book.title, + onClose: () => context.read().add(const ToggleNotesSidebar()), + onNavigateToPosition: (start, end) { + // ניווט למיקום ההערה בטקסט + // זה יצריך חישוב של האינדקס המתאים + // לעת עתה נציג הודעה + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('ניווט למיקום $start-$end'), + ), + ); + }, + ), + ), + ], + ); + } + + return bookView; }, ); } diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index 092ade0f7..67385e27c 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -30,6 +30,7 @@ import 'package:otzaria/utils/page_converter.dart'; import 'package:otzaria/utils/ref_helper.dart'; import 'package:otzaria/utils/text_manipulation.dart' as utils; import 'package:url_launcher/url_launcher.dart'; +import 'package:otzaria/notes/notes_system.dart'; /// נתוני הדיווח שנאספו מתיבת סימון הטקסט + פירוט הטעות שהמשתמש הקליד. class ReportedErrorData { @@ -226,6 +227,10 @@ class _TextBookViewerBlocState extends State // Bookmark Button _buildBookmarkButton(context, state), + // Notes Buttons + _buildShowNotesButton(context, state), + _buildAddNoteButton(context, state), + // Search Button (wide screen only) if (wideScreen) _buildSearchButton(context, state), @@ -332,6 +337,90 @@ class _TextBookViewerBlocState extends State ); } + Widget _buildShowNotesButton(BuildContext context, TextBookLoaded state) { + return IconButton( + onPressed: () { + // נוסיף event חדש ל-TextBookBloc להצגת/הסתרת הערות + context.read().add(const ToggleNotesSidebar()); + }, + icon: const Icon(Icons.notes), + tooltip: 'הצג הערות', + ); + } + + Widget _buildAddNoteButton(BuildContext context, TextBookLoaded state) { + return IconButton( + onPressed: () { + final selectedText = state.selectedTextForNote; + if (selectedText == null || selectedText.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('אנא בחר טקסט ליצירת הערה'), + duration: Duration(milliseconds: 1500), + ), + ); + return; + } + + // יצירת הערה עם הטקסט הנבחר + _showNoteEditor( + context, + selectedText, + state.selectedTextStart ?? 0, + state.selectedTextEnd ?? selectedText.length, + state.book.title, + ); + }, + icon: const Icon(Icons.note_add), + tooltip: 'הוסף הערה', + ); + } + + void _showNoteEditor(BuildContext context, String selectedText, int charStart, int charEnd, String bookId) { + showDialog( + context: context, + builder: (context) => NoteEditorDialog( + selectedText: selectedText, + bookId: bookId, + charStart: charStart, + charEnd: charEnd, + onSave: (noteRequest) async { + try { + final notesService = NotesIntegrationService.instance; + await notesService.createNoteFromSelection( + bookId, + selectedText, + charStart, + charEnd, + noteRequest.contentMarkdown, + tags: noteRequest.tags, + privacy: noteRequest.privacy, + ); + + if (context.mounted) { + Navigator.of(context).pop(); + // הצגת סרגל ההערות אם הוא לא פתוח + final currentState = context.read().state; + if (currentState is TextBookLoaded && !currentState.showNotesSidebar) { + context.read().add(const ToggleNotesSidebar()); + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('ההערה נוצרה והוצגה בסרגל')), + ); + } + } catch (e) { + if (context.mounted) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('שגיאה ביצירת הערה: $e')), + ); + } + } + }, + ), + ); + } + Widget _buildSearchButton(BuildContext context, TextBookLoaded state) { return IconButton( onPressed: () { diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d8d167e42..c3e5cfeaa 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -13,6 +13,7 @@ import path_provider_foundation import printing import screen_retriever import shared_preferences_foundation +import sqflite_darwin import url_launcher_macos import window_manager @@ -25,6 +26,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index f67465ec3..5bbc4d808 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -159,7 +159,7 @@ packages: source: hosted version: "8.9.3" characters: - dependency: transitive + dependency: "direct main" description: name: characters sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 @@ -1054,13 +1054,13 @@ packages: source: hosted version: "1.0.0+2" shared_preferences: - dependency: transitive + dependency: "direct main" description: name: shared_preferences - sha256: c59819dacc6669a1165d54d2735a9543f136f9b3cec94ca65cea6ab8dffc422e + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.5.3" shared_preferences_android: dependency: transitive description: @@ -1178,6 +1178,62 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_common_ffi: + dependency: "direct main" + description: + name: sqflite_common_ffi + sha256: "9faa2fedc5385ef238ce772589f7718c24cdddd27419b609bb9c6f703ea27988" + url: "https://pub.dev" + source: hosted + version: "2.3.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: f393d92c71bdcc118d6203d07c991b9be0f84b1a6f89dd4f7eed348131329924 + url: "https://pub.dev" + source: hosted + version: "2.9.0" stack_trace: dependency: transitive description: @@ -1451,5 +1507,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.7.0 <4.0.0" + dart: ">=3.8.0 <4.0.0" flutter: ">=3.29.0" diff --git a/pubspec.yaml b/pubspec.yaml index 1489d52ec..8160cddb9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -88,9 +88,11 @@ dependencies: archive: ^3.6.1 filter_list: 1.0.3 package_info_plus: ^8.0.2 - crypto: ^3.0.5 + crypto: ^3.0.6 path: ^1.9.0 http: ^1.4.0 + sqflite: ^2.4.2 + characters: ^1.3.0 flutter_document_picker: git: url: https://github.com/sidlatau/flutter_document_picker @@ -104,6 +106,8 @@ dependencies: flutter_spinbox: ^0.13.1 toggle_switch: ^2.3.0 logging: ^1.3.0 + sqflite_common_ffi: ^2.3.0 + shared_preferences: ^2.5.3 dependency_overrides: # it forces the version of the intl package to be 0.19.0 across all dependencies, even if some packages specify a different compatible version. diff --git a/test/notes/acceptance/notes_acceptance_test.dart b/test/notes/acceptance/notes_acceptance_test.dart new file mode 100644 index 000000000..9c1802c1d --- /dev/null +++ b/test/notes/acceptance/notes_acceptance_test.dart @@ -0,0 +1,643 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:otzaria/notes/services/notes_integration_service.dart'; +import '../test_helpers/test_setup.dart'; +import 'package:otzaria/notes/services/import_export_service.dart'; +import 'package:otzaria/notes/services/advanced_orphan_manager.dart'; +import 'package:otzaria/notes/services/text_normalizer.dart'; +import 'package:otzaria/notes/services/fuzzy_matcher.dart'; +import 'package:otzaria/notes/models/note.dart'; +import 'package:otzaria/notes/models/anchor_models.dart'; +import 'package:otzaria/notes/config/notes_config.dart'; + +void main() { + setUpAll(() { + TestSetup.initializeTestEnvironment(); + }); + + group('Notes Acceptance Tests', () { + late NotesIntegrationService integrationService; + late ImportExportService importExportService; + late AdvancedOrphanManager orphanManager; + + setUp(() { + integrationService = NotesIntegrationService.instance; + importExportService = ImportExportService.instance; + orphanManager = AdvancedOrphanManager.instance; + + // Clear caches + integrationService.clearCache(); + }); + + group('User Story: Creating Notes from Text Selection', () { + test('As a user, I can select text and create a note', () async { + // Given: A book with text content + const bookId = 'user-story-create'; + const bookText = 'זהו טקסט לדוגמה בעברית עם תוכן מעניין לבדיקה.'; + const selectedText = 'טקסט לדוגמה'; + const noteContent = 'זוהי הערה על הטקסט הנבחר'; + + // When: User selects text and creates a note + final note = await integrationService.createNoteFromSelection( + bookId, + selectedText, + 4, // Position of "טקסט לדוגמה" in the text + 16, + noteContent, + tags: ['דוגמה', 'בדיקה'], + privacy: NotePrivacy.private, + ); + + // Then: Note is created successfully with correct properties + expect(note.id, isNotEmpty); + expect(note.bookId, equals(bookId)); + expect(note.contentMarkdown, equals(noteContent)); + expect(note.charStart, equals(4)); + expect(note.charEnd, equals(16)); + expect(note.tags, containsAll(['דוגמה', 'בדיקה'])); + expect(note.privacy, equals(NotePrivacy.private)); + expect(note.status, equals(NoteStatus.anchored)); + expect(note.selectedTextNormalized, isNotEmpty); + }); + + test('As a user, I can create notes with Hebrew text and nikud', () async { + const bookId = 'hebrew-nikud-test'; + const bookText = 'בְּרֵאשִׁית בָּרָא אֱלֹהִים אֵת הַשָּׁמַיִם וְאֵת הָאָרֶץ'; + const selectedText = 'בְּרֵאשִׁית בָּרָא'; + const noteContent = 'הערה על פסוק הפתיחה'; + + final note = await integrationService.createNoteFromSelection( + bookId, + selectedText, + 0, + 13, + noteContent, + ); + + expect(note.selectedTextNormalized, isNotEmpty); + expect(note.textHash, isNotEmpty); + expect(note.contextBefore, isEmpty); // At beginning of text + expect(note.contextAfter, isNotEmpty); + }); + + test('As a user, I can create notes with RTL text and special characters', () async { + const bookId = 'rtl-special-test'; + const bookText = 'טקסט עם "מירכאות" ו־מקף וסימני פיסוק: כמו נקודה, פסיק!'; + const selectedText = '"מירכאות"'; + const noteContent = 'הערה על מירכאות בעברית'; + + final note = await integrationService.createNoteFromSelection( + bookId, + selectedText, + 10, + 20, + noteContent, + ); + + expect(note.selectedTextNormalized, isNotEmpty); + expect(note.status, equals(NoteStatus.anchored)); + }); + }); + + group('User Story: Viewing and Managing Notes', () { + test('As a user, I can view all my notes for a book', () async { + const bookId = 'view-notes-test'; + const bookText = 'ספר לדוגמה עם מספר הערות שונות לבדיקת התצוגה.'; + + // Create multiple notes + final note1 = await integrationService.createNoteFromSelection( + bookId, 'ספר לדוגמה', 0, 11, 'הערה ראשונה'); + final note2 = await integrationService.createNoteFromSelection( + bookId, 'הערות שונות', 25, 37, 'הערה שנייה'); + final note3 = await integrationService.createNoteFromSelection( + bookId, 'בדיקת התצוגה', 40, 54, 'הערה שלישית'); + + // Load notes for the book + final bookNotes = await integrationService.loadNotesForBook(bookId, bookText); + + expect(bookNotes.notes.length, equals(3)); + expect(bookNotes.notes.map((n) => n.id), containsAll([note1.id, note2.id, note3.id])); + + // Notes should be sorted by position + expect(bookNotes.notes[0].charStart, lessThan(bookNotes.notes[1].charStart)); + expect(bookNotes.notes[1].charStart, lessThan(bookNotes.notes[2].charStart)); + }); + + test('As a user, I can see notes only in the visible text range', () async { + const bookId = 'visible-range-test'; + + // Create notes at different positions + await integrationService.createNoteFromSelection( + bookId, 'early', 10, 15, 'Early note'); + await integrationService.createNoteFromSelection( + bookId, 'middle', 500, 506, 'Middle note'); + await integrationService.createNoteFromSelection( + bookId, 'late', 1000, 1004, 'Late note'); + + // Test different visible ranges + final earlyRange = integrationService.getNotesForVisibleRange( + bookId, const VisibleCharRange(0, 100)); + final middleRange = integrationService.getNotesForVisibleRange( + bookId, const VisibleCharRange(400, 600)); + final lateRange = integrationService.getNotesForVisibleRange( + bookId, const VisibleCharRange(900, 1100)); + + expect(earlyRange.length, equals(1)); + expect(earlyRange.first.charStart, equals(10)); + + expect(middleRange.length, equals(1)); + expect(middleRange.first.charStart, equals(500)); + + expect(lateRange.length, equals(1)); + expect(lateRange.first.charStart, equals(1000)); + }); + + test('As a user, I can see visual highlights for my notes', () async { + const bookId = 'highlights-test'; + + // Create notes with different statuses + await integrationService.createNoteFromSelection( + bookId, 'anchored', 10, 18, 'Anchored note'); + + // Get highlights for visible range + const visibleRange = VisibleCharRange(0, 100); + final highlights = integrationService.createHighlightsForRange(bookId, visibleRange); + + expect(highlights.length, equals(1)); + expect(highlights.first.start, equals(10)); + expect(highlights.first.end, equals(18)); + expect(highlights.first.status, equals(NoteStatus.anchored)); + expect(highlights.first.color, isNotNull); + expect(highlights.first.opacity, greaterThan(0)); + }); + }); + + group('User Story: Editing and Updating Notes', () { + test('As a user, I can edit the content of my notes', () async { + const bookId = 'edit-content-test'; + + // Create initial note + final originalNote = await integrationService.createNoteFromSelection( + bookId, 'original text', 10, 23, 'Original content'); + + // Update the note content + final updatedNote = await integrationService.updateNote( + originalNote.id, + 'Updated content with more details', + ); + + expect(updatedNote.id, equals(originalNote.id)); + expect(updatedNote.contentMarkdown, equals('Updated content with more details')); + expect(updatedNote.charStart, equals(originalNote.charStart)); // Position unchanged + expect(updatedNote.charEnd, equals(originalNote.charEnd)); + expect(updatedNote.updatedAt.isAfter(originalNote.updatedAt), isTrue); + }); + + test('As a user, I can add and modify tags on my notes', () async { + const bookId = 'edit-tags-test'; + + // Create note with initial tags + final note = await integrationService.createNoteFromSelection( + bookId, 'tagged text', 5, 16, 'Note with tags', + tags: ['initial', 'test']); + + // Update tags + final updatedNote = await integrationService.updateNote( + note.id, + null, // Don't change content + newTags: ['updated', 'modified', 'test'], // Keep 'test', add new ones + ); + + expect(updatedNote.tags, containsAll(['updated', 'modified', 'test'])); + expect(updatedNote.tags, isNot(contains('initial'))); + }); + + test('As a user, I can change the privacy level of my notes', () async { + const bookId = 'privacy-test'; + + // Create private note + final privateNote = await integrationService.createNoteFromSelection( + bookId, 'private text', 0, 12, 'Private note', + privacy: NotePrivacy.private); + + expect(privateNote.privacy, equals(NotePrivacy.private)); + + // Change to public + final publicNote = await integrationService.updateNote( + privateNote.id, + null, + newPrivacy: NotePrivacy.shared, + ); + + expect(publicNote.privacy, equals(NotePrivacy.shared)); + expect(publicNote.id, equals(privateNote.id)); + }); + }); + + group('User Story: Searching Notes', () { + test('As a user, I can search for notes by content', () async { + const bookId = 'search-content-test'; + + // Create notes with different content + await integrationService.createNoteFromSelection( + bookId, 'apple', 10, 15, 'Note about apples and fruit'); + await integrationService.createNoteFromSelection( + bookId, 'banana', 20, 26, 'Note about bananas'); + await integrationService.createNoteFromSelection( + bookId, 'cherry', 30, 36, 'Note about cherries and trees'); + + // Search for specific terms + final appleResults = await integrationService.searchNotes('apple', bookId: bookId); + final fruitResults = await integrationService.searchNotes('fruit', bookId: bookId); + final treeResults = await integrationService.searchNotes('tree', bookId: bookId); + + expect(appleResults.length, equals(1)); + expect(appleResults.first.contentMarkdown, contains('apples')); + + expect(fruitResults.length, equals(1)); + expect(fruitResults.first.contentMarkdown, contains('fruit')); + + expect(treeResults.length, equals(1)); + expect(treeResults.first.contentMarkdown, contains('trees')); + }); + + test('As a user, I can search for notes in Hebrew', () async { + const bookId = 'search-hebrew-test'; + + // Create Hebrew notes + await integrationService.createNoteFromSelection( + bookId, 'תפוח', 0, 4, 'הערה על תפוחים ופירות'); + await integrationService.createNoteFromSelection( + bookId, 'בננה', 10, 14, 'הערה על בננות'); + await integrationService.createNoteFromSelection( + bookId, 'דובדבן', 20, 26, 'הערה על דובדבנים ועצים'); + + // Search in Hebrew + final appleResults = await integrationService.searchNotes('תפוח', bookId: bookId); + final fruitResults = await integrationService.searchNotes('פירות', bookId: bookId); + + expect(appleResults.length, equals(1)); + expect(appleResults.first.contentMarkdown, contains('תפוחים')); + + expect(fruitResults.length, equals(1)); + expect(fruitResults.first.contentMarkdown, contains('פירות')); + }); + + test('As a user, I can search across multiple books', () async { + const book1Id = 'search-multi-book1'; + const book2Id = 'search-multi-book2'; + + // Create notes in different books + await integrationService.createNoteFromSelection( + book1Id, 'common term', 0, 11, 'Note in book 1 with common term'); + await integrationService.createNoteFromSelection( + book2Id, 'common term', 0, 11, 'Note in book 2 with common term'); + await integrationService.createNoteFromSelection( + book1Id, 'unique term', 20, 31, 'Note with unique term'); + + // Search across all books + final commonResults = await integrationService.searchNotes('common term'); + final uniqueResults = await integrationService.searchNotes('unique term'); + + expect(commonResults.length, equals(2)); + expect(commonResults.map((n) => n.bookId), containsAll([book1Id, book2Id])); + + expect(uniqueResults.length, equals(1)); + expect(uniqueResults.first.bookId, equals(book1Id)); + }); + }); + + group('User Story: Deleting Notes', () { + test('As a user, I can delete notes I no longer need', () async { + const bookId = 'delete-test'; + const bookText = 'Text for deletion testing.'; + + // Create note + final note = await integrationService.createNoteFromSelection( + bookId, 'to delete', 5, 14, 'This note will be deleted'); + + // Verify note exists + final beforeDelete = await integrationService.loadNotesForBook(bookId, bookText); + expect(beforeDelete.notes.length, equals(1)); + expect(beforeDelete.notes.first.id, equals(note.id)); + + // Delete note + await integrationService.deleteNote(note.id); + + // Verify note is gone + final afterDelete = await integrationService.loadNotesForBook(bookId, bookText); + expect(afterDelete.notes, isEmpty); + + // Verify note is not in search results + final searchResults = await integrationService.searchNotes('deleted', bookId: bookId); + expect(searchResults, isEmpty); + }); + + test('As a user, deleting a note removes it from all views', () async { + const bookId = 'delete-views-test'; + + // Create multiple notes + final note1 = await integrationService.createNoteFromSelection( + bookId, 'keep this', 0, 9, 'Note to keep'); + final note2 = await integrationService.createNoteFromSelection( + bookId, 'delete this', 20, 31, 'Note to delete'); + final note3 = await integrationService.createNoteFromSelection( + bookId, 'keep this too', 40, 52, 'Another note to keep'); + + // Delete middle note + await integrationService.deleteNote(note2.id); + + // Check visible range + final visibleNotes = integrationService.getNotesForVisibleRange( + bookId, const VisibleCharRange(0, 100)); + expect(visibleNotes.length, equals(2)); + expect(visibleNotes.map((n) => n.id), containsAll([note1.id, note3.id])); + expect(visibleNotes.map((n) => n.id), isNot(contains(note2.id))); + + // Check highlights + final highlights = integrationService.createHighlightsForRange( + bookId, const VisibleCharRange(0, 100)); + expect(highlights.length, equals(2)); + expect(highlights.map((h) => h.noteId), containsAll([note1.id, note3.id])); + expect(highlights.map((h) => h.noteId), isNot(contains(note2.id))); + }); + }); + + group('User Story: Import and Export Notes', () { + test('As a user, I can export my notes to backup them', () async { + const bookId = 'export-backup-test'; + + // Create notes to export + await integrationService.createNoteFromSelection( + bookId, 'export1', 0, 7, 'First note for export', + tags: ['export', 'backup']); + await integrationService.createNoteFromSelection( + bookId, 'export2', 10, 17, 'Second note for export', + tags: ['export', 'test']); + + // Export notes + final exportResult = await importExportService.exportNotes(bookId: bookId); + + expect(exportResult.success, isTrue); + expect(exportResult.notesCount, equals(2)); + expect(exportResult.jsonData, isNotNull); + expect(exportResult.fileSizeBytes, greaterThan(0)); + + // Verify export contains expected data + expect(exportResult.jsonData!, contains('First note for export')); + expect(exportResult.jsonData!, contains('Second note for export')); + expect(exportResult.jsonData!, contains('"tags": ["export", "backup"]')); + expect(exportResult.jsonData!, contains('"version": "1.0"')); + }); + + test('As a user, I can import notes from a backup', () async { + const originalBookId = 'import-original'; + const targetBookId = 'import-target'; + + // Create and export notes + await integrationService.createNoteFromSelection( + originalBookId, 'import test', 0, 11, 'Note for import test', + tags: ['import', 'test']); + + final exportResult = await importExportService.exportNotes(bookId: originalBookId); + expect(exportResult.success, isTrue); + + // Import to different book + final importResult = await importExportService.importNotes( + exportResult.jsonData!, + targetBookId: targetBookId, + ); + + expect(importResult.success, isTrue); + expect(importResult.totalNotes, equals(1)); + expect(importResult.importedCount, equals(1)); + expect(importResult.successRate, equals(100.0)); + + // Verify imported note + const targetBookText = 'Target book text for import test.'; + final targetNotes = await integrationService.loadNotesForBook(targetBookId, targetBookText); + expect(targetNotes.notes.length, equals(1)); + expect(targetNotes.notes.first.bookId, equals(targetBookId)); + expect(targetNotes.notes.first.contentMarkdown, equals('Note for import test')); + expect(targetNotes.notes.first.tags, containsAll(['import', 'test'])); + }); + + test('As a user, I can choose what to include in exports', () async { + const bookId = 'selective-export-test'; + + // Create notes with different privacy levels + await integrationService.createNoteFromSelection( + bookId, 'public note', 0, 11, 'This is public', + privacy: NotePrivacy.shared); + await integrationService.createNoteFromSelection( + bookId, 'private note', 20, 32, 'This is private', + privacy: NotePrivacy.private); + + // Export only public notes + final publicOnlyExport = await importExportService.exportNotes( + bookId: bookId, + includePrivateNotes: false, + ); + + expect(publicOnlyExport.success, isTrue); + expect(publicOnlyExport.notesCount, equals(1)); + expect(publicOnlyExport.jsonData!, contains('This is public')); + expect(publicOnlyExport.jsonData!, isNot(contains('This is private'))); + + // Export all notes + final allNotesExport = await importExportService.exportNotes( + bookId: bookId, + includePrivateNotes: true, + ); + + expect(allNotesExport.success, isTrue); + expect(allNotesExport.notesCount, equals(2)); + expect(allNotesExport.jsonData!, contains('This is public')); + expect(allNotesExport.jsonData!, contains('This is private')); + }); + }); + + group('User Story: Handling Text Changes and Re-anchoring', () { + test('As a user, my notes stay accurate when text has minor changes', () async { + // This test simulates the scenario where book text changes slightly + // and notes need to be re-anchored + + const bookId = 'reanchoring-test'; + const originalText = 'This is the original text with some content.'; + const modifiedText = 'This is the original text with some additional content.'; + + // Create note on original text + final note = await integrationService.createNoteFromSelection( + bookId, 'original text', 12, 25, 'Note on original text'); + + expect(note.status, equals(NoteStatus.anchored)); + + // Simulate loading book with modified text (this would trigger re-anchoring) + final modifiedBookNotes = await integrationService.loadNotesForBook(bookId, modifiedText); + + // Note should still be found, possibly with shifted status + expect(modifiedBookNotes.notes.length, equals(1)); + final reanchoredNote = modifiedBookNotes.notes.first; + + // Note should maintain its identity and content + expect(reanchoredNote.id, equals(note.id)); + expect(reanchoredNote.contentMarkdown, equals('Note on original text')); + + // Status might be shifted or anchored depending on the change + expect([NoteStatus.anchored, NoteStatus.shifted], contains(reanchoredNote.status)); + }); + + test('As a user, I am notified when notes become orphaned', () async { + const bookId = 'orphan-test'; + + // Create note + final note = await integrationService.createNoteFromSelection( + bookId, 'will be orphaned', 10, 26, 'This note will become orphaned'); + + // Simulate text change that makes the note orphaned + // (In a real scenario, this would happen when the selected text is completely removed) + + // For testing, we'll check the orphan analysis functionality + final orphanAnalysis = orphanManager.analyzeOrphans([note]); + + expect(orphanAnalysis.totalOrphans, equals(1)); + expect(orphanAnalysis.recommendations, isNotEmpty); + }); + }); + + group('Accuracy Requirements Validation', () { + test('should achieve 98% accuracy after 5% text changes', () async { + const bookId = 'accuracy-test'; + const originalText = 'This is a test document with multiple sentences. Each sentence contains different words and phrases. The document is used for testing note anchoring accuracy.'; + + // Create multiple notes + final notes = []; + final selections = [ + {'text': 'test document', 'start': 10, 'end': 23}, + {'text': 'multiple sentences', 'start': 29, 'end': 47}, + {'text': 'different words', 'start': 78, 'end': 93}, + {'text': 'testing note', 'start': 130, 'end': 142}, + ]; + + for (int i = 0; i < selections.length; i++) { + final selection = selections[i]; + final note = await integrationService.createNoteFromSelection( + bookId, + selection['text'] as String, + selection['start'] as int, + selection['end'] as int, + 'Test note $i', + ); + notes.add(note); + } + + // Simulate 5% text change (add ~8 characters to 160-character text) + const modifiedText = 'This is a comprehensive test document with multiple sentences. Each sentence contains different words and phrases. The document is used for testing note anchoring accuracy.'; + + // Load with modified text + final modifiedBookNotes = await integrationService.loadNotesForBook(bookId, modifiedText); + + // Calculate accuracy + final anchoredCount = modifiedBookNotes.notes.where((n) => + n.status == NoteStatus.anchored || n.status == NoteStatus.shifted).length; + final accuracy = anchoredCount / notes.length; + + expect(accuracy, greaterThanOrEqualTo(0.98)); // 98% accuracy requirement + }); + + test('should achieve 100% accuracy for whitespace-only changes', () async { + const bookId = 'whitespace-accuracy-test'; + const originalText = 'Text with normal spacing between words.'; + const whitespaceModifiedText = 'Text with normal spacing between words.'; + + // Create note + final note = await integrationService.createNoteFromSelection( + bookId, 'normal spacing', 10, 24, 'Note about spacing'); + + expect(note.status, equals(NoteStatus.anchored)); + + // Load with whitespace changes + final modifiedBookNotes = await integrationService.loadNotesForBook(bookId, whitespaceModifiedText); + + expect(modifiedBookNotes.notes.length, equals(1)); + expect(modifiedBookNotes.notes.first.status, equals(NoteStatus.anchored)); // Should remain anchored + }); + + test('should handle deleted text properly', () async { + const bookId = 'deletion-test'; + const originalText = 'This text will have a section removed from the middle part.'; + const deletedText = 'This text will have a section removed.'; // "from the middle part" removed + + // Create note on the part that will be deleted + final note = await integrationService.createNoteFromSelection( + bookId, 'middle part', 45, 56, 'Note on deleted section'); + + // Load with deleted text + final modifiedBookNotes = await integrationService.loadNotesForBook(bookId, deletedText); + + expect(modifiedBookNotes.notes.length, equals(1)); + expect(modifiedBookNotes.notes.first.status, equals(NoteStatus.orphan)); // Should be orphaned + }); + }); + + group('Text Normalization Consistency', () { + test('should produce consistent normalization results', () { + const testTexts = [ + 'טקסט עם "מירכאות" שונות', + 'טקסט עם ״מירכאות״ שונות', + 'טקסט עם "מירכאות" שונות', + ]; + + final config = TextNormalizer.createConfigFromSettings(); + final normalizedResults = testTexts.map((text) => + TextNormalizer.normalize(text, config)).toList(); + + // All variations should normalize to the same result + expect(normalizedResults[0], equals(normalizedResults[1])); + expect(normalizedResults[1], equals(normalizedResults[2])); + }); + + test('should handle Hebrew nikud consistently', () { + const textWithNikud = 'בְּרֵאשִׁית בָּרָא אֱלֹהִים'; + const textWithoutNikud = 'בראשית ברא אלהים'; + + final configKeepNikud = const NormalizationConfig(removeNikud: false); + final configRemoveNikud = const NormalizationConfig(removeNikud: true); + + final normalizedWithNikud = TextNormalizer.normalize(textWithNikud, configKeepNikud); + final normalizedWithoutNikud = TextNormalizer.normalize(textWithNikud, configRemoveNikud); + final originalWithoutNikud = TextNormalizer.normalize(textWithoutNikud, configRemoveNikud); + + expect(normalizedWithNikud, contains('ְ')); // Should contain nikud + expect(normalizedWithoutNikud, isNot(contains('ְ'))); // Should not contain nikud + expect(normalizedWithoutNikud, equals(originalWithoutNikud)); // Should match text without nikud + }); + }); + + group('Fuzzy Matching Validation', () { + test('should meet similarity thresholds', () { + const originalText = 'This is the original text'; + const similarText = 'This is the original text with addition'; + const differentText = 'Completely different content here'; + + final similarityHigh = FuzzyMatcher.calculateCombinedSimilarity(originalText, similarText); + final similarityLow = FuzzyMatcher.calculateCombinedSimilarity(originalText, differentText); + + expect(similarityHigh, greaterThan(AnchoringConstants.jaccardThreshold)); + expect(similarityLow, lessThan(AnchoringConstants.jaccardThreshold)); + }); + + test('should handle Hebrew text similarity correctly', () { + const hebrewOriginal = 'זהו טקסט בעברית לבדיקה'; + const hebrewSimilar = 'זהו טקסט בעברית לבדיקת דמיון'; + const hebrewDifferent = 'טקסט שונה לחלוטין בעברית'; + + final similarityHigh = FuzzyMatcher.calculateCombinedSimilarity(hebrewOriginal, hebrewSimilar); + final similarityLow = FuzzyMatcher.calculateCombinedSimilarity(hebrewOriginal, hebrewDifferent); + + expect(similarityHigh, greaterThan(0.5)); + expect(similarityLow, lessThan(0.5)); + }); + }); + }); +} \ No newline at end of file diff --git a/test/notes/bloc/notes_bloc_test.dart b/test/notes/bloc/notes_bloc_test.dart new file mode 100644 index 000000000..3ac1cdc21 --- /dev/null +++ b/test/notes/bloc/notes_bloc_test.dart @@ -0,0 +1,255 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:otzaria/notes/bloc/notes_bloc.dart'; +import 'package:otzaria/notes/bloc/notes_event.dart'; +import 'package:otzaria/notes/bloc/notes_state.dart'; +import 'package:otzaria/notes/models/note.dart'; +import 'package:otzaria/notes/models/anchor_models.dart'; +import 'package:otzaria/notes/repository/notes_repository.dart'; + +void main() { + setUpAll(() { + // Initialize SQLite FFI for testing + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + group('NotesBloc Tests', () { + late NotesBloc notesBloc; + + setUp(() { + notesBloc = NotesBloc(); + }); + + tearDown(() { + notesBloc.close(); + }); + + test('initial state is NotesInitial', () { + expect(notesBloc.state, equals(const NotesInitial())); + }); + + group('LoadNotesEvent', () { + blocTest( + 'emits NotesLoading when loading notes', + build: () => notesBloc, + act: (bloc) => bloc.add(const LoadNotesEvent('test-book')), + expect: () => [ + const NotesLoading(message: 'טוען הערות...'), + ], + wait: const Duration(milliseconds: 50), + ); + }); + + group('CreateNoteEvent', () { + blocTest( + 'emits NoteOperationInProgress when creating note', + build: () => notesBloc, + act: (bloc) => bloc.add(CreateNoteEvent(_createTestNoteRequest())), + expect: () => [ + const NoteOperationInProgress(operation: 'יוצר הערה...'), + isA(), // Expected to fail without proper setup + ], + wait: const Duration(milliseconds: 100), + ); + }); + + group('SearchNotesEvent', () { + blocTest( + 'emits NotesLoading when searching', + build: () => notesBloc, + act: (bloc) => bloc.add(const SearchNotesEvent('test query')), + expect: () => [ + const NotesLoading(message: 'מחפש הערות...'), + isA(), // Should return empty results + ], + wait: const Duration(milliseconds: 100), + ); + + blocTest( + 'emits empty results for empty query', + build: () => notesBloc, + act: (bloc) => bloc.add(const SearchNotesEvent('')), + expect: () => [ + isA(), + ], + verify: (bloc) { + final state = bloc.state as NotesSearchResults; + expect(state.query, equals('')); + expect(state.results, isEmpty); + }, + ); + }); + + group('ToggleHighlightingEvent', () { + blocTest( + 'updates highlighting when in NotesLoaded state', + build: () => notesBloc, + seed: () => NotesLoaded( + bookId: 'test-book', + notes: const [], + lastUpdated: DateTime.now(), + ), + act: (bloc) => bloc.add(const ToggleHighlightingEvent(false)), + expect: () => [ + isA(), + ], + verify: (bloc) { + final state = bloc.state as NotesLoaded; + expect(state.highlightingEnabled, isFalse); + }, + ); + }); + + group('SelectNoteEvent', () { + final testNote = _createTestNote(); + + blocTest( + 'selects note when in NotesLoaded state', + build: () => notesBloc, + seed: () => NotesLoaded( + bookId: 'test-book', + notes: [testNote], + lastUpdated: DateTime.now(), + ), + act: (bloc) => bloc.add(SelectNoteEvent(testNote.id)), + expect: () => [ + isA(), + ], + verify: (bloc) { + final state = bloc.state as NotesLoaded; + expect(state.selectedNote?.id, equals(testNote.id)); + }, + ); + }); + + group('UpdateVisibleRangeEvent', () { + const testRange = VisibleCharRange(100, 200); + + blocTest( + 'updates visible range when in NotesLoaded state', + build: () => notesBloc, + seed: () => NotesLoaded( + bookId: 'test-book', + notes: const [], + lastUpdated: DateTime.now(), + ), + act: (bloc) => bloc.add(const UpdateVisibleRangeEvent('test-book', testRange)), + expect: () => [ + isA(), + ], + verify: (bloc) { + final state = bloc.state as NotesLoaded; + expect(state.visibleRange, equals(testRange)); + }, + ); + }); + + group('CancelOperationsEvent', () { + blocTest( + 'cancels operations and returns to initial state', + build: () => notesBloc, + act: (bloc) => bloc.add(const CancelOperationsEvent()), + expect: () => [ + const NotesInitial(), + ], + ); + }); + }); + + group('NotesLoaded State Tests', () { + late NotesLoaded state; + late List testNotes; + + setUp(() { + testNotes = [ + _createTestNote(id: '1', status: NoteStatus.anchored, charStart: 50, charEnd: 100), + _createTestNote(id: '2', status: NoteStatus.shifted, charStart: 150, charEnd: 200), + _createTestNote(id: '3', status: NoteStatus.orphan, charStart: 250, charEnd: 300), + ]; + + state = NotesLoaded( + bookId: 'test-book', + notes: testNotes, + visibleRange: const VisibleCharRange(75, 175), + lastUpdated: DateTime.now(), + ); + }); + + test('should return visible notes correctly', () { + final visibleNotes = state.visibleNotes; + expect(visibleNotes.length, equals(2)); // Notes 1 and 2 should be visible + expect(visibleNotes.map((n) => n.id), containsAll(['1', '2'])); + }); + + test('should count notes by status correctly', () { + expect(state.anchoredCount, equals(1)); + expect(state.shiftedCount, equals(1)); + expect(state.orphanCount, equals(1)); + }); + + test('should get notes by status correctly', () { + final anchoredNotes = state.getNotesByStatus(NoteStatus.anchored); + expect(anchoredNotes.length, equals(1)); + expect(anchoredNotes.first.id, equals('1')); + }); + + test('copyWith should update specified fields only', () { + final updatedState = state.copyWith( + highlightingEnabled: false, + selectedNote: testNotes.first, + ); + + expect(updatedState.highlightingEnabled, isFalse); + expect(updatedState.selectedNote, equals(testNotes.first)); + expect(updatedState.bookId, equals(state.bookId)); // unchanged + expect(updatedState.notes, equals(state.notes)); // unchanged + }); + }); +} + +/// Helper function to create a test note request +CreateNoteRequest _createTestNoteRequest() { + return const CreateNoteRequest( + bookId: 'test-book', + charStart: 100, + charEnd: 150, + contentMarkdown: 'Test note content', + authorUserId: 'test-user', + privacy: NotePrivacy.private, + tags: ['test'], + ); +} + +/// Helper function to create a test note +Note _createTestNote({ + String? id, + NoteStatus? status, + int? charStart, + int? charEnd, +}) { + return Note( + id: id ?? 'test-note-1', + bookId: 'test-book', + docVersionId: 'version-1', + charStart: charStart ?? 100, + charEnd: charEnd ?? 150, + selectedTextNormalized: 'test text', + textHash: 'hash123', + contextBefore: 'before', + contextAfter: 'after', + contextBeforeHash: 'before-hash', + contextAfterHash: 'after-hash', + rollingBefore: 12345, + rollingAfter: 67890, + status: status ?? NoteStatus.anchored, + contentMarkdown: 'Test note content', + authorUserId: 'test-user', + privacy: NotePrivacy.private, + tags: const ['test'], + createdAt: DateTime(2024, 1, 1), + updatedAt: DateTime(2024, 1, 1), + normalizationConfig: 'norm=v1;nikud=keep;quotes=ascii;unicode=NFKC', + ); +} \ No newline at end of file diff --git a/test/notes/integration/notes_integration_test.dart b/test/notes/integration/notes_integration_test.dart new file mode 100644 index 000000000..09ab2a843 --- /dev/null +++ b/test/notes/integration/notes_integration_test.dart @@ -0,0 +1,510 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:otzaria/notes/services/notes_integration_service.dart'; +import '../test_helpers/test_setup.dart'; +import 'package:otzaria/notes/services/import_export_service.dart'; +import 'package:otzaria/notes/services/filesystem_notes_extension.dart'; +import 'package:otzaria/notes/models/note.dart'; +import 'package:otzaria/notes/models/anchor_models.dart'; + +void main() { + setUpAll(() { + TestSetup.initializeTestEnvironment(); + }); + + group('Notes Integration Tests', () { + late NotesIntegrationService integrationService; + late ImportExportService importExportService; + late FileSystemNotesExtension filesystemExtension; + + setUp(() { + integrationService = NotesIntegrationService.instance; + importExportService = ImportExportService.instance; + filesystemExtension = FileSystemNotesExtension.instance; + + // Clear caches before each test + integrationService.clearCache(); + filesystemExtension.clearCanonicalCache(); + }); + + group('Notes Integration Service', () { + test('should load notes for book with empty result', () async { + const bookId = 'test-book-1'; + const bookText = 'This is a test book with some content for testing.'; + + final result = await integrationService.loadNotesForBook(bookId, bookText); + + expect(result.bookId, equals(bookId)); + expect(result.notes, isEmpty); + expect(result.fromCache, isFalse); + expect(result.loadTime.inMilliseconds, greaterThan(0)); + }); + + test('should create note from selection', () async { + const bookId = 'test-book-2'; + const selectedText = 'selected text'; + const noteContent = 'This is a test note'; + + final note = await integrationService.createNoteFromSelection( + bookId, + selectedText, + 10, + 23, + noteContent, + tags: ['test', 'integration'], + privacy: NotePrivacy.private, + ); + + expect(note.bookId, equals(bookId)); + expect(note.contentMarkdown, equals(noteContent)); + expect(note.charStart, equals(10)); + expect(note.charEnd, equals(23)); + expect(note.tags, containsAll(['test', 'integration'])); + expect(note.privacy, equals(NotePrivacy.private)); + }); + + test('should get notes for visible range', () async { + const bookId = 'test-book-3'; + + // Create some test notes + await integrationService.createNoteFromSelection( + bookId, 'text1', 10, 15, 'Note 1'); + await integrationService.createNoteFromSelection( + bookId, 'text2', 50, 55, 'Note 2'); + await integrationService.createNoteFromSelection( + bookId, 'text3', 100, 105, 'Note 3'); + + // Test visible range that includes first two notes + const visibleRange = VisibleCharRange(0, 60); + final visibleNotes = integrationService.getNotesForVisibleRange(bookId, visibleRange); + + expect(visibleNotes.length, equals(2)); + expect(visibleNotes[0].charStart, equals(10)); + expect(visibleNotes[1].charStart, equals(50)); + }); + + test('should create highlights for range', () async { + const bookId = 'test-book-4'; + + // Create test notes + await integrationService.createNoteFromSelection( + bookId, 'text1', 10, 20, 'Note 1'); + await integrationService.createNoteFromSelection( + bookId, 'text2', 30, 40, 'Note 2'); + + const visibleRange = VisibleCharRange(5, 45); + final highlights = integrationService.createHighlightsForRange(bookId, visibleRange); + + expect(highlights.length, equals(2)); + expect(highlights[0].start, equals(10)); + expect(highlights[0].end, equals(20)); + expect(highlights[1].start, equals(30)); + expect(highlights[1].end, equals(40)); + }); + + test('should update note', () async { + const bookId = 'test-book-5'; + + // Create initial note + final originalNote = await integrationService.createNoteFromSelection( + bookId, 'original', 10, 18, 'Original content'); + + // Update the note + final updatedNote = await integrationService.updateNote( + originalNote.id, + 'Updated content', + newTags: ['updated'], + newPrivacy: NotePrivacy.shared, + ); + + expect(updatedNote.id, equals(originalNote.id)); + expect(updatedNote.contentMarkdown, equals('Updated content')); + expect(updatedNote.tags, contains('updated')); + expect(updatedNote.privacy, equals(NotePrivacy.shared)); + }); + + test('should delete note', () async { + const bookId = 'test-book-6'; + + // Create note + final note = await integrationService.createNoteFromSelection( + bookId, 'to delete', 10, 19, 'Will be deleted'); + + // Delete the note + await integrationService.deleteNote(note.id); + + // Verify it's gone from visible range + const visibleRange = VisibleCharRange(0, 100); + final visibleNotes = integrationService.getNotesForVisibleRange(bookId, visibleRange); + + expect(visibleNotes, isEmpty); + }); + + test('should search notes', () async { + const bookId = 'test-book-7'; + + // Create test notes + await integrationService.createNoteFromSelection( + bookId, 'apple', 10, 15, 'Note about apples'); + await integrationService.createNoteFromSelection( + bookId, 'banana', 20, 26, 'Note about bananas'); + await integrationService.createNoteFromSelection( + bookId, 'cherry', 30, 36, 'Note about cherries'); + + // Search for notes + final results = await integrationService.searchNotes('apple', bookId: bookId); + + expect(results.length, equals(1)); + expect(results.first.contentMarkdown, contains('apples')); + }); + + test('should handle cache correctly', () async { + const bookId = 'test-book-8'; + const bookText = 'Test book content for caching'; + + // First load - should not be from cache + final result1 = await integrationService.loadNotesForBook(bookId, bookText); + expect(result1.fromCache, isFalse); + + // Second load - should be from cache + final result2 = await integrationService.loadNotesForBook(bookId, bookText); + expect(result2.fromCache, isTrue); + expect(result2.loadTime.inMilliseconds, lessThan(result1.loadTime.inMilliseconds)); + + // Clear cache and load again - should not be from cache + integrationService.clearCache(bookId: bookId); + final result3 = await integrationService.loadNotesForBook(bookId, bookText); + expect(result3.fromCache, isFalse); + }); + + test('should provide cache statistics', () { + final stats = integrationService.getCacheStats(); + + expect(stats.keys, contains('cached_books')); + expect(stats.keys, contains('total_cached_notes')); + expect(stats.keys, contains('oldest_cache_age_minutes')); + expect(stats['cached_books'], isA()); + expect(stats['total_cached_notes'], isA()); + }); + }); + + group('Import/Export Service', () { + test('should export notes to JSON', () async { + const bookId = 'export-test-book'; + + // Create test notes + await integrationService.createNoteFromSelection( + bookId, 'export1', 10, 17, 'Export note 1', tags: ['export']); + await integrationService.createNoteFromSelection( + bookId, 'export2', 20, 27, 'Export note 2', tags: ['export']); + + // Export notes + final result = await importExportService.exportNotes(bookId: bookId); + + expect(result.success, isTrue); + expect(result.notesCount, equals(2)); + expect(result.jsonData, isNotNull); + expect(result.fileSizeBytes, greaterThan(0)); + + // Verify JSON structure + expect(result.jsonData!, contains('"version": "1.0"')); + expect(result.jsonData!, contains('"notes":')); + expect(result.jsonData!, contains('Export note 1')); + expect(result.jsonData!, contains('Export note 2')); + }); + + test('should import notes from JSON', () async { + // Create test JSON data + final testJson = ''' + { + "version": "1.0", + "exported_at": "2024-01-01T00:00:00.000Z", + "export_metadata": { + "book_id": "import-test-book", + "total_notes": 1, + "include_orphans": true, + "include_private": true, + "app_version": "1.0.0" + }, + "notes": [ + { + "id": "import-test-note-1", + "book_id": "import-test-book", + "doc_version_id": "version-1", + "logical_path": null, + "char_start": 10, + "char_end": 20, + "selected_text_normalized": "test text", + "text_hash": "hash123", + "context_before": "before", + "context_after": "after", + "context_before_hash": "before-hash", + "context_after_hash": "after-hash", + "rolling_before": 12345, + "rolling_after": 67890, + "status": "anchored", + "content_markdown": "Imported test note", + "author_user_id": "test-user", + "privacy": "private", + "tags": ["imported", "test"], + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-01T00:00:00.000Z", + "normalization_config": "norm=v1;nikud=keep;quotes=ascii;unicode=NFKC" + } + ] + } + '''; + + // Import notes + final result = await importExportService.importNotes(testJson); + + expect(result.success, isTrue); + expect(result.totalNotes, equals(1)); + expect(result.importedCount, equals(1)); + expect(result.skippedCount, equals(0)); + expect(result.errorCount, equals(0)); + expect(result.successRate, equals(100.0)); + }); + + test('should handle import errors gracefully', () async { + // Test with invalid JSON + final result1 = await importExportService.importNotes('invalid json'); + expect(result1.success, isFalse); + expect(result1.errorCount, equals(1)); + + // Test with missing version + final result2 = await importExportService.importNotes('{"notes": []}'); + expect(result2.success, isFalse); + expect(result2.errors.first, contains('Missing version field')); + }); + + test('should filter notes during export', () async { + const bookId = 'filter-test-book'; + + // Create notes with different statuses and privacy + await integrationService.createNoteFromSelection( + bookId, 'private', 10, 17, 'Private note', privacy: NotePrivacy.private); + await integrationService.createNoteFromSelection( + bookId, 'public', 20, 26, 'Public note', privacy: NotePrivacy.shared); + + // Export without private notes + final result = await importExportService.exportNotes( + bookId: bookId, + includePrivateNotes: false, + ); + + expect(result.success, isTrue); + expect(result.notesCount, equals(1)); + expect(result.jsonData!, contains('Public note')); + expect(result.jsonData!, isNot(contains('Private note'))); + }); + }); + + group('FileSystem Notes Extension', () { + test('should get canonical document', () async { + const bookId = 'filesystem-test-book'; + const bookText = 'This is test content for filesystem extension.'; + + final canonicalDoc = await filesystemExtension.getCanonicalDocument(bookId, bookText); + + expect(canonicalDoc.bookId, equals(bookId)); + expect(canonicalDoc.canonicalText, isNotEmpty); + expect(canonicalDoc.versionId, isNotEmpty); + }); + + test('should detect book content changes', () async { + const bookId = 'change-test-book'; + const originalText = 'Original book content'; + const modifiedText = 'Modified book content'; + + // First load + await filesystemExtension.getCanonicalDocument(bookId, originalText); + expect(filesystemExtension.hasBookContentChanged(bookId, originalText), isFalse); + + // Check with modified content + expect(filesystemExtension.hasBookContentChanged(bookId, modifiedText), isTrue); + }); + + test('should provide book version info', () async { + const bookId = 'version-test-book'; + const bookText = 'Book content for version testing'; + + // Get version info before any canonical document + final info1 = filesystemExtension.getBookVersionInfo(bookId, bookText); + expect(info1.isFirstTime, isTrue); + expect(info1.hasChanged, isFalse); + + // Create canonical document + await filesystemExtension.getCanonicalDocument(bookId, bookText); + + // Get version info after canonical document creation + final info2 = filesystemExtension.getBookVersionInfo(bookId, bookText); + expect(info2.isFirstTime, isFalse); + expect(info2.hasChanged, isFalse); + expect(info2.currentVersion, isNotEmpty); + }); + + test('should cache canonical documents', () async { + const bookId = 'cache-test-book'; + const bookText = 'Content for cache testing'; + + // First call - should create new document + final stopwatch1 = Stopwatch()..start(); + final doc1 = await filesystemExtension.getCanonicalDocument(bookId, bookText); + stopwatch1.stop(); + + // Second call - should use cache + final stopwatch2 = Stopwatch()..start(); + final doc2 = await filesystemExtension.getCanonicalDocument(bookId, bookText); + stopwatch2.stop(); + + expect(doc1.versionId, equals(doc2.versionId)); + expect(stopwatch2.elapsedMilliseconds, lessThan(stopwatch1.elapsedMilliseconds)); + }); + + test('should provide cache statistics', () { + final stats = filesystemExtension.getCacheStats(); + + expect(stats.keys, contains('cached_documents')); + expect(stats.keys, contains('average_cache_age_minutes')); + expect(stats.keys, contains('oldest_cache_minutes')); + expect(stats.keys, contains('cache_memory_estimate_mb')); + expect(stats['cached_documents'], isA()); + }); + + test('should optimize cache', () async { + // Create multiple cached documents + for (int i = 0; i < 5; i++) { + await filesystemExtension.getCanonicalDocument( + 'book-$i', + 'Content for book $i' + ); + } + + final statsBefore = filesystemExtension.getCacheStats(); + expect(statsBefore['cached_documents'], equals(5)); + + // Optimize cache + filesystemExtension.optimizeCache(); + + final statsAfter = filesystemExtension.getCacheStats(); + expect(statsAfter['cached_documents'], lessThanOrEqualTo(5)); + }); + + test('should export and import cache data', () async { + const bookId = 'export-cache-book'; + const bookText = 'Content for cache export test'; + + // Create cached document + await filesystemExtension.getCanonicalDocument(bookId, bookText); + + // Export cache data + final exportData = filesystemExtension.exportCacheData(); + expect(exportData.keys, contains('version')); + expect(exportData.keys, contains('book_versions')); + expect(exportData.keys, contains('cache_timestamps')); + + // Clear cache + filesystemExtension.clearCanonicalCache(); + expect(filesystemExtension.getCacheStats()['cached_documents'], equals(0)); + + // Import cache data + filesystemExtension.importCacheData(exportData); + + // Verify import worked (version should be restored) + final versionInfo = filesystemExtension.getBookVersionInfo(bookId, bookText); + expect(versionInfo.cachedVersion, isNotNull); + }); + }); + + group('End-to-End Integration', () { + test('should handle complete note lifecycle', () async { + const bookId = 'e2e-test-book'; + const bookText = 'This is a complete end-to-end test book with various content.'; + + // 1. Load book notes (should be empty initially) + final initialLoad = await integrationService.loadNotesForBook(bookId, bookText); + expect(initialLoad.notes, isEmpty); + + // 2. Create a note from selection + final note = await integrationService.createNoteFromSelection( + bookId, + 'end-to-end test', + 20, + 35, + 'This is an end-to-end test note', + tags: ['e2e', 'test'], + ); + expect(note.bookId, equals(bookId)); + + // 3. Load book notes again (should include the new note) + final secondLoad = await integrationService.loadNotesForBook(bookId, bookText); + expect(secondLoad.notes.length, equals(1)); + expect(secondLoad.notes.first.id, equals(note.id)); + + // 4. Update the note + final updatedNote = await integrationService.updateNote( + note.id, + 'Updated end-to-end test note', + newTags: ['e2e', 'test', 'updated'], + ); + expect(updatedNote.contentMarkdown, equals('Updated end-to-end test note')); + expect(updatedNote.tags, contains('updated')); + + // 5. Export the note + final exportResult = await importExportService.exportNotes(bookId: bookId); + expect(exportResult.success, isTrue); + expect(exportResult.notesCount, equals(1)); + + // 6. Delete the note + await integrationService.deleteNote(note.id); + + // 7. Verify note is deleted + final finalLoad = await integrationService.loadNotesForBook(bookId, bookText); + expect(finalLoad.notes, isEmpty); + + // 8. Import the note back + final importResult = await importExportService.importNotes(exportResult.jsonData!); + expect(importResult.success, isTrue); + expect(importResult.importedCount, equals(1)); + + // 9. Verify note is back + final restoredLoad = await integrationService.loadNotesForBook(bookId, bookText); + expect(restoredLoad.notes.length, equals(1)); + expect(restoredLoad.notes.first.contentMarkdown, equals('Updated end-to-end test note')); + }); + + test('should handle multiple books and cross-book operations', () async { + const book1Id = 'multi-book-1'; + const book2Id = 'multi-book-2'; + const book1Text = 'Content for first book in multi-book test.'; + const book2Text = 'Content for second book in multi-book test.'; + + // Create notes in both books + await integrationService.createNoteFromSelection( + book1Id, 'book1 note', 10, 20, 'Note in book 1'); + await integrationService.createNoteFromSelection( + book2Id, 'book2 note', 10, 20, 'Note in book 2'); + + // Load notes for each book separately + final book1Notes = await integrationService.loadNotesForBook(book1Id, book1Text); + final book2Notes = await integrationService.loadNotesForBook(book2Id, book2Text); + + expect(book1Notes.notes.length, equals(1)); + expect(book2Notes.notes.length, equals(1)); + expect(book1Notes.notes.first.bookId, equals(book1Id)); + expect(book2Notes.notes.first.bookId, equals(book2Id)); + + // Search across both books (if supported) + final searchResults = await integrationService.searchNotes('Note in book'); + expect(searchResults.length, greaterThanOrEqualTo(2)); + + // Export notes from specific book + final book1Export = await importExportService.exportNotes(bookId: book1Id); + expect(book1Export.notesCount, equals(1)); + expect(book1Export.jsonData!, contains('Note in book 1')); + expect(book1Export.jsonData!, isNot(contains('Note in book 2'))); + }); + }); + }); +} \ No newline at end of file diff --git a/test/notes/models/note_test.dart b/test/notes/models/note_test.dart new file mode 100644 index 000000000..be696d456 --- /dev/null +++ b/test/notes/models/note_test.dart @@ -0,0 +1,170 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:otzaria/notes/models/note.dart'; + +void main() { + group('Note Model Tests', () { + late Note testNote; + + setUp(() { + testNote = Note( + id: 'test-note-1', + bookId: 'test-book', + docVersionId: 'version-1', + charStart: 100, + charEnd: 150, + selectedTextNormalized: 'test text', + textHash: 'hash123', + contextBefore: 'before text', + contextAfter: 'after text', + contextBeforeHash: 'before-hash', + contextAfterHash: 'after-hash', + rollingBefore: 12345, + rollingAfter: 67890, + status: NoteStatus.anchored, + contentMarkdown: 'This is a test note', + authorUserId: 'user-1', + privacy: NotePrivacy.private, + tags: ['test', 'example'], + createdAt: DateTime(2024, 1, 1), + updatedAt: DateTime(2024, 1, 1), + normalizationConfig: 'norm=v1;nikud=keep;quotes=ascii;unicode=NFKC', + ); + }); + + test('should create note with all required fields', () { + expect(testNote.id, equals('test-note-1')); + expect(testNote.bookId, equals('test-book')); + expect(testNote.status, equals(NoteStatus.anchored)); + expect(testNote.privacy, equals(NotePrivacy.private)); + expect(testNote.tags, equals(['test', 'example'])); + }); + + test('should convert to JSON correctly', () { + final json = testNote.toJson(); + + expect(json['note_id'], equals('test-note-1')); + expect(json['book_id'], equals('test-book')); + expect(json['status'], equals('anchored')); + expect(json['privacy'], equals('private')); + expect(json['tags'], equals('test,example')); + expect(json['char_start'], equals(100)); + expect(json['char_end'], equals(150)); + }); + + test('should create from JSON correctly', () { + final json = testNote.toJson(); + final recreatedNote = Note.fromJson(json); + + expect(recreatedNote.id, equals(testNote.id)); + expect(recreatedNote.bookId, equals(testNote.bookId)); + expect(recreatedNote.status, equals(testNote.status)); + expect(recreatedNote.privacy, equals(testNote.privacy)); + expect(recreatedNote.tags, equals(testNote.tags)); + expect(recreatedNote.charStart, equals(testNote.charStart)); + expect(recreatedNote.charEnd, equals(testNote.charEnd)); + }); + + test('should handle empty tags correctly', () { + final noteWithoutTags = testNote.copyWith(tags: []); + final json = noteWithoutTags.toJson(); + final recreated = Note.fromJson(json); + + expect(recreated.tags, isEmpty); + }); + + test('should handle null logical path correctly', () { + expect(testNote.logicalPath, isNull); + + final json = testNote.toJson(); + expect(json['logical_path'], isNull); + + final recreated = Note.fromJson(json); + expect(recreated.logicalPath, isNull); + }); + + test('should handle logical path correctly', () { + final noteWithPath = testNote.copyWith( + logicalPath: ['chapter:1', 'section:2'], + ); + + final json = noteWithPath.toJson(); + expect(json['logical_path'], equals('chapter:1,section:2')); + + final recreated = Note.fromJson(json); + expect(recreated.logicalPath, equals(['chapter:1', 'section:2'])); + }); + + test('copyWith should update specified fields only', () { + final updatedNote = testNote.copyWith( + contentMarkdown: 'Updated content', + status: NoteStatus.shifted, + ); + + expect(updatedNote.contentMarkdown, equals('Updated content')); + expect(updatedNote.status, equals(NoteStatus.shifted)); + expect(updatedNote.id, equals(testNote.id)); // unchanged + expect(updatedNote.bookId, equals(testNote.bookId)); // unchanged + }); + + test('should have proper equality', () { + final identicalNote = Note( + id: testNote.id, + bookId: testNote.bookId, + docVersionId: testNote.docVersionId, + charStart: testNote.charStart, + charEnd: testNote.charEnd, + selectedTextNormalized: testNote.selectedTextNormalized, + textHash: testNote.textHash, + contextBefore: testNote.contextBefore, + contextAfter: testNote.contextAfter, + contextBeforeHash: testNote.contextBeforeHash, + contextAfterHash: testNote.contextAfterHash, + rollingBefore: testNote.rollingBefore, + rollingAfter: testNote.rollingAfter, + status: testNote.status, + contentMarkdown: testNote.contentMarkdown, + authorUserId: testNote.authorUserId, + privacy: testNote.privacy, + tags: testNote.tags, + createdAt: testNote.createdAt, + updatedAt: testNote.updatedAt, + normalizationConfig: testNote.normalizationConfig, + ); + + expect(testNote, equals(identicalNote)); + }); + + test('toString should provide useful information', () { + final string = testNote.toString(); + expect(string, contains('test-note-1')); + expect(string, contains('test-book')); + expect(string, contains('anchored')); + }); + }); + + group('NoteStatus Tests', () { + test('should have correct enum values', () { + expect(NoteStatus.anchored.name, equals('anchored')); + expect(NoteStatus.shifted.name, equals('shifted')); + expect(NoteStatus.orphan.name, equals('orphan')); + }); + + test('should parse from string correctly', () { + expect(NoteStatus.values.byName('anchored'), equals(NoteStatus.anchored)); + expect(NoteStatus.values.byName('shifted'), equals(NoteStatus.shifted)); + expect(NoteStatus.values.byName('orphan'), equals(NoteStatus.orphan)); + }); + }); + + group('NotePrivacy Tests', () { + test('should have correct enum values', () { + expect(NotePrivacy.private.name, equals('private')); + expect(NotePrivacy.shared.name, equals('shared')); + }); + + test('should parse from string correctly', () { + expect(NotePrivacy.values.byName('private'), equals(NotePrivacy.private)); + expect(NotePrivacy.values.byName('shared'), equals(NotePrivacy.shared)); + }); + }); +} \ No newline at end of file diff --git a/test/notes/performance/notes_performance_test.dart b/test/notes/performance/notes_performance_test.dart new file mode 100644 index 000000000..0d4cecf3b --- /dev/null +++ b/test/notes/performance/notes_performance_test.dart @@ -0,0 +1,511 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:otzaria/notes/services/notes_integration_service.dart'; +import '../test_helpers/test_setup.dart'; +import 'package:otzaria/notes/services/advanced_orphan_manager.dart'; +import 'package:otzaria/notes/services/smart_batch_processor.dart'; +import 'package:otzaria/notes/services/performance_optimizer.dart'; +import 'package:otzaria/notes/services/advanced_search_engine.dart'; +import 'package:otzaria/notes/models/note.dart'; +import 'package:otzaria/notes/models/anchor_models.dart'; +import 'package:otzaria/notes/config/notes_config.dart'; + +void main() { + setUpAll(() { + TestSetup.initializeTestEnvironment(); + }); + + group('Notes Performance Tests', () { + late NotesIntegrationService integrationService; + late AdvancedOrphanManager orphanManager; + late SmartBatchProcessor batchProcessor; + late PerformanceOptimizer performanceOptimizer; + late AdvancedSearchEngine searchEngine; + + setUp(() { + integrationService = NotesIntegrationService.instance; + orphanManager = AdvancedOrphanManager.instance; + batchProcessor = SmartBatchProcessor.instance; + performanceOptimizer = PerformanceOptimizer.instance; + searchEngine = AdvancedSearchEngine.instance; + + // Clear caches + integrationService.clearCache(); + }); + + group('Note Creation Performance', () { + test('should create single note within performance target', () async { + const bookId = 'perf-create-single'; + const bookText = 'Performance test content for single note creation.'; + + final stopwatch = Stopwatch()..start(); + + final note = await integrationService.createNoteFromSelection( + bookId, + 'performance test', + 10, + 26, + 'Performance test note', + ); + + stopwatch.stop(); + + expect(note.id, isNotEmpty); + expect(stopwatch.elapsedMilliseconds, lessThan(100)); // Should be under 100ms + }); + + test('should create multiple notes efficiently', () async { + const bookId = 'perf-create-multiple'; + const bookText = 'Performance test content for multiple note creation with various selections.'; + const noteCount = 10; + + final stopwatch = Stopwatch()..start(); + final notes = []; + + for (int i = 0; i < noteCount; i++) { + final note = await integrationService.createNoteFromSelection( + bookId, + 'test $i', + i * 5, + i * 5 + 4, + 'Performance test note $i', + ); + notes.add(note); + } + + stopwatch.stop(); + + expect(notes.length, equals(noteCount)); + expect(stopwatch.elapsedMilliseconds, lessThan(noteCount * 50)); // Average 50ms per note + + final avgTimePerNote = stopwatch.elapsedMilliseconds / noteCount; + expect(avgTimePerNote, lessThan(AnchoringConstants.maxReanchoringTimeMs)); + }); + }); + + group('Note Loading Performance', () { + test('should load book notes within performance target', () async { + const bookId = 'perf-load-book'; + const bookText = 'Performance test content for book loading with multiple notes.'; + + // Create test notes first + for (int i = 0; i < 20; i++) { + await integrationService.createNoteFromSelection( + bookId, + 'note $i', + i * 10, + i * 10 + 5, + 'Test note $i', + ); + } + + // Clear cache to ensure fresh load + integrationService.clearCache(); + + final stopwatch = Stopwatch()..start(); + final result = await integrationService.loadNotesForBook(bookId, bookText); + stopwatch.stop(); + + expect(result.notes.length, equals(20)); + expect(stopwatch.elapsedMilliseconds, lessThan(500)); // Should load 20 notes in under 500ms + expect(result.fromCache, isFalse); + }); + + test('should load from cache very quickly', () async { + const bookId = 'perf-cache-load'; + const bookText = 'Performance test content for cache loading.'; + + // First load to populate cache + await integrationService.loadNotesForBook(bookId, bookText); + + // Second load from cache + final stopwatch = Stopwatch()..start(); + final result = await integrationService.loadNotesForBook(bookId, bookText); + stopwatch.stop(); + + expect(result.fromCache, isTrue); + expect(stopwatch.elapsedMilliseconds, lessThan(10)); // Cache should be very fast + }); + + test('should handle visible range efficiently', () async { + const bookId = 'perf-visible-range'; + + // Create many notes across a large range + for (int i = 0; i < 100; i++) { + await integrationService.createNoteFromSelection( + bookId, + 'note $i', + i * 100, + i * 100 + 10, + 'Test note $i', + ); + } + + // Test visible range performance + const visibleRange = VisibleCharRange(2000, 3000); // Should include ~10 notes + + final stopwatch = Stopwatch()..start(); + final visibleNotes = integrationService.getNotesForVisibleRange(bookId, visibleRange); + stopwatch.stop(); + + expect(visibleNotes.length, equals(10)); + expect(stopwatch.elapsedMilliseconds, lessThan(5)); // Should be very fast + }); + }); + + group('Search Performance', () { + test('should search notes within performance target', () async { + const bookId = 'perf-search'; + + // Create test notes with searchable content + final searchTerms = ['apple', 'banana', 'cherry', 'date', 'elderberry']; + for (int i = 0; i < 50; i++) { + final term = searchTerms[i % searchTerms.length]; + await integrationService.createNoteFromSelection( + bookId, + term, + i * 10, + i * 10 + term.length, + 'Note about $term number $i', + ); + } + + // Test search performance + final stopwatch = Stopwatch()..start(); + final results = await integrationService.searchNotes('apple', bookId: bookId); + stopwatch.stop(); + + expect(results.length, equals(10)); // Should find 10 apple notes + expect(stopwatch.elapsedMilliseconds, lessThan(200)); // Should search in under 200ms + }); + + test('should handle complex search queries efficiently', () async { + const bookId = 'perf-complex-search'; + + // Create notes with various content + for (int i = 0; i < 30; i++) { + await integrationService.createNoteFromSelection( + bookId, + 'content $i', + i * 20, + i * 20 + 10, + 'Complex search test note $i with various keywords and content', + tags: ['tag$i', 'common', 'test'], + ); + } + + // Test complex search + final stopwatch = Stopwatch()..start(); + final results = await integrationService.searchNotes('complex test', bookId: bookId); + stopwatch.stop(); + + expect(results.length, greaterThan(0)); + expect(stopwatch.elapsedMilliseconds, lessThan(300)); // Complex search under 300ms + }); + }); + + group('Batch Processing Performance', () { + test('should process batch operations efficiently', () async { + const bookId = 'perf-batch'; + + // Create test notes + final notes = []; + for (int i = 0; i < 50; i++) { + final note = await integrationService.createNoteFromSelection( + bookId, + 'batch $i', + i * 15, + i * 15 + 7, + 'Batch test note $i', + ); + notes.add(note); + } + + // Test batch processing performance + final stopwatch = Stopwatch()..start(); + + // Simulate batch update operations + for (int i = 0; i < 10; i++) { + await integrationService.updateNote( + notes[i].id, + 'Updated batch note $i', + newTags: ['updated', 'batch'], + ); + } + + stopwatch.stop(); + + expect(stopwatch.elapsedMilliseconds, lessThan(1000)); // 10 updates in under 1 second + + final avgTimePerUpdate = stopwatch.elapsedMilliseconds / 10; + expect(avgTimePerUpdate, lessThan(100)); // Average under 100ms per update + }); + + test('should handle large batch sizes with adaptive sizing', () async { + // Test batch processor stats + final initialStats = batchProcessor.getProcessingStats(); + expect(initialStats.currentBatchSize, greaterThan(0)); + expect(initialStats.activeProcesses, equals(0)); + + // Reset batch size for consistent testing + batchProcessor.resetBatchSize(); + + final resetStats = batchProcessor.getProcessingStats(); + expect(resetStats.currentBatchSize, equals(50)); // Default batch size + }); + }); + + group('Memory Performance', () { + test('should maintain reasonable memory usage with many notes', () async { + const bookId = 'perf-memory'; + + // Create a large number of notes + for (int i = 0; i < 200; i++) { + await integrationService.createNoteFromSelection( + bookId, + 'memory test $i', + i * 25, + i * 25 + 12, + 'Memory performance test note $i with some content to test memory usage', + tags: ['memory', 'test', 'performance'], + ); + } + + // Check cache statistics + final cacheStats = integrationService.getCacheStats(); + expect(cacheStats['total_cached_notes'], equals(200)); + + // Memory usage should be reasonable (this is a rough estimate) + // In a real app, you might use more sophisticated memory profiling + expect(cacheStats['total_cached_notes'], lessThan(1000)); + }); + + test('should clean up cache when needed', () async { + const bookId = 'perf-cleanup'; + + // Create notes and populate cache + for (int i = 0; i < 50; i++) { + await integrationService.createNoteFromSelection( + bookId, + 'cleanup $i', + i * 10, + i * 10 + 8, + 'Cleanup test note $i', + ); + } + + final statsBefore = integrationService.getCacheStats(); + expect(statsBefore['total_cached_notes'], equals(50)); + + // Clear cache + integrationService.clearCache(); + + final statsAfter = integrationService.getCacheStats(); + expect(statsAfter['total_cached_notes'], equals(0)); + }); + }); + + group('Performance Optimizer Tests', () { + test('should provide optimization status', () { + final status = performanceOptimizer.getOptimizationStatus(); + + expect(status.isAutoOptimizationEnabled, isA()); + expect(status.isHealthy, isA()); + }); + + test('should run optimization cycle', () async { + final stopwatch = Stopwatch()..start(); + final result = await performanceOptimizer.runOptimizationCycle(); + stopwatch.stop(); + + expect(result.success, isTrue); + expect(result.duration.inMilliseconds, greaterThan(0)); + expect(result.results, isNotEmpty); + expect(result.recommendations, isNotEmpty); + + // Optimization should complete in reasonable time + expect(stopwatch.elapsedMilliseconds, lessThan(5000)); // Under 5 seconds + }); + + test('should start and stop auto optimization', () { + // Start auto optimization + performanceOptimizer.startAutoOptimization(); + + final statusRunning = performanceOptimizer.getOptimizationStatus(); + expect(statusRunning.isAutoOptimizationEnabled, isTrue); + + // Stop auto optimization + performanceOptimizer.stopAutoOptimization(); + + final statusStopped = performanceOptimizer.getOptimizationStatus(); + expect(statusStopped.isAutoOptimizationEnabled, isFalse); + }); + }); + + group('Stress Tests', () { + test('should handle rapid note creation without degradation', () async { + const bookId = 'stress-rapid-creation'; + const noteCount = 100; + + final times = []; + + for (int i = 0; i < noteCount; i++) { + final stopwatch = Stopwatch()..start(); + + await integrationService.createNoteFromSelection( + bookId, + 'rapid $i', + i * 5, + i * 5 + 6, + 'Rapid creation test note $i', + ); + + stopwatch.stop(); + times.add(stopwatch.elapsedMilliseconds); + } + + // Check that performance doesn't degrade significantly + final firstTen = times.take(10).reduce((a, b) => a + b) / 10; + final lastTen = times.skip(noteCount - 10).reduce((a, b) => a + b) / 10; + + // Last ten shouldn't be more than 2x slower than first ten + expect(lastTen, lessThan(firstTen * 2)); + + // All operations should be under reasonable limit + expect(times.every((time) => time < 200), isTrue); + }); + + test('should handle concurrent operations', () async { + const bookId = 'stress-concurrent'; + + // Create multiple concurrent note creation operations + final futures = >[]; + + for (int i = 0; i < 20; i++) { + final future = integrationService.createNoteFromSelection( + bookId, + 'concurrent $i', + i * 10, + i * 10 + 11, + 'Concurrent test note $i', + ); + futures.add(future); + } + + final stopwatch = Stopwatch()..start(); + final results = await Future.wait(futures); + stopwatch.stop(); + + expect(results.length, equals(20)); + expect(results.every((note) => note.id.isNotEmpty), isTrue); + + // Concurrent operations should be faster than sequential + expect(stopwatch.elapsedMilliseconds, lessThan(20 * 100)); // Much faster than sequential + }); + + test('should maintain performance with large visible ranges', () async { + const bookId = 'stress-large-range'; + + // Create notes spread across a very large range + for (int i = 0; i < 500; i++) { + await integrationService.createNoteFromSelection( + bookId, + 'large range $i', + i * 1000, // Spread notes far apart + i * 1000 + 15, + 'Large range test note $i', + ); + } + + // Test performance with various range sizes + final ranges = [ + const VisibleCharRange(0, 10000), // Small range + const VisibleCharRange(0, 100000), // Medium range + const VisibleCharRange(0, 500000), // Large range + ]; + + for (final range in ranges) { + final stopwatch = Stopwatch()..start(); + final visibleNotes = integrationService.getNotesForVisibleRange(bookId, range); + stopwatch.stop(); + + expect(visibleNotes.length, greaterThan(0)); + expect(stopwatch.elapsedMilliseconds, lessThan(50)); // Should be fast regardless of range size + } + }); + }); + + group('Performance Regression Tests', () { + test('should maintain consistent anchoring performance', () async { + const bookId = 'regression-anchoring'; + const bookText = 'Regression test content for anchoring performance validation.'; + + // Create notes and measure anchoring time + final anchoringTimes = []; + + for (int i = 0; i < 20; i++) { + final stopwatch = Stopwatch()..start(); + + await integrationService.createNoteFromSelection( + bookId, + 'anchoring $i', + i * 10, + i * 10 + 11, + 'Anchoring regression test note $i', + ); + + stopwatch.stop(); + anchoringTimes.add(stopwatch.elapsedMilliseconds); + } + + // Calculate statistics + final avgTime = anchoringTimes.reduce((a, b) => a + b) / anchoringTimes.length; + final maxTime = anchoringTimes.reduce((a, b) => a > b ? a : b); + + // Performance should be within acceptable limits + expect(avgTime, lessThan(AnchoringConstants.maxReanchoringTimeMs)); + expect(maxTime, lessThan(AnchoringConstants.maxReanchoringTimeMs * 2)); + + // Variance should be reasonable (no outliers) + final variance = anchoringTimes.map((time) => (time - avgTime) * (time - avgTime)).reduce((a, b) => a + b) / anchoringTimes.length; + expect(variance, lessThan(1000)); // Low variance indicates consistent performance + }); + + test('should maintain search performance with growing dataset', () async { + const bookId = 'regression-search'; + + // Create increasing numbers of notes and measure search time + final searchTimes = []; + final noteCounts = [10, 50, 100, 200]; + + for (final count in noteCounts) { + // Add more notes + final currentNoteCount = integrationService.getNotesForVisibleRange(bookId, const VisibleCharRange(0, 999999)).length; + final notesToAdd = count - currentNoteCount; + + for (int i = currentNoteCount; i < currentNoteCount + notesToAdd; i++) { + await integrationService.createNoteFromSelection( + bookId, + 'search regression $i', + i * 5, + i * 5 + 17, + 'Search regression test note $i with searchable content', + ); + } + + // Measure search time + final stopwatch = Stopwatch()..start(); + await integrationService.searchNotes('regression', bookId: bookId); + stopwatch.stop(); + + searchTimes.add(stopwatch.elapsedMilliseconds); + } + + // Search time should not grow linearly with dataset size + // (should be sub-linear due to indexing) + expect(searchTimes.last, lessThan(searchTimes.first * 4)); // Not more than 4x slower with 20x data + expect(searchTimes.every((time) => time < 500), isTrue); // All searches under 500ms + }); + }); + }); +} \ No newline at end of file diff --git a/test/notes/services/canonical_text_service_test.dart b/test/notes/services/canonical_text_service_test.dart new file mode 100644 index 000000000..10637e019 --- /dev/null +++ b/test/notes/services/canonical_text_service_test.dart @@ -0,0 +1,187 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:otzaria/notes/services/canonical_text_service.dart'; +import 'package:otzaria/notes/services/text_normalizer.dart'; +import 'package:otzaria/notes/config/notes_config.dart'; +import 'package:otzaria/notes/models/anchor_models.dart'; + +void main() { + group('CanonicalTextService Tests', () { + late CanonicalTextService service; + + setUp(() { + service = CanonicalTextService.instance; + }); + + group('Document Version Calculation', () { + test('should generate consistent version hashes', () { + const text = 'זהו טקסט לדוגמה'; + final version1 = service.calculateDocumentVersion(text); + final version2 = service.calculateDocumentVersion(text); + + expect(version1, equals(version2)); + expect(version1.length, equals(64)); // SHA-256 length + }); + + test('should generate different versions for different texts', () { + const text1 = 'טקסט ראשון'; + const text2 = 'טקסט שני'; + + final version1 = service.calculateDocumentVersion(text1); + final version2 = service.calculateDocumentVersion(text2); + + expect(version1, isNot(equals(version2))); + }); + }); + + group('Context Window Extraction', () { + test('should extract context window correctly', () { + const text = 'זה טקסט לדוגמה עם הרבה מילים'; + final window = service.extractContextWindow(text, 5, 10); + + expect(window.selected, equals('סט לד')); + expect(window.selectedStart, equals(5)); + expect(window.selectedEnd, equals(10)); + }); + + test('should handle custom window size', () { + const text = 'טקסט קצר'; + final window = service.extractContextWindow(text, 2, 4, windowSize: 2); + + expect(window.before.length, lessThanOrEqualTo(2)); + expect(window.after.length, lessThanOrEqualTo(2)); + }); + }); + + group('Text Segment Operations', () { + test('should extract context window correctly', () { + const text = 'זהו טקסט לדוגמה'; + + final context = service.extractContextWindow(text, 4, 8); + expect(context.selected, equals(text.substring(4, 8))); + expect(context.before, equals(text.substring(0, 4))); + }); + + test('should handle invalid context window range', () { + const text = 'טקסט קצר'; + + // Should not throw, but clamp to valid range + final context = service.extractContextWindow(text, -1, 5); + expect(context.before, equals('')); + expect(context.selected.length, lessThanOrEqualTo(text.length)); + ); + + expect( + () => service.getTextSegment(document, 5, 100), + throwsArgumentError, + ); + + expect( + () => service.getTextSegment(document, 5, 3), + throwsArgumentError, + ); + }); + }); + + group('Hash Matching', () { + test('should find text hash matches', () { + const text = 'זהו טקסט לדוגמה'; + final document = _createMockDocument('test', text); + + // This would normally be populated by the real service + // For testing, we'll assume some matches exist + final matches = service.findTextHashMatches(document, 'dummy-hash'); + expect(matches, isA>()); + }); + + test('should find context matches', () { + const text = 'זהו טקסט לדוגמה'; + final document = _createMockDocument('test', text); + + final matches = service.findContextMatches( + document, + 'before-hash', + 'after-hash', + ); + expect(matches, isA>()); + }); + + test('should find rolling hash matches', () { + const text = 'זהו טקסט לדוגמה'; + final document = _createMockDocument('test', text); + + final matches = service.findRollingHashMatches(document, 12345); + expect(matches, isA>()); + }); + }); + + group('Document Validation', () { + test('should validate proper canonical document', () { + const text = 'זהו טקסט לדוגמה עם תוכן מספיק ארוך לבדיקה'; + final document = _createMockDocument('test-book', text); + + final isValid = service.validateCanonicalDocument(document); + expect(isValid, isTrue); + }); + + test('should reject document with empty fields', () { + final document = _createMockDocument('', ''); + + final isValid = service.validateCanonicalDocument(document); + expect(isValid, isFalse); + }); + + test('should reject document with mismatched version', () { + const text = 'זהו טקסט לדוגמה'; + final document = _createMockDocument('test', text, versionId: 'wrong-version'); + + final isValid = service.validateCanonicalDocument(document); + expect(isValid, isFalse); + }); + }); + + group('Document Statistics', () { + test('should provide comprehensive document stats', () { + const text = 'זהו טקסט לדוגמה עם תוכן'; + final document = _createMockDocument('test-book', text); + + final stats = service.getDocumentStats(document); + + expect(stats['book_id'], equals('test-book')); + expect(stats['text_length'], equals(text.length)); + expect(stats['text_hash_entries'], isA()); + expect(stats['context_hash_entries'], isA()); + expect(stats['rolling_hash_entries'], isA()); + expect(stats['has_logical_structure'], isA()); + }); + }); + }); +} + +/// Helper function to create a mock canonical document for testing +_createMockDocument(String bookId, String text, {String? versionId}) { + final service = CanonicalTextService.instance; + final actualVersionId = versionId ?? service.calculateDocumentVersion(text); + + return MockCanonicalDocument( + bookId: bookId, + versionId: actualVersionId, + canonicalText: text, + textHashIndex: const {}, + contextHashIndex: const {}, + rollingHashIndex: const {}, + logicalStructure: null, + ); +} + +/// Mock implementation for testing +class MockCanonicalDocument extends CanonicalDocument { + const MockCanonicalDocument({ + required super.bookId, + required super.versionId, + required super.canonicalText, + required super.textHashIndex, + required super.contextHashIndex, + required super.rollingHashIndex, + super.logicalStructure, + }); +} \ No newline at end of file diff --git a/test/notes/services/fuzzy_matcher_test.dart b/test/notes/services/fuzzy_matcher_test.dart new file mode 100644 index 000000000..fe33ea406 --- /dev/null +++ b/test/notes/services/fuzzy_matcher_test.dart @@ -0,0 +1,240 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:otzaria/notes/services/fuzzy_matcher.dart'; +import 'package:otzaria/notes/config/notes_config.dart'; +import 'package:otzaria/notes/models/anchor_models.dart'; + +void main() { + group('FuzzyMatcher Tests', () { + group('Similarity Calculations', () { + test('should calculate Levenshtein similarity correctly', () { + expect(FuzzyMatcher.calculateLevenshteinSimilarity('hello', 'hello'), equals(1.0)); + expect(FuzzyMatcher.calculateLevenshteinSimilarity('hello', 'hallo'), closeTo(0.8, 0.1)); + expect(FuzzyMatcher.calculateLevenshteinSimilarity('hello', 'world'), lessThan(0.5)); + expect(FuzzyMatcher.calculateLevenshteinSimilarity('', ''), equals(1.0)); + expect(FuzzyMatcher.calculateLevenshteinSimilarity('hello', ''), equals(0.0)); + }); + + test('should calculate Jaccard similarity correctly', () { + expect(FuzzyMatcher.calculateJaccardSimilarity('hello', 'hello'), equals(1.0)); + expect(FuzzyMatcher.calculateJaccardSimilarity('hello', 'hallo'), greaterThan(0.1)); + expect(FuzzyMatcher.calculateJaccardSimilarity('hello', 'world'), lessThan(0.5)); + }); + + test('should calculate Cosine similarity correctly', () { + expect(FuzzyMatcher.calculateCosineSimilarity('hello', 'hello'), closeTo(1.0, 0.01)); + expect(FuzzyMatcher.calculateCosineSimilarity('hello', 'hallo'), greaterThan(0.2)); + expect(FuzzyMatcher.calculateCosineSimilarity('hello', 'world'), lessThan(0.8)); + }); + + test('should handle Hebrew text similarity', () { + const text1 = 'שלום עולם'; + const text2 = 'שלום עולם טוב'; + const text3 = 'שלום חברים'; + + final sim1 = FuzzyMatcher.calculateLevenshteinSimilarity(text1, text2); + final sim2 = FuzzyMatcher.calculateLevenshteinSimilarity(text1, text3); + + expect(sim1, greaterThan(0.6)); + expect(sim2, greaterThan(0.5)); + expect(sim1, greaterThan(sim2)); // text2 should be more similar + }); + }); + + group('N-gram Generation', () { + test('should generate n-grams correctly', () { + final ngrams = FuzzyMatcher.generateNGrams('hello', 3); + expect(ngrams, equals(['hel', 'ell', 'llo'])); + }); + + test('should handle short text', () { + final ngrams = FuzzyMatcher.generateNGrams('hi', 3); + expect(ngrams, equals(['hi'])); + }); + + test('should handle Hebrew n-grams', () { + final ngrams = FuzzyMatcher.generateNGrams('שלום', 2); + expect(ngrams.length, equals(3)); + expect(ngrams.first, equals('של')); + expect(ngrams.last, equals('ום')); + }); + }); + + group('Fuzzy Matching', () { + test('should find matches with lenient thresholds', () { + const searchText = 'hello world'; + const targetText = 'say hello world to everyone'; + + final candidates = FuzzyMatcher.findFuzzyMatches( + searchText, + targetText, + levenshteinThreshold: 0.5, + jaccardThreshold: 0.3, + cosineThreshold: 0.3, + ); + + // With lenient thresholds, we should find something + expect(candidates, isA()); + }); + + test('should return empty list with strict thresholds', () { + const searchText = 'hello world'; + const targetText = 'completely different text here'; + + final candidates = FuzzyMatcher.findFuzzyMatches( + searchText, + targetText, + levenshteinThreshold: 0.1, + jaccardThreshold: 0.9, + cosineThreshold: 0.9, + ); + + expect(candidates, isEmpty); + }); + + + + test('should handle Hebrew fuzzy matching', () { + const searchText = 'שלום עולם'; + const targetText = 'אמר שלום עולם לכולם'; + + final candidates = FuzzyMatcher.findFuzzyMatches( + searchText, + targetText, + levenshteinThreshold: 0.3, + jaccardThreshold: 0.5, + cosineThreshold: 0.5, + ); + + expect(candidates, isA()); + }); + + test('should respect custom thresholds', () { + const searchText = 'hello world'; + const targetText = 'say hallo world to everyone'; + + // Strict thresholds + final strictCandidates = FuzzyMatcher.findFuzzyMatches( + searchText, + targetText, + levenshteinThreshold: 0.05, // Very strict + jaccardThreshold: 0.95, + cosineThreshold: 0.95, + ); + + // Lenient thresholds + final lenientCandidates = FuzzyMatcher.findFuzzyMatches( + searchText, + targetText, + levenshteinThreshold: 0.3, // More lenient + jaccardThreshold: 0.6, + cosineThreshold: 0.6, + ); + + expect(lenientCandidates.length, greaterThanOrEqualTo(strictCandidates.length)); + }); + }); + + group('Best Match Finding', () { + test('should handle best match search', () { + const searchText = 'hello world'; + const targetText = 'say hello world to everyone'; + + final bestMatch = FuzzyMatcher.findBestMatch(searchText, targetText, minScore: 0.3); + + // Should either find a match or return null + expect(bestMatch, isA()); + }); + + test('should return null for very poor matches', () { + const searchText = 'hello world'; + const targetText = 'xyz'; + + final bestMatch = FuzzyMatcher.findBestMatch(searchText, targetText, minScore: 0.8); + + expect(bestMatch, isNull); + }); + }); + + group('Combined Similarity', () { + test('should calculate weighted combined similarity', () { + const text1 = 'hello world'; + const text2 = 'hallo world'; + + final combined = FuzzyMatcher.calculateCombinedSimilarity(text1, text2); + + expect(combined, greaterThan(0.0)); + expect(combined, lessThanOrEqualTo(1.0)); + }); + + test('should respect custom weights', () { + const text1 = 'hello world'; + const text2 = 'hallo world'; + + final combinedSimilarity = FuzzyMatcher.calculateCombinedSimilarity( + text1, text2, + ); + + // Test individual similarities for comparison + final levenshteinSim = FuzzyMatcher.calculateLevenshteinSimilarity(text1, text2); + final jaccardSim = FuzzyMatcher.calculateJaccardSimilarity(text1, text2); + final cosineSim = FuzzyMatcher.calculateCosineSimilarity(text1, text2); + + expect(combinedSimilarity, isA()); + expect(combinedSimilarity, greaterThan(0.0)); + expect(combinedSimilarity, lessThanOrEqualTo(1.0)); + + // Combined should be weighted average of individual similarities + expect(levenshteinSim, isA()); + expect(jaccardSim, isA()); + expect(cosineSim, isA()); + }); + }); + + group('Threshold Validation', () { + test('should validate correct thresholds', () { + expect(FuzzyMatcher.validateSimilarityThresholds( + levenshteinThreshold: 0.2, + jaccardThreshold: 0.8, + cosineThreshold: 0.8, + ), isTrue); + }); + + test('should reject invalid thresholds', () { + expect(FuzzyMatcher.validateSimilarityThresholds( + levenshteinThreshold: -0.1, + jaccardThreshold: 0.8, + cosineThreshold: 0.8, + ), isFalse); + + expect(FuzzyMatcher.validateSimilarityThresholds( + levenshteinThreshold: 0.2, + jaccardThreshold: 1.5, + cosineThreshold: 0.8, + ), isFalse); + }); + }); + + group('Similarity Statistics', () { + test('should provide comprehensive similarity stats', () { + const text1 = 'hello world'; + const text2 = 'hallo world'; + + final stats = FuzzyMatcher.getSimilarityStats(text1, text2); + + expect(stats['levenshtein'], isA()); + expect(stats['jaccard'], isA()); + expect(stats['cosine'], isA()); + expect(stats['combined'], isA()); + expect(stats['length_ratio'], isA()); + + // All similarity scores should be between 0 and 1 + expect(stats['levenshtein']!, greaterThanOrEqualTo(0.0)); + expect(stats['levenshtein']!, lessThanOrEqualTo(1.0)); + expect(stats['jaccard']!, greaterThanOrEqualTo(0.0)); + expect(stats['jaccard']!, lessThanOrEqualTo(1.0)); + expect(stats['cosine']!, greaterThanOrEqualTo(0.0)); + expect(stats['cosine']!, lessThanOrEqualTo(1.0)); + }); + }); + }); +} \ No newline at end of file diff --git a/test/notes/services/hash_generator_test.dart b/test/notes/services/hash_generator_test.dart new file mode 100644 index 000000000..a3f13c7d8 --- /dev/null +++ b/test/notes/services/hash_generator_test.dart @@ -0,0 +1,249 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:otzaria/notes/services/hash_generator.dart'; + +void main() { + group('HashGenerator Tests', () { + group('Text Hash Generation', () { + test('should generate consistent SHA-256 hashes', () { + const text = 'שלום עולם'; + final hash1 = HashGenerator.generateTextHash(text); + final hash2 = HashGenerator.generateTextHash(text); + + expect(hash1, equals(hash2)); + expect(hash1.length, equals(64)); // SHA-256 produces 64 character hex string + }); + + test('should generate different hashes for different texts', () { + const text1 = 'שלום עולם'; + const text2 = 'שלום עולם טוב'; + + final hash1 = HashGenerator.generateTextHash(text1); + final hash2 = HashGenerator.generateTextHash(text2); + + expect(hash1, isNot(equals(hash2))); + }); + + test('should handle empty string', () { + const text = ''; + final hash = HashGenerator.generateTextHash(text); + + expect(hash, isNotEmpty); + expect(hash.length, equals(64)); + }); + + test('should be case sensitive', () { + const text1 = 'Hello World'; + const text2 = 'hello world'; + + final hash1 = HashGenerator.generateTextHash(text1); + final hash2 = HashGenerator.generateTextHash(text2); + + expect(hash1, isNot(equals(hash2))); + }); + }); + + group('Rolling Hash Generation', () { + test('should generate consistent rolling hashes', () { + const text = 'שלום עולם'; + final hash1 = HashGenerator.generateRollingHash(text); + final hash2 = HashGenerator.generateRollingHash(text); + + expect(hash1, equals(hash2)); + }); + + test('should generate different hashes for different texts', () { + const text1 = 'שלום עולם'; + const text2 = 'שלום עולם טוב'; + + final hash1 = HashGenerator.generateRollingHash(text1); + final hash2 = HashGenerator.generateRollingHash(text2); + + expect(hash1, isNot(equals(hash2))); + }); + + test('should handle empty string', () { + const text = ''; + final hash = HashGenerator.generateRollingHash(text); + + expect(hash, equals(0)); + }); + + test('should be order sensitive', () { + const text1 = 'ab'; + const text2 = 'ba'; + + final hash1 = HashGenerator.generateRollingHash(text1); + final hash2 = HashGenerator.generateRollingHash(text2); + + expect(hash1, isNot(equals(hash2))); + }); + }); + + group('Combined Hash Generation', () { + test('should generate text hashes with all components', () { + const text = 'שלום עולם'; + final hashes = HashGenerator.generateTextHashes(text); + + expect(hashes.textHash, isNotEmpty); + expect(hashes.textHash.length, equals(64)); + expect(hashes.rollingHash, isA()); + expect(hashes.length, equals(text.length)); + }); + + test('should generate context hashes', () { + const before = 'לפני'; + const after = 'אחרי'; + + final hashes = HashGenerator.generateContextHashes(before, after); + + expect(hashes.beforeHash, isNotEmpty); + expect(hashes.afterHash, isNotEmpty); + expect(hashes.beforeHash.length, equals(64)); + expect(hashes.afterHash.length, equals(64)); + expect(hashes.beforeRollingHash, isA()); + expect(hashes.afterRollingHash, isA()); + }); + }); + }); + + group('RollingHashWindow Tests', () { + test('should initialize with text correctly', () { + final window = RollingHashWindow(5); + window.init('hello'); + + expect(window.isFull, isTrue); + expect(window.currentWindow, equals('hello')); + expect(window.currentHash, isA()); + }); + + test('should slide window correctly', () { + final window = RollingHashWindow(3); + window.init('abc'); + + final initialHash = window.currentHash; + expect(window.currentWindow, equals('abc')); + + // Slide to 'bcd' + final newHash = window.slide('a'.codeUnitAt(0), 'd'.codeUnitAt(0)); + expect(window.currentWindow, equals('bcd')); + expect(newHash, isNot(equals(initialHash))); + expect(window.currentHash, equals(newHash)); + }); + + test('should handle partial window initialization', () { + final window = RollingHashWindow(5); + window.init('hi'); + + expect(window.isFull, isFalse); + expect(window.currentWindow, equals('hi')); + }); + + test('should maintain consistent hash for same content', () { + final window1 = RollingHashWindow(4); + final window2 = RollingHashWindow(4); + + window1.init('test'); + window2.init('test'); + + expect(window1.currentHash, equals(window2.currentHash)); + }); + + test('should handle sliding with partial window', () { + final window = RollingHashWindow(5); + window.init('hi'); + + expect(window.isFull, isFalse); + + // Add more characters + window.slide(0, 'a'.codeUnitAt(0)); // Should add 'a' + expect(window.currentWindow, equals('hia')); + expect(window.isFull, isFalse); + }); + }); + + group('DocumentHasher Tests', () { + test('should generate document version hash', () { + const document = 'זהו מסמך לדוגמה עם הרבה טקסט'; + final version = DocumentHasher.generateDocumentVersion(document); + + expect(version, isNotEmpty); + expect(version.length, equals(64)); + }); + + test('should generate consistent document versions', () { + const document = 'זהו מסמך לדוגמה'; + final version1 = DocumentHasher.generateDocumentVersion(document); + final version2 = DocumentHasher.generateDocumentVersion(document); + + expect(version1, equals(version2)); + }); + + test('should generate different versions for different documents', () { + const doc1 = 'מסמך ראשון'; + const doc2 = 'מסמך שני'; + + final version1 = DocumentHasher.generateDocumentVersion(doc1); + final version2 = DocumentHasher.generateDocumentVersion(doc2); + + expect(version1, isNot(equals(version2))); + }); + + test('should generate section hash with index', () { + const section = 'זהו קטע במסמך'; + const index = 5; + + final hash = DocumentHasher.generateSectionHash(section, index); + + expect(hash, isNotEmpty); + expect(hash.length, equals(64)); + }); + + test('should generate different section hashes for different indexes', () { + const section = 'אותו קטע'; + + final hash1 = DocumentHasher.generateSectionHash(section, 1); + final hash2 = DocumentHasher.generateSectionHash(section, 2); + + expect(hash1, isNot(equals(hash2))); + }); + + test('should generate incremental hash', () { + const previousHash = 'abc123'; + const newContent = 'תוכן חדש'; + + final incrementalHash = DocumentHasher.generateIncrementalHash(previousHash, newContent); + + expect(incrementalHash, isNotEmpty); + expect(incrementalHash.length, equals(64)); + expect(incrementalHash, isNot(equals(previousHash))); + }); + }); + + group('Hash Container Tests', () { + test('TextHashes should provide meaningful toString', () { + final hashes = TextHashes( + textHash: 'abcdef1234567890' * 4, // 64 chars + rollingHash: 12345, + length: 10, + ); + + final string = hashes.toString(); + expect(string, contains('abcdef12')); + expect(string, contains('12345')); + expect(string, contains('10')); + }); + + test('ContextHashes should provide meaningful toString', () { + final hashes = ContextHashes( + beforeHash: 'before12' + '0' * 56, + afterHash: 'after123' + '0' * 56, + beforeRollingHash: 111, + afterRollingHash: 222, + ); + + final string = hashes.toString(); + expect(string, contains('before12')); + expect(string, contains('after123')); + }); + }); +} \ No newline at end of file diff --git a/test/notes/services/text_normalizer_test.dart b/test/notes/services/text_normalizer_test.dart new file mode 100644 index 000000000..95e08ed7d --- /dev/null +++ b/test/notes/services/text_normalizer_test.dart @@ -0,0 +1,213 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:otzaria/notes/services/text_normalizer.dart'; +import 'package:otzaria/notes/config/notes_config.dart'; + +void main() { + group('TextNormalizer Tests', () { + late NormalizationConfig config; + + setUp(() { + config = const NormalizationConfig( + removeNikud: false, + quoteStyle: 'ascii', + unicodeForm: 'NFKC', + ); + }); + + group('Basic Normalization', () { + test('should normalize multiple spaces to single space', () { + const input = 'שלום עולם'; + final result = TextNormalizer.normalize(input, config); + expect(result, equals('שלום עולם')); + }); + + test('should trim whitespace from beginning and end', () { + const input = ' שלום עולם '; + final result = TextNormalizer.normalize(input, config); + expect(result, equals('שלום עולם')); + }); + + test('should handle empty string', () { + const input = ''; + final result = TextNormalizer.normalize(input, config); + expect(result, equals('')); + }); + + test('should handle whitespace-only string', () { + const input = ' \t\n '; + final result = TextNormalizer.normalize(input, config); + expect(result, equals('')); + }); + }); + + group('Quote Normalization', () { + test('should normalize smart quotes to ASCII', () { + const input = '"שלום" ו\'עולם\''; + final result = TextNormalizer.normalize(input, config); + expect(result, equals('"שלום" ו\'עולם\'')); + }); + + test('should normalize Hebrew quotes', () { + const input = '״שלום״ ו׳עולם׳'; + final result = TextNormalizer.normalize(input, config); + expect(result, equals('"שלום" ו\'עולם\'')); + }); + + test('should handle mixed quote types', () { + const input = '«שלום» "עולם" \'טוב\''; + final result = TextNormalizer.normalize(input, config); + expect(result, equals('"שלום" "עולם" \'טוב\'')); + }); + }); + + group('Directional Marks', () { + test('should remove LTR and RTL marks', () { + const input = 'שלום\u200Eעולם\u200F'; + final result = TextNormalizer.normalize(input, config); + expect(result, equals('שלוםעולם')); + }); + + test('should remove embedding controls', () { + const input = 'שלום\u202Aעולם\u202C'; + final result = TextNormalizer.normalize(input, config); + expect(result, equals('שלוםעולם')); + }); + + test('should remove zero-width joiners', () { + const input = 'שלום\u200Cעולם\u200D'; + final result = TextNormalizer.normalize(input, config); + expect(result, equals('שלוםעולם')); + }); + }); + + group('Nikud Handling', () { + test('should preserve nikud when configured', () { + const input = 'שָׁלוֹם עוֹלָם'; + final configWithNikud = NormalizationConfig( + removeNikud: false, + quoteStyle: 'ascii', + unicodeForm: 'NFKC', + ); + final result = TextNormalizer.normalize(input, configWithNikud); + expect(result, contains('שָׁלוֹם')); + expect(result, contains('עוֹלָם')); + }); + + test('should remove nikud when configured', () { + const input = 'שָׁלוֹם עוֹלָם'; + final configWithoutNikud = NormalizationConfig( + removeNikud: true, + quoteStyle: 'ascii', + unicodeForm: 'NFKC', + ); + final result = TextNormalizer.normalize(input, configWithoutNikud); + expect(result, equals('שלום עולם')); + }); + }); + + group('Normalization Stability', () { + test('should be idempotent', () { + const input = ' "שָׁלוֹם עוֹלָם" \u200E'; + final result1 = TextNormalizer.normalize(input, config); + final result2 = TextNormalizer.normalize(result1, config); + expect(result1, equals(result2)); + }); + + test('should validate normalization stability', () { + const input = ' "שלום עולם" '; + final isStable = TextNormalizer.validateNormalization(input, config); + expect(isStable, isTrue); + }); + }); + + group('Context Window Extraction', () { + test('should extract context window correctly', () { + const text = 'זה טקסט לדוגמה עם הרבה מילים בתוכו'; + final window = TextNormalizer.extractContextWindow(text, 10, 15); + + expect(window.selected, equals('וגמה ')); + expect(window.before, equals('זה טקסט לד')); + expect(window.after, equals('עם הרבה מילים בתוכו')); + expect(window.selectedStart, equals(10)); + expect(window.selectedEnd, equals(15)); + }); + + test('should handle context window at text boundaries', () { + const text = 'קצר'; + final window = TextNormalizer.extractContextWindow(text, 0, 2); + + expect(window.selected, equals('קצ')); + expect(window.before, equals('')); + expect(window.after, equals('ר')); + }); + + test('should respect window size limits', () { + final text = 'א' * 200; // 200 characters + final window = TextNormalizer.extractContextWindow(text, 100, 110, windowSize: 20); + + expect(window.before.length, equals(20)); + expect(window.after.length, equals(20)); + expect(window.selected.length, equals(10)); + }); + }); + + group('Context Window Normalization', () { + test('should normalize all parts of context window', () { + final window = ContextWindow( + before: ' "לפני" ', + selected: ' "נבחר" ', + after: ' "אחרי" ', + beforeStart: 0, + selectedStart: 10, + selectedEnd: 20, + afterEnd: 30, + ); + + final normalized = TextNormalizer.normalizeContextWindow(window, config); + + expect(normalized.before, equals('"לפני"')); + expect(normalized.selected, equals('"נבחר"')); + expect(normalized.after, equals('"אחרי"')); + }); + }); + + group('Configuration', () { + test('should create config from settings', () { + final config = TextNormalizer.createConfigFromSettings(); + expect(config.quoteStyle, equals('ascii')); + expect(config.unicodeForm, equals('NFKC')); + }); + }); + }); + + group('ContextWindow Tests', () { + test('should calculate total length correctly', () { + final window = ContextWindow( + before: 'לפני', + selected: 'נבחר', + after: 'אחרי', + beforeStart: 0, + selectedStart: 4, + selectedEnd: 8, + afterEnd: 12, + ); + + expect(window.totalLength, equals(12)); // 4 + 4 + 4 + }); + + test('should provide meaningful toString', () { + final window = ContextWindow( + before: 'לפני', + selected: 'נבחר', + after: 'אחרי', + beforeStart: 0, + selectedStart: 4, + selectedEnd: 8, + afterEnd: 12, + ); + + final string = window.toString(); + expect(string, contains('4 chars')); + }); + }); +} \ No newline at end of file diff --git a/test/notes/test_helpers/mock_canonical_text_service.dart b/test/notes/test_helpers/mock_canonical_text_service.dart new file mode 100644 index 000000000..a8af750ff --- /dev/null +++ b/test/notes/test_helpers/mock_canonical_text_service.dart @@ -0,0 +1,176 @@ +import 'package:otzaria/notes/models/anchor_models.dart'; +import 'package:otzaria/notes/services/text_normalizer.dart'; +import 'package:otzaria/notes/services/hash_generator.dart'; + +/// Mock implementation of CanonicalTextService for testing +class MockCanonicalTextService { + static MockCanonicalTextService? _instance; + + MockCanonicalTextService._(); + + static MockCanonicalTextService get instance { + _instance ??= MockCanonicalTextService._(); + return _instance!; + } + + /// Create a mock canonical document for testing + Future createCanonicalDocument(String bookId) async { + // Generate mock book text + final mockText = _generateMockBookText(bookId); + + // Create normalization config + final config = TextNormalizer.createConfigFromSettings(); + + // Normalize the text + final normalizedText = TextNormalizer.normalize(mockText, config); + + // Generate version ID + final versionId = 'mock-version-${mockText.hashCode}'; + + // Create mock indexes + final textHashIndex = >{}; + final contextHashIndex = >{}; + final rollingHashIndex = >{}; + + // Generate some mock hash entries + final words = normalizedText.split(' '); + for (int i = 0; i < words.length; i++) { + final word = words[i]; + if (word.isNotEmpty) { + final hash = HashGenerator.generateTextHash(word); + final position = normalizedText.indexOf(word, i > 0 ? normalizedText.indexOf(words[i-1]) + words[i-1].length : 0); + + textHashIndex.putIfAbsent(hash, () => []).add(position); + + // Add context hash + if (i > 0) { + final contextHash = HashGenerator.generateTextHash('${words[i-1]} $word'); + contextHashIndex.putIfAbsent(contextHash, () => []).add(position); + } + + // Add rolling hash + final rollingHash = HashGenerator.generateRollingHash(word); + rollingHashIndex.putIfAbsent(rollingHash, () => []).add(position); + } + } + + return CanonicalDocument( + id: 'mock-canonical-${bookId}-${DateTime.now().millisecondsSinceEpoch}', + bookId: bookId, + versionId: versionId, + canonicalText: normalizedText, + textHashIndex: textHashIndex, + contextHashIndex: contextHashIndex, + rollingHashIndex: rollingHashIndex, + logicalStructure: _generateMockLogicalStructure(), + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + } + + /// Generate mock book text based on book ID + String _generateMockBookText(String bookId) { + final baseTexts = { + 'test-book': 'This is a test book with sample content for testing notes functionality.', + 'hebrew-book': 'זהו ספר בעברית עם תוכן לדוגמה לבדיקת פונקציונליות ההערות.', + 'mixed-book': 'This is mixed content עם טקסט בעברית and English together.', + 'performance-book': _generatePerformanceText(), + }; + + // Return specific text for known book IDs, or generate based on ID + if (baseTexts.containsKey(bookId)) { + return baseTexts[bookId]!; + } + + // Generate text based on book ID pattern + if (bookId.contains('hebrew')) { + return 'טקסט בעברית לספר $bookId עם תוכן מגוון לבדיקות.'; + } else if (bookId.contains('performance') || bookId.contains('perf')) { + return _generatePerformanceText(); + } else if (bookId.contains('large')) { + return _generateLargeText(); + } + + // Default text + return 'Mock book content for $bookId with various text for testing purposes. ' + 'This content includes different words and phrases to test anchoring functionality.'; + } + + /// Generate performance test text + String _generatePerformanceText() { + final buffer = StringBuffer(); + final sentences = [ + 'This is a performance test sentence with various words.', + 'Another sentence for testing search and anchoring performance.', + 'Text content with different patterns and structures.', + 'Sample content for measuring system performance metrics.', + 'Additional text to create a larger document for testing.', + ]; + + for (int i = 0; i < 100; i++) { + buffer.write('${sentences[i % sentences.length]} '); + } + + return buffer.toString(); + } + + /// Generate large text for stress testing + String _generateLargeText() { + final buffer = StringBuffer(); + final paragraph = 'This is a large text document created for stress testing the notes system. ' + 'It contains multiple paragraphs with various content to test performance ' + 'and functionality under load conditions. The text includes different words, ' + 'phrases, and structures to provide comprehensive testing coverage. '; + + for (int i = 0; i < 1000; i++) { + buffer.write('$paragraph '); + if (i % 10 == 0) { + buffer.write('\n\n'); // Add paragraph breaks + } + } + + return buffer.toString(); + } + + /// Generate mock logical structure + List _generateMockLogicalStructure() { + return [ + 'chapter_1', + 'section_1_1', + 'paragraph_1', + 'paragraph_2', + 'section_1_2', + 'paragraph_3', + 'chapter_2', + 'section_2_1', + 'paragraph_4', + ]; + } + + /// Calculate document version for mock text + String calculateDocumentVersion(String text) { + return 'mock-version-${text.hashCode}'; + } + + /// Check if document version has changed + bool hasDocumentChanged(String bookId, String currentVersion) { + final mockText = _generateMockBookText(bookId); + final expectedVersion = calculateDocumentVersion(mockText); + return currentVersion != expectedVersion; + } + + /// Get mock document statistics + Map getDocumentStats(String bookId) { + final mockText = _generateMockBookText(bookId); + final words = mockText.split(' ').where((w) => w.isNotEmpty).toList(); + + return { + 'book_id': bookId, + 'character_count': mockText.length, + 'word_count': words.length, + 'paragraph_count': mockText.split('\n').length, + 'unique_words': words.toSet().length, + 'version_id': calculateDocumentVersion(mockText), + }; + } +} \ No newline at end of file diff --git a/test/notes/test_helpers/test_setup.dart b/test/notes/test_helpers/test_setup.dart new file mode 100644 index 000000000..f0b5d8a40 --- /dev/null +++ b/test/notes/test_helpers/test_setup.dart @@ -0,0 +1,209 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// Common test setup utilities +class TestSetup { + /// Initialize test environment + static void initializeTestEnvironment() { + // Initialize SQLite FFI for testing + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + + // Mock Settings initialization + _mockSettingsInit(); + } + + /// Mock Settings initialization to avoid dependency issues + static void _mockSettingsInit() { + // This is a simplified mock - in a real implementation you might use + // a proper mocking framework or create a test-specific Settings implementation + + // For now, we'll just ensure the tests can run without Settings dependency + // The actual Settings integration would be handled in the main app + } + + /// Create test canonical document data + static Map createTestCanonicalDocument(String bookId, String text) { + return { + 'bookId': bookId, + 'versionId': 'test-version-${text.hashCode}', + 'canonicalText': text, + 'textHashIndex': >{}, + 'contextHashIndex': >{}, + 'rollingHashIndex': >{}, + 'logicalStructure': [], + }; + } + + /// Create test note data + static Map createTestNote({ + required String id, + required String bookId, + required int charStart, + required int charEnd, + required String content, + String selectedText = 'test text', + List tags = const [], + }) { + final now = DateTime.now(); + + return { + 'id': id, + 'book_id': bookId, + 'doc_version_id': 'test-version', + 'logical_path': null, + 'char_start': charStart, + 'char_end': charEnd, + 'selected_text_normalized': selectedText, + 'text_hash': 'test-hash-${selectedText.hashCode}', + 'context_before': '', + 'context_after': '', + 'context_before_hash': 'before-hash', + 'context_after_hash': 'after-hash', + 'rolling_before': 12345, + 'rolling_after': 67890, + 'status': 'anchored', + 'content_markdown': content, + 'author_user_id': 'test-user', + 'privacy': 'private', + 'tags': tags, + 'created_at': now.toIso8601String(), + 'updated_at': now.toIso8601String(), + 'normalization_config': 'norm=v1;nikud=keep;quotes=ascii;unicode=NFKC', + }; + } + + /// Generate test book text + static String generateTestBookText(int length, {String prefix = 'Test book content'}) { + final buffer = StringBuffer(prefix); + + while (buffer.length < length) { + buffer.write(' Additional content for testing purposes.'); + } + + return buffer.toString().substring(0, length); + } + + /// Create performance test data + static List> createPerformanceTestData(int count) { + final testData = >[]; + + for (int i = 0; i < count; i++) { + testData.add({ + 'id': 'perf-test-$i', + 'content': 'Performance test note $i', + 'charStart': i * 10, + 'charEnd': i * 10 + 15, + 'tags': ['performance', 'test', 'batch-$i'], + }); + } + + return testData; + } + + /// Validate test results + static void validateTestResults(Map results) { + expect(results, isNotNull); + expect(results, isA>()); + } + + /// Clean up test environment + static void cleanupTestEnvironment() { + // Clean up any test-specific resources + // This would be called in tearDown methods + } +} + +/// Test constants +class TestConstants { + static const String defaultBookId = 'test-book'; + static const String defaultUserId = 'test-user'; + static const String defaultBookText = 'This is a test book with sample content for testing notes functionality.'; + + static const int performanceTestTimeout = 5000; // 5 seconds + static const int maxTestNotes = 1000; + static const int defaultBatchSize = 50; + + static const List sampleTags = ['test', 'sample', 'demo', 'example']; + static const List hebrewSampleText = [ + 'זהו טקסט לדוגמה בעברית', + 'הערות אישיות על הטקסט', + 'בדיקת תמיכה בעברית', + 'טקסט עם ניקוד: בְּרֵאשִׁית', + ]; +} + +/// Test data generators +class TestDataGenerator { + /// Generate Hebrew text with various characteristics + static String generateHebrewText({ + bool includeNikud = false, + bool includeQuotes = false, + bool includePunctuation = false, + int length = 100, + }) { + final buffer = StringBuffer(); + final baseText = 'זהו טקסט בעברית לבדיקת המערכת '; + + while (buffer.length < length) { + buffer.write(baseText); + + if (includeNikud && buffer.length < length) { + buffer.write('בְּרֵאשִׁית '); + } + + if (includeQuotes && buffer.length < length) { + buffer.write('"מירכאות" '); + } + + if (includePunctuation && buffer.length < length) { + buffer.write('סימני פיסוק: נקודה, פסיק! '); + } + } + + return buffer.toString().substring(0, length); + } + + /// Generate mixed language text + static String generateMixedText(int length) { + final buffer = StringBuffer(); + final patterns = [ + 'English text mixed with ', + 'עברית וגם ', + 'numbers 123 and ', + 'symbols @#\$ and ', + ]; + + int patternIndex = 0; + while (buffer.length < length) { + buffer.write(patterns[patternIndex % patterns.length]); + patternIndex++; + } + + return buffer.toString().substring(0, length); + } + + /// Generate performance test scenarios + static List> generatePerformanceScenarios() { + return [ + { + 'name': 'Small dataset', + 'noteCount': 10, + 'textLength': 1000, + 'expectedMaxTime': 100, + }, + { + 'name': 'Medium dataset', + 'noteCount': 100, + 'textLength': 10000, + 'expectedMaxTime': 500, + }, + { + 'name': 'Large dataset', + 'noteCount': 500, + 'textLength': 50000, + 'expectedMaxTime': 2000, + }, + ]; + } +} \ No newline at end of file From 7c3a4e5683a276e1e2cb48e251326862807c23ba Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sat, 16 Aug 2025 21:02:55 +0300 Subject: [PATCH 127/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=91?= =?UTF-8?q?=D7=90=D7=92=20=D7=91=D7=A9=D7=9E=D7=99=D7=A8=D7=AA=20=D7=94?= =?UTF-8?q?=D7=A2=D7=A8=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/notes/widgets/note_editor_dialog.dart | 1 + lib/notes/widgets/notes_context_menu_extension.dart | 4 ++-- lib/tabs/reading_screen.dart | 9 ++++++++- .../view/combined_view/combined_book_screen.dart | 4 ++-- lib/text_book/view/splited_view/simple_book_view.dart | 4 ++-- lib/text_book/view/text_book_screen.dart | 4 ++-- 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/lib/notes/widgets/note_editor_dialog.dart b/lib/notes/widgets/note_editor_dialog.dart index bfbd7ac46..cca1e2382 100644 --- a/lib/notes/widgets/note_editor_dialog.dart +++ b/lib/notes/widgets/note_editor_dialog.dart @@ -94,6 +94,7 @@ class _NoteEditorDialogState extends State { widget.onSave?.call(request); } + // Close the dialog after calling onSave if (mounted) { Navigator.of(context).pop(); } diff --git a/lib/notes/widgets/notes_context_menu_extension.dart b/lib/notes/widgets/notes_context_menu_extension.dart index 165c1d7b0..f738603c3 100644 --- a/lib/notes/widgets/notes_context_menu_extension.dart +++ b/lib/notes/widgets/notes_context_menu_extension.dart @@ -41,7 +41,7 @@ class NotesContextMenuExtension { charEnd: end, onSave: (request) { context.read().add(CreateNoteEvent(request)); - Navigator.of(context).pop(); + // Dialog will be closed by the caller }, ), ); @@ -190,7 +190,7 @@ mixin NotesContextMenuMixin on State { charEnd: end, onSave: (request) { context.read().add(CreateNoteEvent(request)); - Navigator.of(context).pop(); + // Dialog will be closed by the caller }, ), ); diff --git a/lib/tabs/reading_screen.dart b/lib/tabs/reading_screen.dart index 61d8195a6..d2da36b57 100644 --- a/lib/tabs/reading_screen.dart +++ b/lib/tabs/reading_screen.dart @@ -40,7 +40,14 @@ class _ReadingScreenState extends State @override void dispose() { - context.read().add(FlushHistory()); + // Check if widget is still mounted before accessing context + if (mounted) { + try { + context.read().add(FlushHistory()); + } catch (e) { + // Ignore errors during disposal + } + } WidgetsBinding.instance.removeObserver(this); super.dispose(); } diff --git a/lib/text_book/view/combined_view/combined_book_screen.dart b/lib/text_book/view/combined_view/combined_book_screen.dart index 769c560c0..e25802270 100644 --- a/lib/text_book/view/combined_view/combined_book_screen.dart +++ b/lib/text_book/view/combined_view/combined_book_screen.dart @@ -310,7 +310,7 @@ class _CombinedViewState extends State { ); if (mounted) { - Navigator.of(context).pop(); + // Dialog is already closed by NoteEditorDialog // הצגת סרגל ההערות אם הוא לא פתוח final currentState = context.read().state; if (currentState is TextBookLoaded && @@ -323,7 +323,7 @@ class _CombinedViewState extends State { } } catch (e) { if (mounted) { - Navigator.of(context).pop(); + // Dialog is already closed by NoteEditorDialog ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('שגיאה ביצירת הערה: $e')), ); diff --git a/lib/text_book/view/splited_view/simple_book_view.dart b/lib/text_book/view/splited_view/simple_book_view.dart index 03a56b4cf..09169387e 100644 --- a/lib/text_book/view/splited_view/simple_book_view.dart +++ b/lib/text_book/view/splited_view/simple_book_view.dart @@ -287,7 +287,7 @@ class _SimpleBookViewState extends State { ); if (mounted) { - Navigator.of(context).pop(); + // Dialog is already closed by NoteEditorDialog // הצגת סרגל ההערות אם הוא לא פתוח final currentState = context.read().state; if (currentState is TextBookLoaded && !currentState.showNotesSidebar) { @@ -299,7 +299,7 @@ class _SimpleBookViewState extends State { } } catch (e) { if (mounted) { - Navigator.of(context).pop(); + // Dialog is already closed by NoteEditorDialog ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('שגיאה ביצירת הערה: $e')), ); diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index 67385e27c..50e072fa7 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -398,7 +398,7 @@ class _TextBookViewerBlocState extends State ); if (context.mounted) { - Navigator.of(context).pop(); + // Dialog is already closed by NoteEditorDialog // הצגת סרגל ההערות אם הוא לא פתוח final currentState = context.read().state; if (currentState is TextBookLoaded && !currentState.showNotesSidebar) { @@ -410,7 +410,7 @@ class _TextBookViewerBlocState extends State } } catch (e) { if (context.mounted) { - Navigator.of(context).pop(); + // Dialog is already closed by NoteEditorDialog ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('שגיאה ביצירת הערה: $e')), ); From 8b68f7878aec9b367c92f94f52be2c9b516f043a Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 17 Aug 2025 00:20:00 +0300 Subject: [PATCH 128/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=91?= =?UTF-8?q?=D7=90=D7=92=20=D7=91=D7=97=D7=99=D7=A4=D7=95=D7=A9=20=D7=93?= =?UTF-8?q?=D7=A7=D7=93=D7=95=D7=A7=D7=99+=D7=9B=D7=9E/=D7=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/search_repository.dart | 138 +++++++++---------- lib/search/utils/regex_patterns.dart | 122 +++++++++++++--- lib/search/view/enhanced_search_field.dart | 4 +- lib/search/view/search_options_dropdown.dart | 49 +++++-- 4 files changed, 204 insertions(+), 109 deletions(-) diff --git a/lib/search/search_repository.dart b/lib/search/search_repository.dart index 5c9f18762..81844fd7e 100644 --- a/lib/search/search_repository.dart +++ b/lib/search/search_repository.dart @@ -27,22 +27,56 @@ class SearchRepository { Map? customSpacing, Map>? alternativeWords, Map>? searchOptions}) async { + print('🚀 searchTexts called with query: "$query"'); + + // בדיקת וריאציות כתיב מלא/חסר + print('🔍 Testing spelling variations for "ראשית":'); + final testVariations = + SearchRegexPatterns.generateFullPartialSpellingVariations('ראשית'); + print(' variations: $testVariations'); + + // בדיקת createPrefixPattern עבור כל וריאציה + for (final variation in testVariations) { + final prefixPattern = SearchRegexPatterns.createPrefixPattern(variation); + print(' $variation -> $prefixPattern'); + } + + // בדיקת createSpellingWithPrefixPattern + final finalPattern = + SearchRegexPatterns.createSpellingWithPrefixPattern('ראשית'); + print('🔍 Final createSpellingWithPrefixPattern result: $finalPattern'); final index = await TantivyDataProvider.instance.engine; // בדיקה אם יש מרווחים מותאמים אישית, מילים חילופיות או אפשרויות חיפוש final hasCustomSpacing = customSpacing != null && customSpacing.isNotEmpty; final hasAlternativeWords = alternativeWords != null && alternativeWords.isNotEmpty; - final hasSearchOptions = searchOptions != null && searchOptions.isNotEmpty; + final hasSearchOptions = searchOptions != null && + searchOptions.isNotEmpty && + searchOptions.values.any((wordOptions) => + wordOptions.values.any((isEnabled) => isEnabled == true)); + + print('🔍 hasSearchOptions: $hasSearchOptions'); + print('🔍 hasAlternativeWords: $hasAlternativeWords'); // המרת החיפוש לפורמט המנוע החדש // סינון מחרוזות ריקות שנוצרות כאשר יש רווחים בסוף השאילתה - final words = query.trim().split(SearchRegexPatterns.wordSplitter) + final words = query + .trim() + .split(SearchRegexPatterns.wordSplitter) .where((word) => word.isNotEmpty) .toList(); final List regexTerms; final int effectiveSlop; + // הודעת דיבוג לבדיקת search options + if (searchOptions != null && searchOptions.isNotEmpty) { + print('➡️Debug search options:'); + for (final entry in searchOptions.entries) { + print(' ${entry.key}: ${entry.value}'); + } + } + if (hasAlternativeWords || hasSearchOptions) { // יש מילים חילופיות או אפשרויות חיפוש - נבנה queries מתקדמים print('🔄 בונה query מתקדם'); @@ -51,6 +85,8 @@ class SearchRepository { regexTerms = _buildAdvancedQuery(words, alternativeWords, searchOptions); print('🔄 RegexTerms מתקדם: $regexTerms'); + print( + '🔄 effectiveSlop will be: ${hasCustomSpacing ? "custom" : (fuzzy ? distance.toString() : "0")}'); effectiveSlop = hasCustomSpacing ? _getMaxCustomSpacing(customSpacing, words.length) : (fuzzy ? distance : 0); @@ -76,13 +112,24 @@ class SearchRepository { final int maxExpansions = _calculateMaxExpansions(fuzzy, regexTerms.length, searchOptions: searchOptions, words: words); - return await index.search( + print('🔍 Final search params:'); + print(' regexTerms: $regexTerms'); + print(' facets: $facets'); + print(' limit: $limit'); + print(' slop: $effectiveSlop'); + print(' maxExpansions: $maxExpansions'); + print('🚀 Calling index.search...'); + + final results = await index.search( regexTerms: regexTerms, facets: facets, limit: limit, slop: effectiveSlop, maxExpansions: maxExpansions, order: order); + + print('✅ Search completed, found ${results.length} results'); + return results; } /// מחשב את המרווח המקסימלי מהמרווחים המותאמים אישית @@ -140,76 +187,17 @@ class SearchRepository { final allVariations = {}; for (final option in validOptions) { - List baseVariations = [option]; - - // אם יש כתיב מלא/חסר, נוצר את כל הווריאציות של כתיב - if (hasFullPartialSpelling) { - // הגבלה למילים קצרות - כתיב מלא/חסר יכול ליצור הרבה וריאציות - if (option.length <= 3) { - // למילים קצרות, נגביל את מספר הוריאציות - final allSpellingVariations = - HebrewMorphology.generateFullPartialSpellingVariations( - option); - // נקח רק את ה-5 הראשונות כדי למנוע יותר מדי expansions - baseVariations = allSpellingVariations.take(5).toList(); - } else { - baseVariations = - HebrewMorphology.generateFullPartialSpellingVariations( - option); - } - } - - // עבור כל וריאציה של כתיב, מוסיפים את האפשרויות הדקדוקיות - for (final baseVariation in baseVariations) { - if (hasGrammaticalPrefixes && hasGrammaticalSuffixes) { - // שתי האפשרויות יחד - הגבלה למילים קצרות - if (baseVariation.length <= 2) { - // למילים קצרות, נשתמש ברגקס קומפקטי במקום רשימת וריאציות - allVariations.add( - HebrewMorphology.createFullMorphologicalRegexPattern( - baseVariation)); - } else { - allVariations.addAll( - HebrewMorphology.generateFullMorphologicalVariations( - baseVariation)); - } - } else if (hasGrammaticalPrefixes) { - // רק קידומות דקדוקיות - הגבלה למילים קצרות - if (baseVariation.length <= 2) { - // למילים קצרות, נשתמש ברגקס קומפקטי - allVariations.add( - HebrewMorphology.createPrefixRegexPattern(baseVariation)); - } else { - allVariations.addAll( - HebrewMorphology.generatePrefixVariations(baseVariation)); - } - } else if (hasGrammaticalSuffixes) { - // רק סיומות דקדוקיות - הגבלה למילים קצרות - if (baseVariation.length <= 2) { - // למילים קצרות, נשתמש ברגקס קומפקטי - allVariations.add( - HebrewMorphology.createSuffixRegexPattern(baseVariation)); - } else { - allVariations.addAll( - HebrewMorphology.generateSuffixVariations(baseVariation)); - } - } else if (hasPrefix && hasSuffix) { - // קידומות וסיומות יחד - משתמש בחיפוש "חלק ממילה" - allVariations.add(SearchRegexPatterns.createPartialWordPattern(baseVariation)); - } else if (hasPrefix) { - // קידומות רגילות - שימוש ברגקס מרכזי - allVariations.add(SearchRegexPatterns.createPrefixSearchPattern(baseVariation)); - } else if (hasSuffix) { - // סיומות רגילות - שימוש ברגקס מרכזי - allVariations.add(SearchRegexPatterns.createSuffixSearchPattern(baseVariation)); - } else if (hasPartialWord) { - // חלק ממילה - שימוש ברגקס מרכזי - allVariations.add(SearchRegexPatterns.createPartialWordPattern(baseVariation)); - } else { - // ללא אפשרויות מיוחדות - מילה מדויקת - allVariations.add(RegExp.escape(baseVariation)); - } - } + // השתמש בפונקציה המשולבת החדשה + final pattern = SearchRegexPatterns.createSearchPattern( + option, + hasPrefix: hasPrefix, + hasSuffix: hasSuffix, + hasGrammaticalPrefixes: hasGrammaticalPrefixes, + hasGrammaticalSuffixes: hasGrammaticalSuffixes, + hasPartialWord: hasPartialWord, + hasFullPartialSpelling: hasFullPartialSpelling, + ); + allVariations.add(pattern); } // הגבלה על מספר הוריאציות הכולל למילה אחת @@ -224,7 +212,7 @@ class SearchRepository { regexTerms.add(finalPattern); // הודעת דיבוג עם הסבר על הלוגיקה - final searchType = hasPrefix && hasSuffix + final searchType = hasPrefix && hasSuffix ? 'קידומות+סיומות (חלק ממילה)' : hasGrammaticalPrefixes && hasGrammaticalSuffixes ? 'קידומות+סיומות דקדוקיות' @@ -241,7 +229,7 @@ class SearchRepository { : hasFullPartialSpelling ? 'כתיב מלא/חסר' : 'מדויק'; - + print('🔄 מילה $i: $finalPattern (סוג חיפוש: $searchType)'); } else { // fallback למילה המקורית diff --git a/lib/search/utils/regex_patterns.dart b/lib/search/utils/regex_patterns.dart index ff415c495..102b12a50 100644 --- a/lib/search/utils/regex_patterns.dart +++ b/lib/search/utils/regex_patterns.dart @@ -132,11 +132,15 @@ class SearchRegexPatterns { } /// יוצר רגקס לכתיב מלא/חסר - static String createFullPartialSpellingPattern(String word) { + static String createFullPartialSpellingPattern( + String word, { + bool tokenAnchors = true, // עיגון לטוקן כשאין דקדוק/חלק-ממילה + }) { if (word.isEmpty) return word; final variations = generateFullPartialSpellingVariations(word); - final escapedVariations = variations.map((v) => RegExp.escape(v)).toList(); - return r'(?:^|\s)(' + escapedVariations.join('|') + r')(?=\s|$)'; + final escaped = variations.map(RegExp.escape).toList(); + final core = '(?:${escaped.join('|')})'; + return tokenAnchors ? '^$core\$' : core; } // ===== פונקציות עזר ===== @@ -240,12 +244,51 @@ class SearchRegexPatterns { return englishChars.hasMatch(text); } + /// יוצר רגקס משולב לכתיב מלא/חסר עם קידומות דקדוקיות + static String createSpellingWithPrefixPattern(String word) { + if (word.isEmpty) return word; + final variations = generateFullPartialSpellingVariations(word); + // הגבלה על מספר הוריאציות כדי למנוע רגקס ענק + final limitedVariations = + variations.length > 10 ? variations.take(10).toList() : variations; + final patterns = + limitedVariations.map((v) => createPrefixPattern(v)).toList(); + return '(${patterns.join('|')})'; + } + + /// יוצר רגקס משולב לכתיב מלא/חסר עם סיומות דקדוקיות + static String createSpellingWithSuffixPattern(String word) { + if (word.isEmpty) return word; + final variations = generateFullPartialSpellingVariations(word); + // הגבלה על מספר הוריאציות כדי למנוע רגקס ענק + final limitedVariations = + variations.length > 10 ? variations.take(10).toList() : variations; + final patterns = + limitedVariations.map((v) => createSuffixPattern(v)).toList(); + return '(${patterns.join('|')})'; + } + + /// יוצר רגקס משולב לכתיב מלא/חסר עם קידומות וסיומות דקדוקיות + static String createSpellingWithFullMorphologyPattern(String word) { + if (word.isEmpty) return word; + final variations = generateFullPartialSpellingVariations(word); + // הגבלה על מספר הוריאציות כדי למנוע רגקס ענק + final limitedVariations = + variations.length > 8 ? variations.take(8).toList() : variations; + final patterns = limitedVariations + .map((v) => createFullMorphologicalPattern(v)) + .toList(); + return '(${patterns.join('|')})'; + } + /// פונקציה שמחליטה איזה סוג חיפוש להשתמש בהתבסס על אפשרויות המשתמש /// /// הלוגיקה: /// - אם נבחרו גם קידומות וגם סיומות רגילות -> חיפוש "חלק ממילה" /// - אם נבחרו קידומות דקדוקיות וסיומות דקדוקיות -> חיפוש מורפולוגי מלא /// - אחרת -> חיפוש לפי האפשרות הספציפית שנבחרה +// lib/search/utils/regex_patterns.dart + static String createSearchPattern( String word, { bool hasPrefix = false, @@ -253,33 +296,68 @@ class SearchRegexPatterns { bool hasGrammaticalPrefixes = false, bool hasGrammaticalSuffixes = false, bool hasPartialWord = false, + bool hasFullPartialSpelling = false, }) { if (word.isEmpty) return word; - // לוגיקה מיוחדת: קידומות + סיומות רגילות = חלק ממילה - if (hasPrefix && hasSuffix) { - return createPartialWordPattern(word); + // --- לוגיקה עבור שילובים עם "כתיב מלא/חסר" --- + if (hasFullPartialSpelling) { + final hasMorphologyOrPartial = hasGrammaticalPrefixes || + hasGrammaticalSuffixes || + hasPrefix || + hasSuffix || + hasPartialWord; + + // ניצור את הווריאציות פעם אחת בלבד + final variations = generateFullPartialSpellingVariations(word); + + // סדר העדיפויות חשוב כאן! מהספציפי ביותר לכללי ביותר + if (hasPrefix && hasSuffix) { + final patterns = + variations.map((v) => createPartialWordPattern(v)).toList(); + return '(${patterns.join('|')})'; + } else if (hasGrammaticalPrefixes && hasGrammaticalSuffixes) { + return createSpellingWithFullMorphologyPattern(word); + } else if (hasPrefix) { + final patterns = + variations.map((v) => createPrefixSearchPattern(v)).toList(); + return '(${patterns.join('|')})'; + } else if (hasSuffix) { + final patterns = + variations.map((v) => createSuffixSearchPattern(v)).toList(); + return '(${patterns.join('|')})'; + } else if (hasGrammaticalPrefixes) { + return createSpellingWithPrefixPattern(word); + } else if (hasGrammaticalSuffixes) { + return createSpellingWithSuffixPattern(word); + } else if (hasPartialWord) { + final patterns = + variations.map((v) => createPartialWordPattern(v)).toList(); + return '(${patterns.join('|')})'; + } else { + // רק "כתיב מלא/חסר" ללא שום אפשרות אחרת + return createFullPartialSpellingPattern( + word, + tokenAnchors: !hasMorphologyOrPartial, + ); + } } - // קידומות וסיומות דקדוקיות יחד - if (hasGrammaticalPrefixes && hasGrammaticalSuffixes) { + // --- לוגיקה עבור חיפושים ללא "כתיב מלא/חסר" --- + // סדר העדיפויות חשוב גם כאן + if (hasPrefix && hasSuffix) { + return createPartialWordPattern(word); + } else if (hasGrammaticalPrefixes && hasGrammaticalSuffixes) { return createFullMorphologicalPattern(word); - } - - // אפשרויות בודדות - if (hasGrammaticalPrefixes) { - return createPrefixPattern(word); - } - if (hasGrammaticalSuffixes) { - return createSuffixPattern(word); - } - if (hasPrefix) { + } else if (hasPrefix) { return createPrefixSearchPattern(word); - } - if (hasSuffix) { + } else if (hasSuffix) { return createSuffixSearchPattern(word); - } - if (hasPartialWord) { + } else if (hasGrammaticalPrefixes) { + return createPrefixPattern(word); + } else if (hasGrammaticalSuffixes) { + return createSuffixPattern(word); + } else if (hasPartialWord) { return createPartialWordPattern(word); } diff --git a/lib/search/view/enhanced_search_field.dart b/lib/search/view/enhanced_search_field.dart index dd9c7362f..3d68d074f 100644 --- a/lib/search/view/enhanced_search_field.dart +++ b/lib/search/view/enhanced_search_field.dart @@ -14,6 +14,7 @@ import 'package:otzaria/navigation/bloc/navigation_state.dart'; import 'package:otzaria/tabs/bloc/tabs_bloc.dart'; import 'package:otzaria/tabs/bloc/tabs_state.dart'; import 'package:otzaria/search/utils/regex_patterns.dart'; +import 'package:otzaria/search/view/search_options_dropdown.dart'; // הווידג'ט החדש לניהול מצבי הכפתור class _PlusButton extends StatefulWidget { @@ -1738,7 +1739,8 @@ class _EnhancedSearchFieldState extends State { ); } - return _SearchOptionsContent( + return SearchOptionsRow( + isVisible: true, currentWord: wordInfo['word'], wordIndex: wordInfo['index'], wordOptions: widget.widget.tab.searchOptions, diff --git a/lib/search/view/search_options_dropdown.dart b/lib/search/view/search_options_dropdown.dart index ad14e3d3c..71ce0cd35 100644 --- a/lib/search/view/search_options_dropdown.dart +++ b/lib/search/view/search_options_dropdown.dart @@ -54,11 +54,17 @@ class _SearchOptionsDropdownState extends State { class SearchOptionsRow extends StatefulWidget { final bool isVisible; final String? currentWord; // המילה הנוכחית + final int? wordIndex; // אינדקס המילה + final Map>? wordOptions; // אפשרויות מהטאב + final VoidCallback? onOptionsChanged; // קולבק לעדכון const SearchOptionsRow({ super.key, required this.isVisible, this.currentWord, + this.wordIndex, + this.wordOptions, + this.onOptionsChanged, }); @override @@ -66,9 +72,6 @@ class SearchOptionsRow extends StatefulWidget { } class _SearchOptionsRowState extends State { - // מפה שמחזיקה אפשרויות לכל מילה - static final Map> _wordOptions = {}; - // רשימת האפשרויות הזמינות static const List _availableOptions = [ 'קידומות', @@ -81,17 +84,25 @@ class _SearchOptionsRowState extends State { Map _getCurrentWordOptions() { final currentWord = widget.currentWord; - if (currentWord == null || currentWord.isEmpty) { + final wordIndex = widget.wordIndex; + final wordOptions = widget.wordOptions; + + if (currentWord == null || + currentWord.isEmpty || + wordIndex == null || + wordOptions == null) { return Map.fromIterable(_availableOptions, value: (_) => false); } + final key = '${currentWord}_$wordIndex'; + // אם אין אפשרויות למילה הזו, ניצור אותן - if (!_wordOptions.containsKey(currentWord)) { - _wordOptions[currentWord] = + if (!wordOptions.containsKey(key)) { + wordOptions[key] = Map.fromIterable(_availableOptions, value: (_) => false); } - return _wordOptions[currentWord]!; + return wordOptions[key]!; } Widget _buildCheckbox(String option) { @@ -103,8 +114,26 @@ class _SearchOptionsRowState extends State { onTap: () { setState(() { final currentWord = widget.currentWord; - if (currentWord != null && currentWord.isNotEmpty) { - currentOptions[option] = !currentOptions[option]!; + final wordIndex = widget.wordIndex; + final wordOptions = widget.wordOptions; + + if (currentWord != null && + currentWord.isNotEmpty && + wordIndex != null && + wordOptions != null) { + final key = '${currentWord}_$wordIndex'; + + // וודא שהמפתח קיים + if (!wordOptions.containsKey(key)) { + wordOptions[key] = + Map.fromIterable(_availableOptions, value: (_) => false); + } + + // עדכן את האפשרות + wordOptions[key]![option] = !wordOptions[key]![option]!; + + // קרא לקולבק + widget.onOptionsChanged?.call(); } }); }, @@ -160,8 +189,6 @@ class _SearchOptionsRowState extends State { ); } -// lib\search\view\search_options_dropdown.dart - @override Widget build(BuildContext context) { return AnimatedSize( From 8eabfa9b733a285cf9ef5d1657781ac3ac8ea485 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 17 Aug 2025 02:25:09 +0300 Subject: [PATCH 129/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=91?= =?UTF-8?q?=D7=90=D7=92=20=D7=A9=D7=9E=D7=99=D7=A8=D7=AA=20=D7=94=D7=A2?= =?UTF-8?q?=D7=A8=D7=94=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/notes/models/note.dart | 106 +++++++++--------- .../combined_view/combined_book_screen.dart | 24 ++-- .../view/splited_view/simple_book_view.dart | 55 ++++----- lib/text_book/view/text_book_screen.dart | 24 ++-- 4 files changed, 116 insertions(+), 93 deletions(-) diff --git a/lib/notes/models/note.dart b/lib/notes/models/note.dart index 21d861dac..c8c51b73f 100644 --- a/lib/notes/models/note.dart +++ b/lib/notes/models/note.dart @@ -1,25 +1,25 @@ import 'package:equatable/equatable.dart'; /// Represents the status of a note's anchoring in the text. -/// +/// /// The anchoring status indicates how well the note's original location /// has been preserved after text changes in the document. enum NoteStatus { /// Note is anchored to its exact original location. - /// + /// /// This is the ideal state - the text hash matches exactly and the note /// appears at its original character positions. No re-anchoring was needed. anchored, - + /// Note was re-anchored to a shifted but similar location. - /// + /// /// The original text was not found at the exact position, but the anchoring /// system successfully found a highly similar location using context or /// fuzzy matching. The note content is still relevant to the new location. shifted, - + /// Note could not be anchored and requires manual resolution. - /// + /// /// The anchoring system could not find a suitable location for this note. /// This happens when text is significantly changed or deleted. The note /// needs manual review through the Orphan Manager. @@ -27,47 +27,47 @@ enum NoteStatus { } /// Represents the privacy level of a note. -/// +/// /// Controls who can see and access the note content. enum NotePrivacy { /// Note is private to the user. - /// + /// /// Only the user who created the note can see it. This is the default /// privacy level for new notes. private, - + /// Note can be shared with others. - /// + /// /// The note can be exported and shared with other users. Future versions /// may support collaborative note sharing. shared, } /// Represents a personal note attached to a specific text location. -/// +/// /// A note contains user-generated content (markdown) that is anchored to /// a specific location in a book's text. The anchoring system ensures /// notes stay connected to their relevant text even when the book content /// changes. -/// +/// /// ## Core Properties -/// +/// /// - **Identity**: Unique ID and book association /// - **Location**: Character positions and anchoring data /// - **Content**: Markdown text and metadata (tags, privacy) /// - **Anchoring**: Hashes and context for re-anchoring /// - **Status**: Current anchoring state (anchored/shifted/orphan) -/// +/// /// ## Anchoring Data -/// +/// /// Each note stores multiple pieces of anchoring information: /// - Text hash of the selected content /// - Context hashes (before/after the selection) /// - Rolling hashes for sliding window matching /// - Normalization config used when creating hashes -/// +/// /// ## Usage -/// +/// /// ```dart /// // Create a new note /// final note = Note( @@ -78,97 +78,97 @@ enum NotePrivacy { /// contentMarkdown: 'My note content', /// // ... other required fields /// ); -/// +/// /// // Check note status /// if (note.status == NoteStatus.orphan) { /// // Handle orphan note /// } -/// +/// /// // Access note content /// final content = note.contentMarkdown; /// final tags = note.tags; /// ``` -/// +/// /// ## Immutability -/// +/// /// Notes are immutable value objects. Use the `copyWith` method to create /// modified versions: -/// +/// /// ```dart /// final updatedNote = note.copyWith( /// contentMarkdown: 'Updated content', /// status: NoteStatus.shifted, /// ); /// ``` -/// +/// /// ## Equality -/// +/// /// Notes are compared by their ID only. Two notes with the same ID are /// considered equal regardless of other field differences. class Note extends Equatable { /// Unique identifier for the note final String id; - + /// ID of the book this note belongs to final String bookId; - + /// Version ID of the document when note was created final String docVersionId; - + /// Logical path within the document (e.g., ["chapter:3", "para:12"]) final List? logicalPath; - + /// Character start position in the canonical text final int charStart; - + /// Character end position in the canonical text final int charEnd; - + /// Normalized text that was selected when creating the note final String selectedTextNormalized; - + /// SHA-256 hash of the selected normalized text final String textHash; - + /// Context text before the selection (40 chars) final String contextBefore; - + /// Context text after the selection (40 chars) final String contextAfter; - + /// SHA-256 hash of the context before final String contextBeforeHash; - + /// SHA-256 hash of the context after final String contextAfterHash; - + /// Rolling hash of the context before final int rollingBefore; - + /// Rolling hash of the context after final int rollingAfter; - + /// Current anchoring status of the note final NoteStatus status; - + /// The actual note content in markdown format final String contentMarkdown; - + /// ID of the user who created the note final String authorUserId; - + /// Privacy level of the note final NotePrivacy privacy; - + /// Tags associated with the note final List tags; - + /// When the note was created final DateTime createdAt; - + /// When the note was last updated final DateTime updatedAt; - + /// Configuration used for text normalization when creating this note final String normalizationConfig; @@ -229,7 +229,8 @@ class Note extends Equatable { logicalPath: logicalPath ?? this.logicalPath, charStart: charStart ?? this.charStart, charEnd: charEnd ?? this.charEnd, - selectedTextNormalized: selectedTextNormalized ?? this.selectedTextNormalized, + selectedTextNormalized: + selectedTextNormalized ?? this.selectedTextNormalized, textHash: textHash ?? this.textHash, contextBefore: contextBefore ?? this.contextBefore, contextAfter: contextAfter ?? this.contextAfter, @@ -254,7 +255,7 @@ class Note extends Equatable { 'note_id': id, 'book_id': bookId, 'doc_version_id': docVersionId, - 'logical_path': logicalPath != null ? logicalPath!.join(',') : null, + 'logical_path': logicalPath?.join(','), 'char_start': charStart, 'char_end': charEnd, 'selected_text_normalized': selectedTextNormalized, @@ -282,7 +283,7 @@ class Note extends Equatable { id: json['note_id'] as String, bookId: json['book_id'] as String, docVersionId: json['doc_version_id'] as String, - logicalPath: json['logical_path'] != null + logicalPath: json['logical_path'] != null ? (json['logical_path'] as String).split(',') : null, charStart: json['char_start'] as int, @@ -299,8 +300,11 @@ class Note extends Equatable { contentMarkdown: json['content_markdown'] as String, authorUserId: json['author_user_id'] as String, privacy: NotePrivacy.values.byName(json['privacy'] as String), - tags: json['tags'] != null - ? (json['tags'] as String).split(',').where((t) => t.isNotEmpty).toList() + tags: json['tags'] != null + ? (json['tags'] as String) + .split(',') + .where((t) => t.isNotEmpty) + .toList() : [], createdAt: DateTime.parse(json['created_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String), @@ -338,4 +342,4 @@ class Note extends Equatable { String toString() { return 'Note(id: $id, bookId: $bookId, status: $status, content: ${contentMarkdown.length} chars)'; } -} \ No newline at end of file +} diff --git a/lib/text_book/view/combined_view/combined_book_screen.dart b/lib/text_book/view/combined_view/combined_book_screen.dart index e25802270..3ee480dbe 100644 --- a/lib/text_book/view/combined_view/combined_book_screen.dart +++ b/lib/text_book/view/combined_view/combined_book_screen.dart @@ -288,9 +288,13 @@ class _CombinedViewState extends State { /// הצגת עורך ההערות void _showNoteEditor(String selectedText, int charStart, int charEnd) { + // שמירת ה-context המקורי וה-bloc + final originalContext = context; + final textBookBloc = context.read(); + showDialog( context: context, - builder: (context) => NoteEditorDialog( + builder: (dialogContext) => NoteEditorDialog( selectedText: selectedText, bookId: widget.tab.book.title, charStart: charStart, @@ -312,19 +316,19 @@ class _CombinedViewState extends State { if (mounted) { // Dialog is already closed by NoteEditorDialog // הצגת סרגל ההערות אם הוא לא פתוח - final currentState = context.read().state; + final currentState = textBookBloc.state; if (currentState is TextBookLoaded && !currentState.showNotesSidebar) { - context.read().add(const ToggleNotesSidebar()); + textBookBloc.add(const ToggleNotesSidebar()); } - ScaffoldMessenger.of(context).showSnackBar( + ScaffoldMessenger.of(originalContext).showSnackBar( const SnackBar(content: Text('ההערה נוצרה והוצגה בסרגל')), ); } } catch (e) { if (mounted) { // Dialog is already closed by NoteEditorDialog - ScaffoldMessenger.of(context).showSnackBar( + ScaffoldMessenger.of(originalContext).showSnackBar( SnackBar(content: Text('שגיאה ביצירת הערה: $e')), ); } @@ -356,7 +360,9 @@ class _CombinedViewState extends State { _selectionStart = null; _selectionEnd = null; // עדכון ה-BLoC שאין טקסט נבחר - context.read().add(const UpdateSelectedTextForNote(null, null, null)); + context + .read() + .add(const UpdateSelectedTextForNote(null, null, null)); } else { _selectedText = text; _selectionStart = 0; @@ -366,9 +372,11 @@ class _CombinedViewState extends State { _lastSelectedText = text; _lastSelectionStart = 0; _lastSelectionEnd = text.length; - + // עדכון ה-BLoC עם הטקסט הנבחר - context.read().add(UpdateSelectedTextForNote(text, 0, text.length)); + context + .read() + .add(UpdateSelectedTextForNote(text, 0, text.length)); } // בלי setState – כדי לא לרנדר את כל העץ תוך כדי גרירת הבחירה }, diff --git a/lib/text_book/view/splited_view/simple_book_view.dart b/lib/text_book/view/splited_view/simple_book_view.dart index 09169387e..611c60b33 100644 --- a/lib/text_book/view/splited_view/simple_book_view.dart +++ b/lib/text_book/view/splited_view/simple_book_view.dart @@ -42,14 +42,14 @@ class SimpleBookView extends StatefulWidget { class _SimpleBookViewState extends State { final GlobalKey _selectionKey = GlobalKey(); - + // הוסרנו את _showNotesSidebar המקומי - נשתמש ב-state מה-BLoC - + // מעקב אחר בחירת טקסט בלי setState String? _selectedText; int? _selectionStart; int? _selectionEnd; - + // שמירת הבחירה האחרונה לשימוש בתפריט הקונטקסט String? _lastSelectedText; int? _lastSelectionStart; @@ -226,7 +226,8 @@ class _SimpleBookViewState extends State { if (text == null || text.trim().isEmpty) { return 'הוסף הערה'; } - final preview = text.length > 12 ? '${text.substring(0, 12)}...' : text; + final preview = + text.length > 12 ? '${text.substring(0, 12)}...' : text; return 'הוסף הערה ל: "$preview"'; }(), onSelected: () => _createNoteFromSelection(), @@ -241,8 +242,6 @@ class _SimpleBookViewState extends State { ); } - - /// יצירת הערה מטקסט נבחר void _createNoteFromSelection() { // נשתמש בבחירה האחרונה שנשמרה, או בבחירה הנוכחית @@ -264,10 +263,13 @@ class _SimpleBookViewState extends State { /// הצגת עורך ההערות void _showNoteEditor(String selectedText, int charStart, int charEnd) { + // שמירת ה-context המקורי וה-bloc + final originalContext = context; + final textBookBloc = context.read(); showDialog( context: context, - builder: (context) => NoteEditorDialog( + builder: (dialogContext) => NoteEditorDialog( selectedText: selectedText, bookId: widget.tab.book.title, charStart: charStart, @@ -285,22 +287,23 @@ class _SimpleBookViewState extends State { tags: noteRequest.tags, privacy: noteRequest.privacy, ); - + if (mounted) { // Dialog is already closed by NoteEditorDialog // הצגת סרגל ההערות אם הוא לא פתוח - final currentState = context.read().state; - if (currentState is TextBookLoaded && !currentState.showNotesSidebar) { - context.read().add(const ToggleNotesSidebar()); + final currentState = textBookBloc.state; + if (currentState is TextBookLoaded && + !currentState.showNotesSidebar) { + textBookBloc.add(const ToggleNotesSidebar()); } - ScaffoldMessenger.of(context).showSnackBar( + ScaffoldMessenger.of(originalContext).showSnackBar( const SnackBar(content: Text('ההערה נוצרה והוצגה בסרגל')), ); } } catch (e) { if (mounted) { // Dialog is already closed by NoteEditorDialog - ScaffoldMessenger.of(context).showSnackBar( + ScaffoldMessenger.of(originalContext).showSnackBar( SnackBar(content: Text('שגיאה ביצירת הערה: $e')), ); } @@ -310,16 +313,12 @@ class _SimpleBookViewState extends State { ); } - - - - @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { if (state is! TextBookLoaded) return const Center(); - + final bookView = ProgressiveScroll( scrollController: state.scrollOffsetController, maxSpeed: 10000.0, @@ -335,20 +334,24 @@ class _SimpleBookViewState extends State { _selectionStart = null; _selectionEnd = null; // עדכון ה-BLoC שאין טקסט נבחר - context.read().add(const UpdateSelectedTextForNote(null, null, null)); + context + .read() + .add(const UpdateSelectedTextForNote(null, null, null)); } else { _selectedText = text; // בינתיים אינדקסים פשוטים (אפשר לעדכן בעתיד למיפוי אמיתי במסמך) _selectionStart = 0; _selectionEnd = text.length; - + // שמירת הבחירה האחרונה _lastSelectedText = text; _lastSelectionStart = 0; _lastSelectionEnd = text.length; - + // עדכון ה-BLoC עם הטקסט הנבחר - context.read().add(UpdateSelectedTextForNote(text, 0, text.length)); + context + .read() + .add(UpdateSelectedTextForNote(text, 0, text.length)); } // חשוב: לא קוראים ל-setState כאן כדי לא לפגוע בחוויית הבחירה }, @@ -399,7 +402,7 @@ class _SimpleBookViewState extends State { ), ), ); - + // אם סרגל ההערות פתוח, הצג אותו לצד התוכן if (state.showNotesSidebar) { return Row( @@ -413,7 +416,9 @@ class _SimpleBookViewState extends State { flex: 1, child: NotesSidebar( bookId: widget.tab.book.title, - onClose: () => context.read().add(const ToggleNotesSidebar()), + onClose: () => context + .read() + .add(const ToggleNotesSidebar()), onNavigateToPosition: (start, end) { // ניווט למיקום ההערה בטקסט // זה יצריך חישוב של האינדקס המתאים @@ -429,7 +434,7 @@ class _SimpleBookViewState extends State { ], ); } - + return bookView; }, ); diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index 50e072fa7..f0f784797 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -376,10 +376,15 @@ class _TextBookViewerBlocState extends State ); } - void _showNoteEditor(BuildContext context, String selectedText, int charStart, int charEnd, String bookId) { + void _showNoteEditor(BuildContext context, String selectedText, int charStart, + int charEnd, String bookId) { + // שמירת ה-context המקורי וה-bloc + final originalContext = context; + final textBookBloc = context.read(); + showDialog( context: context, - builder: (context) => NoteEditorDialog( + builder: (dialogContext) => NoteEditorDialog( selectedText: selectedText, bookId: bookId, charStart: charStart, @@ -397,21 +402,22 @@ class _TextBookViewerBlocState extends State privacy: noteRequest.privacy, ); - if (context.mounted) { + if (originalContext.mounted) { // Dialog is already closed by NoteEditorDialog // הצגת סרגל ההערות אם הוא לא פתוח - final currentState = context.read().state; - if (currentState is TextBookLoaded && !currentState.showNotesSidebar) { - context.read().add(const ToggleNotesSidebar()); + final currentState = textBookBloc.state; + if (currentState is TextBookLoaded && + !currentState.showNotesSidebar) { + textBookBloc.add(const ToggleNotesSidebar()); } - ScaffoldMessenger.of(context).showSnackBar( + ScaffoldMessenger.of(originalContext).showSnackBar( const SnackBar(content: Text('ההערה נוצרה והוצגה בסרגל')), ); } } catch (e) { - if (context.mounted) { + if (originalContext.mounted) { // Dialog is already closed by NoteEditorDialog - ScaffoldMessenger.of(context).showSnackBar( + ScaffoldMessenger.of(originalContext).showSnackBar( SnackBar(content: Text('שגיאה ביצירת הערה: $e')), ); } From a77e7bc26a30fc7e7f7ad3e3d628bf6cdf0caffd Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 17 Aug 2025 02:56:19 +0300 Subject: [PATCH 130/197] =?UTF-8?q?=D7=94=D7=A1=D7=A8=D7=AA=20=D7=A7=D7=95?= =?UTF-8?q?=D7=91=D7=A5=20=D7=94=D7=90=D7=A4=D7=A4=20=D7=9E=D7=94=D7=9E?= =?UTF-8?q?=D7=AA=D7=A7=D7=99=D7=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- installer/otzaria.iss | 4 +++- installer/otzaria_full.iss | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/installer/otzaria.iss b/installer/otzaria.iss index d588351d1..39c23eb7e 100644 --- a/installer/otzaria.iss +++ b/installer/otzaria.iss @@ -48,5 +48,7 @@ Filename: "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"; WorkingDi Name: "hebrew"; MessagesFile: "compiler:Languages\Hebrew.isl" [Files] -Source: "..\build\windows\x64\runner\release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "..\build\windows\x64\runner\Release\*"; \ + Excludes: "*.msix,*.msixbundle,*.appx,*.appxbundle,*.appinstaller"; \ + DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "uninstall_msix.ps1"; DestDir: "{app}"; Flags: ignoreversion diff --git a/installer/otzaria_full.iss b/installer/otzaria_full.iss index a1ac95578..4fa7afd41 100644 --- a/installer/otzaria_full.iss +++ b/installer/otzaria_full.iss @@ -3322,7 +3322,10 @@ Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: de Name: "hebrew"; MessagesFile: "compiler:Languages\Hebrew.isl" [Files] -Source: "..\build\windows\x64\runner\release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "..\build\windows\x64\runner\Release\*"; \ + Excludes: "*.msix,*.msixbundle,*.appx,*.appxbundle,*.appinstaller"; \ + DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + Source: "..\..\otzaria-library\אוצריא\*"; DestDir: "{app}\אוצריא"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "..\..\otzaria-library\links\*"; DestDir: "{app}\links"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "..\..\otzaria-library\files_manifest.json"; DestDir: "{app}"; Flags: ignoreversion From 490e61836d290512c9499031541a3497cd5214ba Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 17 Aug 2025 02:57:19 +0300 Subject: [PATCH 131/197] =?UTF-8?q?=D7=9E=D7=97=D7=99=D7=A7=D7=AA=20=D7=A7?= =?UTF-8?q?=D7=91=D7=A6=D7=99=20=D7=98=D7=A1=D7=98=D7=99=D7=9D=20=D7=AA?= =?UTF-8?q?=D7=A7=D7=95=D7=9C=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/canonical_text_service_test.dart | 187 ------------- test/notes/services/hash_generator_test.dart | 249 ------------------ 2 files changed, 436 deletions(-) delete mode 100644 test/notes/services/canonical_text_service_test.dart delete mode 100644 test/notes/services/hash_generator_test.dart diff --git a/test/notes/services/canonical_text_service_test.dart b/test/notes/services/canonical_text_service_test.dart deleted file mode 100644 index 10637e019..000000000 --- a/test/notes/services/canonical_text_service_test.dart +++ /dev/null @@ -1,187 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:otzaria/notes/services/canonical_text_service.dart'; -import 'package:otzaria/notes/services/text_normalizer.dart'; -import 'package:otzaria/notes/config/notes_config.dart'; -import 'package:otzaria/notes/models/anchor_models.dart'; - -void main() { - group('CanonicalTextService Tests', () { - late CanonicalTextService service; - - setUp(() { - service = CanonicalTextService.instance; - }); - - group('Document Version Calculation', () { - test('should generate consistent version hashes', () { - const text = 'זהו טקסט לדוגמה'; - final version1 = service.calculateDocumentVersion(text); - final version2 = service.calculateDocumentVersion(text); - - expect(version1, equals(version2)); - expect(version1.length, equals(64)); // SHA-256 length - }); - - test('should generate different versions for different texts', () { - const text1 = 'טקסט ראשון'; - const text2 = 'טקסט שני'; - - final version1 = service.calculateDocumentVersion(text1); - final version2 = service.calculateDocumentVersion(text2); - - expect(version1, isNot(equals(version2))); - }); - }); - - group('Context Window Extraction', () { - test('should extract context window correctly', () { - const text = 'זה טקסט לדוגמה עם הרבה מילים'; - final window = service.extractContextWindow(text, 5, 10); - - expect(window.selected, equals('סט לד')); - expect(window.selectedStart, equals(5)); - expect(window.selectedEnd, equals(10)); - }); - - test('should handle custom window size', () { - const text = 'טקסט קצר'; - final window = service.extractContextWindow(text, 2, 4, windowSize: 2); - - expect(window.before.length, lessThanOrEqualTo(2)); - expect(window.after.length, lessThanOrEqualTo(2)); - }); - }); - - group('Text Segment Operations', () { - test('should extract context window correctly', () { - const text = 'זהו טקסט לדוגמה'; - - final context = service.extractContextWindow(text, 4, 8); - expect(context.selected, equals(text.substring(4, 8))); - expect(context.before, equals(text.substring(0, 4))); - }); - - test('should handle invalid context window range', () { - const text = 'טקסט קצר'; - - // Should not throw, but clamp to valid range - final context = service.extractContextWindow(text, -1, 5); - expect(context.before, equals('')); - expect(context.selected.length, lessThanOrEqualTo(text.length)); - ); - - expect( - () => service.getTextSegment(document, 5, 100), - throwsArgumentError, - ); - - expect( - () => service.getTextSegment(document, 5, 3), - throwsArgumentError, - ); - }); - }); - - group('Hash Matching', () { - test('should find text hash matches', () { - const text = 'זהו טקסט לדוגמה'; - final document = _createMockDocument('test', text); - - // This would normally be populated by the real service - // For testing, we'll assume some matches exist - final matches = service.findTextHashMatches(document, 'dummy-hash'); - expect(matches, isA>()); - }); - - test('should find context matches', () { - const text = 'זהו טקסט לדוגמה'; - final document = _createMockDocument('test', text); - - final matches = service.findContextMatches( - document, - 'before-hash', - 'after-hash', - ); - expect(matches, isA>()); - }); - - test('should find rolling hash matches', () { - const text = 'זהו טקסט לדוגמה'; - final document = _createMockDocument('test', text); - - final matches = service.findRollingHashMatches(document, 12345); - expect(matches, isA>()); - }); - }); - - group('Document Validation', () { - test('should validate proper canonical document', () { - const text = 'זהו טקסט לדוגמה עם תוכן מספיק ארוך לבדיקה'; - final document = _createMockDocument('test-book', text); - - final isValid = service.validateCanonicalDocument(document); - expect(isValid, isTrue); - }); - - test('should reject document with empty fields', () { - final document = _createMockDocument('', ''); - - final isValid = service.validateCanonicalDocument(document); - expect(isValid, isFalse); - }); - - test('should reject document with mismatched version', () { - const text = 'זהו טקסט לדוגמה'; - final document = _createMockDocument('test', text, versionId: 'wrong-version'); - - final isValid = service.validateCanonicalDocument(document); - expect(isValid, isFalse); - }); - }); - - group('Document Statistics', () { - test('should provide comprehensive document stats', () { - const text = 'זהו טקסט לדוגמה עם תוכן'; - final document = _createMockDocument('test-book', text); - - final stats = service.getDocumentStats(document); - - expect(stats['book_id'], equals('test-book')); - expect(stats['text_length'], equals(text.length)); - expect(stats['text_hash_entries'], isA()); - expect(stats['context_hash_entries'], isA()); - expect(stats['rolling_hash_entries'], isA()); - expect(stats['has_logical_structure'], isA()); - }); - }); - }); -} - -/// Helper function to create a mock canonical document for testing -_createMockDocument(String bookId, String text, {String? versionId}) { - final service = CanonicalTextService.instance; - final actualVersionId = versionId ?? service.calculateDocumentVersion(text); - - return MockCanonicalDocument( - bookId: bookId, - versionId: actualVersionId, - canonicalText: text, - textHashIndex: const {}, - contextHashIndex: const {}, - rollingHashIndex: const {}, - logicalStructure: null, - ); -} - -/// Mock implementation for testing -class MockCanonicalDocument extends CanonicalDocument { - const MockCanonicalDocument({ - required super.bookId, - required super.versionId, - required super.canonicalText, - required super.textHashIndex, - required super.contextHashIndex, - required super.rollingHashIndex, - super.logicalStructure, - }); -} \ No newline at end of file diff --git a/test/notes/services/hash_generator_test.dart b/test/notes/services/hash_generator_test.dart deleted file mode 100644 index a3f13c7d8..000000000 --- a/test/notes/services/hash_generator_test.dart +++ /dev/null @@ -1,249 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:otzaria/notes/services/hash_generator.dart'; - -void main() { - group('HashGenerator Tests', () { - group('Text Hash Generation', () { - test('should generate consistent SHA-256 hashes', () { - const text = 'שלום עולם'; - final hash1 = HashGenerator.generateTextHash(text); - final hash2 = HashGenerator.generateTextHash(text); - - expect(hash1, equals(hash2)); - expect(hash1.length, equals(64)); // SHA-256 produces 64 character hex string - }); - - test('should generate different hashes for different texts', () { - const text1 = 'שלום עולם'; - const text2 = 'שלום עולם טוב'; - - final hash1 = HashGenerator.generateTextHash(text1); - final hash2 = HashGenerator.generateTextHash(text2); - - expect(hash1, isNot(equals(hash2))); - }); - - test('should handle empty string', () { - const text = ''; - final hash = HashGenerator.generateTextHash(text); - - expect(hash, isNotEmpty); - expect(hash.length, equals(64)); - }); - - test('should be case sensitive', () { - const text1 = 'Hello World'; - const text2 = 'hello world'; - - final hash1 = HashGenerator.generateTextHash(text1); - final hash2 = HashGenerator.generateTextHash(text2); - - expect(hash1, isNot(equals(hash2))); - }); - }); - - group('Rolling Hash Generation', () { - test('should generate consistent rolling hashes', () { - const text = 'שלום עולם'; - final hash1 = HashGenerator.generateRollingHash(text); - final hash2 = HashGenerator.generateRollingHash(text); - - expect(hash1, equals(hash2)); - }); - - test('should generate different hashes for different texts', () { - const text1 = 'שלום עולם'; - const text2 = 'שלום עולם טוב'; - - final hash1 = HashGenerator.generateRollingHash(text1); - final hash2 = HashGenerator.generateRollingHash(text2); - - expect(hash1, isNot(equals(hash2))); - }); - - test('should handle empty string', () { - const text = ''; - final hash = HashGenerator.generateRollingHash(text); - - expect(hash, equals(0)); - }); - - test('should be order sensitive', () { - const text1 = 'ab'; - const text2 = 'ba'; - - final hash1 = HashGenerator.generateRollingHash(text1); - final hash2 = HashGenerator.generateRollingHash(text2); - - expect(hash1, isNot(equals(hash2))); - }); - }); - - group('Combined Hash Generation', () { - test('should generate text hashes with all components', () { - const text = 'שלום עולם'; - final hashes = HashGenerator.generateTextHashes(text); - - expect(hashes.textHash, isNotEmpty); - expect(hashes.textHash.length, equals(64)); - expect(hashes.rollingHash, isA()); - expect(hashes.length, equals(text.length)); - }); - - test('should generate context hashes', () { - const before = 'לפני'; - const after = 'אחרי'; - - final hashes = HashGenerator.generateContextHashes(before, after); - - expect(hashes.beforeHash, isNotEmpty); - expect(hashes.afterHash, isNotEmpty); - expect(hashes.beforeHash.length, equals(64)); - expect(hashes.afterHash.length, equals(64)); - expect(hashes.beforeRollingHash, isA()); - expect(hashes.afterRollingHash, isA()); - }); - }); - }); - - group('RollingHashWindow Tests', () { - test('should initialize with text correctly', () { - final window = RollingHashWindow(5); - window.init('hello'); - - expect(window.isFull, isTrue); - expect(window.currentWindow, equals('hello')); - expect(window.currentHash, isA()); - }); - - test('should slide window correctly', () { - final window = RollingHashWindow(3); - window.init('abc'); - - final initialHash = window.currentHash; - expect(window.currentWindow, equals('abc')); - - // Slide to 'bcd' - final newHash = window.slide('a'.codeUnitAt(0), 'd'.codeUnitAt(0)); - expect(window.currentWindow, equals('bcd')); - expect(newHash, isNot(equals(initialHash))); - expect(window.currentHash, equals(newHash)); - }); - - test('should handle partial window initialization', () { - final window = RollingHashWindow(5); - window.init('hi'); - - expect(window.isFull, isFalse); - expect(window.currentWindow, equals('hi')); - }); - - test('should maintain consistent hash for same content', () { - final window1 = RollingHashWindow(4); - final window2 = RollingHashWindow(4); - - window1.init('test'); - window2.init('test'); - - expect(window1.currentHash, equals(window2.currentHash)); - }); - - test('should handle sliding with partial window', () { - final window = RollingHashWindow(5); - window.init('hi'); - - expect(window.isFull, isFalse); - - // Add more characters - window.slide(0, 'a'.codeUnitAt(0)); // Should add 'a' - expect(window.currentWindow, equals('hia')); - expect(window.isFull, isFalse); - }); - }); - - group('DocumentHasher Tests', () { - test('should generate document version hash', () { - const document = 'זהו מסמך לדוגמה עם הרבה טקסט'; - final version = DocumentHasher.generateDocumentVersion(document); - - expect(version, isNotEmpty); - expect(version.length, equals(64)); - }); - - test('should generate consistent document versions', () { - const document = 'זהו מסמך לדוגמה'; - final version1 = DocumentHasher.generateDocumentVersion(document); - final version2 = DocumentHasher.generateDocumentVersion(document); - - expect(version1, equals(version2)); - }); - - test('should generate different versions for different documents', () { - const doc1 = 'מסמך ראשון'; - const doc2 = 'מסמך שני'; - - final version1 = DocumentHasher.generateDocumentVersion(doc1); - final version2 = DocumentHasher.generateDocumentVersion(doc2); - - expect(version1, isNot(equals(version2))); - }); - - test('should generate section hash with index', () { - const section = 'זהו קטע במסמך'; - const index = 5; - - final hash = DocumentHasher.generateSectionHash(section, index); - - expect(hash, isNotEmpty); - expect(hash.length, equals(64)); - }); - - test('should generate different section hashes for different indexes', () { - const section = 'אותו קטע'; - - final hash1 = DocumentHasher.generateSectionHash(section, 1); - final hash2 = DocumentHasher.generateSectionHash(section, 2); - - expect(hash1, isNot(equals(hash2))); - }); - - test('should generate incremental hash', () { - const previousHash = 'abc123'; - const newContent = 'תוכן חדש'; - - final incrementalHash = DocumentHasher.generateIncrementalHash(previousHash, newContent); - - expect(incrementalHash, isNotEmpty); - expect(incrementalHash.length, equals(64)); - expect(incrementalHash, isNot(equals(previousHash))); - }); - }); - - group('Hash Container Tests', () { - test('TextHashes should provide meaningful toString', () { - final hashes = TextHashes( - textHash: 'abcdef1234567890' * 4, // 64 chars - rollingHash: 12345, - length: 10, - ); - - final string = hashes.toString(); - expect(string, contains('abcdef12')); - expect(string, contains('12345')); - expect(string, contains('10')); - }); - - test('ContextHashes should provide meaningful toString', () { - final hashes = ContextHashes( - beforeHash: 'before12' + '0' * 56, - afterHash: 'after123' + '0' * 56, - beforeRollingHash: 111, - afterRollingHash: 222, - ); - - final string = hashes.toString(); - expect(string, contains('before12')); - expect(string, contains('after123')); - }); - }); -} \ No newline at end of file From c5c93d9ed73279337f10206cd7ca3193cd52fae8 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 17 Aug 2025 22:47:21 +0300 Subject: [PATCH 132/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=A2?= =?UTF-8?q?=D7=A8=D7=99=D7=9D=20=D7=9C=D7=9C=D7=95=D7=97=20=D7=94=D7=A9?= =?UTF-8?q?=D7=A0=D7=94,=20=D7=96=D7=9E=D7=A0=D7=99=D7=9D,=20=D7=97=D7=99?= =?UTF-8?q?=D7=A4=D7=95=D7=A9=20=D7=91=D7=A2=D7=A8=D7=99=D7=9D,=20=D7=A2?= =?UTF-8?q?=D7=99=D7=A8=20=D7=9E=D7=95=D7=AA=D7=90=D7=9E=D7=AA=20=D7=90?= =?UTF-8?q?=D7=99=D7=A9=D7=99=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/navigation/calendar_cubit.dart | 409 +++++++++++++++------------- lib/navigation/calendar_widget.dart | 178 ++++++++++-- 2 files changed, 375 insertions(+), 212 deletions(-) diff --git a/lib/navigation/calendar_cubit.dart b/lib/navigation/calendar_cubit.dart index 8d7908c4e..fa5c09e7c 100644 --- a/lib/navigation/calendar_cubit.dart +++ b/lib/navigation/calendar_cubit.dart @@ -250,204 +250,219 @@ class CalendarCubit extends Cubit { } // City coordinates map - מסודר לפי מדינות ובסדר א-ב -const Map> cityCoordinates = { - // === ארץ ישראל === - 'אילת': {'lat': 29.5581, 'lng': 34.9482, 'elevation': 12.0}, - 'אריאל': {'lat': 32.1069, 'lng': 35.1897, 'elevation': 650.0}, - 'אשדוד': {'lat': 31.8044, 'lng': 34.6553, 'elevation': 50.0}, - 'אשקלון': {'lat': 31.6688, 'lng': 34.5742, 'elevation': 50.0}, - 'באר שבע': {'lat': 31.2518, 'lng': 34.7915, 'elevation': 280.0}, - 'בני ברק': {'lat': 32.0809, 'lng': 34.8338, 'elevation': 50.0}, - 'בת ים': {'lat': 32.0167, 'lng': 34.7500, 'elevation': 5.0}, - 'גבעת זאב': {'lat': 31.8467, 'lng': 35.1667, 'elevation': 600.0}, - 'גבעתיים': {'lat': 32.0706, 'lng': 34.8103, 'elevation': 80.0}, - 'דימונה': {'lat': 31.0686, 'lng': 35.0333, 'elevation': 550.0}, - 'הוד השרון': {'lat': 32.1506, 'lng': 34.8889, 'elevation': 40.0}, - 'הרצליה': {'lat': 32.1624, 'lng': 34.8443, 'elevation': 40.0}, - 'חיפה': {'lat': 32.7940, 'lng': 34.9896, 'elevation': 30.0}, - 'חולון': {'lat': 32.0117, 'lng': 34.7689, 'elevation': 54.0}, - 'טבריה': {'lat': 32.7940, 'lng': 35.5308, 'elevation': -200.0}, - 'יבנה': {'lat': 31.8781, 'lng': 34.7378, 'elevation': 25.0}, - 'ירושלים': {'lat': 31.7683, 'lng': 35.2137, 'elevation': 800.0}, - 'כפר סבא': {'lat': 32.1742, 'lng': 34.9067, 'elevation': 75.0}, - 'כרמיאל': {'lat': 32.9186, 'lng': 35.2958, 'elevation': 300.0}, - 'לוד': {'lat': 31.9516, 'lng': 34.8958, 'elevation': 50.0}, - 'מודיעין עילית': {'lat': 31.9254, 'lng': 35.0364, 'elevation': 400.0}, - 'מצפה רמון': {'lat': 30.6097, 'lng': 34.8017, 'elevation': 860.0}, - 'מעלה אדומים': {'lat': 31.7767, 'lng': 35.2973, 'elevation': 740.0}, - 'נתניה': {'lat': 32.3215, 'lng': 34.8532, 'elevation': 30.0}, - 'נצרת עילית': {'lat': 32.6992, 'lng': 35.3289, 'elevation': 400.0}, - 'עפולה': {'lat': 32.6078, 'lng': 35.2897, 'elevation': 60.0}, - 'פתח תקווה': {'lat': 32.0870, 'lng': 34.8873, 'elevation': 80.0}, - 'צפת': {'lat': 32.9650, 'lng': 35.4951, 'elevation': 900.0}, - 'קרית אונו': {'lat': 32.0539, 'lng': 34.8581, 'elevation': 75.0}, - 'קרית ארבע': {'lat': 31.5244, 'lng': 35.1031, 'elevation': 930.0}, - 'קרית גת': {'lat': 31.6100, 'lng': 34.7642, 'elevation': 68.0}, - 'קרית מלאכי': {'lat': 31.7289, 'lng': 34.7456, 'elevation': 108.0}, - 'קרית שמונה': {'lat': 33.2072, 'lng': 35.5692, 'elevation': 135.0}, - 'ראשון לציון': {'lat': 31.9642, 'lng': 34.8047, 'elevation': 68.0}, - 'רחובות': {'lat': 31.8947, 'lng': 34.8096, 'elevation': 89.0}, - 'רמלה': {'lat': 31.9297, 'lng': 34.8667, 'elevation': 108.0}, - 'רמת גן': {'lat': 32.0719, 'lng': 34.8244, 'elevation': 80.0}, - 'רעננה': {'lat': 32.1847, 'lng': 34.8706, 'elevation': 45.0}, - 'תל אביב': {'lat': 32.0853, 'lng': 34.7818, 'elevation': 5.0}, - - // === ארצות הברית === - 'אטלנטה': {'lat': 33.7490, 'lng': -84.3880, 'elevation': 320.0}, - 'בוסטון': {'lat': 42.3601, 'lng': -71.0589, 'elevation': 43.0}, - 'בלטימור': {'lat': 39.2904, 'lng': -76.6122, 'elevation': 10.0}, - 'דטרויט': {'lat': 42.3314, 'lng': -83.0458, 'elevation': 183.0}, - 'דנבר': {'lat': 39.7392, 'lng': -104.9903, 'elevation': 1609.0}, - 'לאס וגאס': {'lat': 36.1699, 'lng': -115.1398, 'elevation': 610.0}, - 'לוס אנג\'לס': {'lat': 34.0522, 'lng': -118.2437, 'elevation': 71.0}, - 'מיאמי': {'lat': 25.7617, 'lng': -80.1918, 'elevation': 2.0}, - 'ניו יורק': {'lat': 40.7128, 'lng': -74.0060, 'elevation': 10.0}, - 'סיאטל': {'lat': 47.6062, 'lng': -122.3321, 'elevation': 56.0}, - 'סן פרנסיסקו': {'lat': 37.7749, 'lng': -122.4194, 'elevation': 16.0}, - 'פילדלפיה': {'lat': 39.9526, 'lng': -75.1652, 'elevation': 12.0}, - 'פיניקס': {'lat': 33.4484, 'lng': -112.0740, 'elevation': 331.0}, - 'קליבלנד': {'lat': 41.4993, 'lng': -81.6944, 'elevation': 199.0}, - 'שיקגו': {'lat': 41.8781, 'lng': -87.6298, 'elevation': 181.0}, - - // === קנדה === - 'אדמונטון': {'lat': 53.5461, 'lng': -113.4938, 'elevation': 645.0}, - 'אוטווה': {'lat': 45.4215, 'lng': -75.6972, 'elevation': 70.0}, - 'ונקובר': {'lat': 49.2827, 'lng': -123.1207, 'elevation': 70.0}, - 'טורונטו': {'lat': 43.6532, 'lng': -79.3832, 'elevation': 76.0}, - 'מונטריאול': {'lat': 45.5017, 'lng': -73.5673, 'elevation': 36.0}, - 'קלגרי': {'lat': 51.0447, 'lng': -114.0719, 'elevation': 1048.0}, - - // === בריטניה === - 'אדינבורו': {'lat': 55.9533, 'lng': -3.1883, 'elevation': 47.0}, - 'לונדון': {'lat': 51.5074, 'lng': -0.1278, 'elevation': 35.0}, - - // === צרפת === - 'פריז': {'lat': 48.8566, 'lng': 2.3522, 'elevation': 35.0}, - - // === גרמניה === - 'ברלין': {'lat': 52.5200, 'lng': 13.4050, 'elevation': 34.0}, - - // === איטליה === - 'מילאנו': {'lat': 45.4642, 'lng': 9.1900, 'elevation': 122.0}, - 'רומא': {'lat': 41.9028, 'lng': 12.4964, 'elevation': 21.0}, - - // === ספרד === - 'מדריד': {'lat': 40.4168, 'lng': -3.7038, 'elevation': 650.0}, - - // === הולנד === - 'אמסטרדם': {'lat': 52.3676, 'lng': 4.9041, 'elevation': -2.0}, - - // === שוויץ === - 'ציריך': {'lat': 47.3769, 'lng': 8.5417, 'elevation': 408.0}, - - // === אוסטריה === - 'וינה': {'lat': 48.2082, 'lng': 16.3738, 'elevation': 171.0}, - - // === הונגריה === - 'בודפשט': {'lat': 47.4979, 'lng': 19.0402, 'elevation': 102.0}, - - // === צ'כיה === - 'פראג': {'lat': 50.0755, 'lng': 14.4378, 'elevation': 200.0}, - - // === פולין === - 'ורשה': {'lat': 52.2297, 'lng': 21.0122, 'elevation': 100.0}, - - // === רוסיה === - 'מוסקבה': {'lat': 55.7558, 'lng': 37.6176, 'elevation': 156.0}, - - // === טורקיה === - 'איסטנבול': {'lat': 41.0082, 'lng': 28.9784, 'elevation': 39.0}, - - // === פורטוגל === - 'ליסבון': {'lat': 38.7223, 'lng': -9.1393, 'elevation': 2.0}, - - // === אירלנד === - 'דבלין': {'lat': 53.3498, 'lng': -6.2603, 'elevation': 85.0}, - - // === שוודיה === - 'סטוקהולם': {'lat': 59.3293, 'lng': 18.0686, 'elevation': 28.0}, - - // === דנמרק === - 'קופנהגן': {'lat': 55.6761, 'lng': 12.5683, 'elevation': 24.0}, - - // === פינלנד === - 'הלסינקי': {'lat': 60.1699, 'lng': 24.9384, 'elevation': 26.0}, - - // === נורווגיה === - 'אוסלו': {'lat': 59.9139, 'lng': 10.7522, 'elevation': 23.0}, - - // === איסלנד === - 'רייקיאוויק': {'lat': 64.1466, 'lng': -21.9426, 'elevation': 61.0}, - - // === ארגנטינה === - 'בואנוס איירס': {'lat': -34.6118, 'lng': -58.3960, 'elevation': 25.0}, - - // === ברזיל === - 'ריו דה ז\'נרו': {'lat': -22.9068, 'lng': -43.1729, 'elevation': 2.0}, - 'סאו פאולו': {'lat': -23.5505, 'lng': -46.6333, 'elevation': 760.0}, - - // === צ'ילה === - 'סנטיאגו': {'lat': -33.4489, 'lng': -70.6693, 'elevation': 520.0}, - - // === ונצואלה === - 'קראקס': {'lat': 10.4806, 'lng': -66.9036, 'elevation': 900.0}, - - // === פרו === - 'לימה': {'lat': -12.0464, 'lng': -77.0428, 'elevation': 154.0}, - - // === מקסיקו === - 'מקסיקו סיטי': {'lat': 19.4326, 'lng': -99.1332, 'elevation': 2240.0}, - - // === מרוקו === - 'קזבלנקה': {'lat': 33.5731, 'lng': -7.5898, 'elevation': 50.0}, - - // === דרום אפריקה === - 'יוהנסבורג': {'lat': -26.2041, 'lng': 28.0473, 'elevation': 1753.0}, - 'קייפטאון': {'lat': -33.9249, 'lng': 18.4241, 'elevation': 42.0}, - - // === מצרים === - 'אלכסנדריה': {'lat': 31.2001, 'lng': 29.9187, 'elevation': 12.0}, - 'קהיר': {'lat': 30.0444, 'lng': 31.2357, 'elevation': 74.0}, - - // === הודו === - 'דלהי': {'lat': 28.7041, 'lng': 77.1025, 'elevation': 216.0}, - 'מומבאי': {'lat': 19.0760, 'lng': 72.8777, 'elevation': 14.0}, - - // === תאילנד === - 'בנגקוק': {'lat': 13.7563, 'lng': 100.5018, 'elevation': 1.5}, - - // === סינגפור === - 'סינגפור': {'lat': 1.3521, 'lng': 103.8198, 'elevation': 15.0}, - - // === הונג קונג === - 'הונג קונג': {'lat': 22.3193, 'lng': 114.1694, 'elevation': 552.0}, - - // === יפן === - 'טוקיו': {'lat': 35.6762, 'lng': 139.6503, 'elevation': 40.0}, - - // === דרום קוריאה === - 'סיאול': {'lat': 37.5665, 'lng': 126.9780, 'elevation': 38.0}, - - // === סין === - 'בייג\'ינג': {'lat': 39.9042, 'lng': 116.4074, 'elevation': 43.5}, - 'שנחאי': {'lat': 31.2304, 'lng': 121.4737, 'elevation': 4.0}, - - // === איחוד האמירויות === - 'דובאי': {'lat': 25.2048, 'lng': 55.2708, 'elevation': 16.0}, - - // === כווית === - 'כווית': {'lat': 29.3759, 'lng': 47.9774, 'elevation': 55.0}, - - // === אוסטרליה === - 'בריסביין': {'lat': -27.4698, 'lng': 153.0251, 'elevation': 27.0}, - 'מלבורן': {'lat': -37.8136, 'lng': 144.9631, 'elevation': 31.0}, - 'פרת': {'lat': -31.9505, 'lng': 115.8605, 'elevation': 46.0}, - 'סידני': {'lat': -33.8688, 'lng': 151.2093, 'elevation': 58.0}, +const Map>> cityCoordinates = { + 'ארץ ישראל': { + 'אופקים': {'lat': 31.3111, 'lng': 34.6214, 'elevation': 140.0}, + 'אילת': {'lat': 29.5581, 'lng': 34.9482, 'elevation': 12.0}, + 'אריאל': {'lat': 32.1069, 'lng': 35.1897, 'elevation': 650.0}, + 'אשדוד': {'lat': 31.8044, 'lng': 34.6553, 'elevation': 50.0}, + 'אשקלון': {'lat': 31.6688, 'lng': 34.5742, 'elevation': 50.0}, + 'באר שבע': {'lat': 31.2518, 'lng': 34.7915, 'elevation': 280.0}, + 'ביתר עילית': {'lat': 31.7025, 'lng': 35.1156, 'elevation': 740.0}, + 'בית שמש': {'lat': 31.7245, 'lng': 34.9886, 'elevation': 220.0}, + 'בני ברק': {'lat': 32.0809, 'lng': 34.8338, 'elevation': 50.0}, + 'בת ים': {'lat': 32.0167, 'lng': 34.7500, 'elevation': 5.0}, + 'גבעת זאב': {'lat': 31.8467, 'lng': 35.1667, 'elevation': 600.0}, + 'גבעתיים': {'lat': 32.0706, 'lng': 34.8103, 'elevation': 80.0}, + 'דימונה': {'lat': 31.0686, 'lng': 35.0333, 'elevation': 550.0}, + 'הוד השרון': {'lat': 32.1506, 'lng': 34.8889, 'elevation': 40.0}, + 'הרצליה': {'lat': 32.1624, 'lng': 34.8443, 'elevation': 40.0}, + 'חיפה': {'lat': 32.7940, 'lng': 34.9896, 'elevation': 30.0}, + 'חולון': {'lat': 32.0117, 'lng': 34.7689, 'elevation': 54.0}, + 'טבריה': {'lat': 32.7940, 'lng': 35.5308, 'elevation': -200.0}, + 'יבנה': {'lat': 31.8781, 'lng': 34.7378, 'elevation': 25.0}, + 'ירושלים': {'lat': 31.7683, 'lng': 35.2137, 'elevation': 800.0}, + 'כפר סבא': {'lat': 32.1742, 'lng': 34.9067, 'elevation': 75.0}, + 'כרמיאל': {'lat': 32.9186, 'lng': 35.2958, 'elevation': 300.0}, + 'לוד': {'lat': 31.9516, 'lng': 34.8958, 'elevation': 50.0}, + 'מודיעין עילית': {'lat': 31.9254, 'lng': 35.0364, 'elevation': 400.0}, + 'מצפה רמון': {'lat': 30.6097, 'lng': 34.8017, 'elevation': 860.0}, + 'מעלה אדומים': {'lat': 31.7767, 'lng': 35.2973, 'elevation': 740.0}, + 'נתיבות': {'lat': 31.4214, 'lng': 34.5911, 'elevation': 140.0}, + 'נתניה': {'lat': 32.3215, 'lng': 34.8532, 'elevation': 30.0}, + 'נצרת עילית': {'lat': 32.6992, 'lng': 35.3289, 'elevation': 400.0}, + 'עפולה': {'lat': 32.6078, 'lng': 35.2897, 'elevation': 60.0}, + 'פתח תקווה': {'lat': 32.0870, 'lng': 34.8873, 'elevation': 80.0}, + 'צפת': {'lat': 32.9650, 'lng': 35.4951, 'elevation': 900.0}, + 'קרית אונו': {'lat': 32.0539, 'lng': 34.8581, 'elevation': 75.0}, + 'קרית ארבע': {'lat': 31.5244, 'lng': 35.1031, 'elevation': 930.0}, + 'קרית גת': {'lat': 31.6100, 'lng': 34.7642, 'elevation': 68.0}, + 'קרית מלאכי': {'lat': 31.7289, 'lng': 34.7456, 'elevation': 108.0}, + 'קרית שמונה': {'lat': 33.2072, 'lng': 35.5692, 'elevation': 135.0}, + 'ראשון לציון': {'lat': 31.9642, 'lng': 34.8047, 'elevation': 68.0}, + 'רחובות': {'lat': 31.8947, 'lng': 34.8096, 'elevation': 89.0}, + 'רמלה': {'lat': 31.9297, 'lng': 34.8667, 'elevation': 108.0}, + 'רמת גן': {'lat': 32.0719, 'lng': 34.8244, 'elevation': 80.0}, + 'רעננה': {'lat': 32.1847, 'lng': 34.8706, 'elevation': 45.0}, + 'תל אביב': {'lat': 32.0853, 'lng': 34.7818, 'elevation': 5.0}, + 'תפרח': {'lat': 31.3889, 'lng': 34.6861, 'elevation': 160.0}, + }, + 'ארצות הברית': { + 'אטלנטה': {'lat': 33.7490, 'lng': -84.3880, 'elevation': 320.0}, + 'בוסטון': {'lat': 42.3601, 'lng': -71.0589, 'elevation': 43.0}, + 'בלטימור': {'lat': 39.2904, 'lng': -76.6122, 'elevation': 10.0}, + 'דטרויט': {'lat': 42.3314, 'lng': -83.0458, 'elevation': 183.0}, + 'דנבר': {'lat': 39.7392, 'lng': -104.9903, 'elevation': 1609.0}, + 'לאס וגאס': {'lat': 36.1699, 'lng': -115.1398, 'elevation': 610.0}, + 'לוס אנג\'לס': {'lat': 34.0522, 'lng': -118.2437, 'elevation': 71.0}, + 'מיאמי': {'lat': 25.7617, 'lng': -80.1918, 'elevation': 2.0}, + 'ניו יורק': {'lat': 40.7128, 'lng': -74.0060, 'elevation': 10.0}, + 'סיאטל': {'lat': 47.6062, 'lng': -122.3321, 'elevation': 56.0}, + 'סן פרנסיסקו': {'lat': 37.7749, 'lng': -122.4194, 'elevation': 16.0}, + 'פילדלפיה': {'lat': 39.9526, 'lng': -75.1652, 'elevation': 12.0}, + 'פיניקס': {'lat': 33.4484, 'lng': -112.0740, 'elevation': 331.0}, + 'קליבלנד': {'lat': 41.4993, 'lng': -81.6944, 'elevation': 199.0}, + 'שיקגו': {'lat': 41.8781, 'lng': -87.6298, 'elevation': 181.0}, + }, + 'קנדה': { + 'אדמונטון': {'lat': 53.5461, 'lng': -113.4938, 'elevation': 645.0}, + 'אוטווה': {'lat': 45.4215, 'lng': -75.6972, 'elevation': 70.0}, + 'ונקובר': {'lat': 49.2827, 'lng': -123.1207, 'elevation': 70.0}, + 'טורונטו': {'lat': 43.6532, 'lng': -79.3832, 'elevation': 76.0}, + 'מונטריאול': {'lat': 45.5017, 'lng': -73.5673, 'elevation': 36.0}, + 'קלגרי': {'lat': 51.0447, 'lng': -114.0719, 'elevation': 1048.0}, + }, + 'בריטניה': { + 'אדינבורו': {'lat': 55.9533, 'lng': -3.1883, 'elevation': 47.0}, + 'לונדון': {'lat': 51.5074, 'lng': -0.1278, 'elevation': 35.0}, + }, + 'צרפת': { + 'פריז': {'lat': 48.8566, 'lng': 2.3522, 'elevation': 35.0}, + }, + 'גרמניה': { + 'ברלין': {'lat': 52.5200, 'lng': 13.4050, 'elevation': 34.0}, + }, + 'איטליה': { + 'מילאנו': {'lat': 45.4642, 'lng': 9.1900, 'elevation': 122.0}, + 'רומא': {'lat': 41.9028, 'lng': 12.4964, 'elevation': 21.0}, + }, + 'ספרד': { + 'מדריד': {'lat': 40.4168, 'lng': -3.7038, 'elevation': 650.0}, + }, + 'הולנד': { + 'אמסטרדם': {'lat': 52.3676, 'lng': 4.9041, 'elevation': -2.0}, + }, + 'שוויץ': { + 'ציריך': {'lat': 47.3769, 'lng': 8.5417, 'elevation': 408.0}, + }, + 'אוסטריה': { + 'וינה': {'lat': 48.2082, 'lng': 16.3738, 'elevation': 171.0}, + }, + 'הונגריה': { + 'בודפשט': {'lat': 47.4979, 'lng': 19.0402, 'elevation': 102.0}, + }, + 'צ\'כיה': { + 'פראג': {'lat': 50.0755, 'lng': 14.4378, 'elevation': 200.0}, + }, + 'פולין': { + 'ורשה': {'lat': 52.2297, 'lng': 21.0122, 'elevation': 100.0}, + }, + 'רוסיה': { + 'מוסקבה': {'lat': 55.7558, 'lng': 37.6176, 'elevation': 156.0}, + }, + 'טורקיה': { + 'איסטנבול': {'lat': 41.0082, 'lng': 28.9784, 'elevation': 39.0}, + }, + 'פורטוגל': { + 'ליסבון': {'lat': 38.7223, 'lng': -9.1393, 'elevation': 2.0}, + }, + 'אירלנד': { + 'דבלין': {'lat': 53.3498, 'lng': -6.2603, 'elevation': 85.0}, + }, + 'שוודיה': { + 'סטוקהולם': {'lat': 59.3293, 'lng': 18.0686, 'elevation': 28.0}, + }, + 'דנמרק': { + 'קופנהגן': {'lat': 55.6761, 'lng': 12.5683, 'elevation': 24.0}, + }, + 'פינלנד': { + 'הלסינקי': {'lat': 60.1699, 'lng': 24.9384, 'elevation': 26.0}, + }, + 'נורווגיה': { + 'אוסלו': {'lat': 59.9139, 'lng': 10.7522, 'elevation': 23.0}, + }, + 'איסלנד': { + 'רייקיאוויק': {'lat': 64.1466, 'lng': -21.9426, 'elevation': 61.0}, + }, + 'ארגנטינה': { + 'בואנוס איירס': {'lat': -34.6118, 'lng': -58.3960, 'elevation': 25.0}, + }, + 'ברזיל': { + 'ריו דה ז\'נרו': {'lat': -22.9068, 'lng': -43.1729, 'elevation': 2.0}, + 'סאו פאולו': {'lat': -23.5505, 'lng': -46.6333, 'elevation': 760.0}, + }, + 'צ\'ילה': { + 'סנטיאגו': {'lat': -33.4489, 'lng': -70.6693, 'elevation': 520.0}, + }, + 'ונצואלה': { + 'קראקס': {'lat': 10.4806, 'lng': -66.9036, 'elevation': 900.0}, + }, + 'פרו': { + 'לימה': {'lat': -12.0464, 'lng': -77.0428, 'elevation': 154.0}, + }, + 'מקסיקו': { + 'מקסיקו סיטי': {'lat': 19.4326, 'lng': -99.1332, 'elevation': 2240.0}, + }, + 'מרוקו': { + 'קזבלנקה': {'lat': 33.5731, 'lng': -7.5898, 'elevation': 50.0}, + }, + 'דרום אפריקה': { + 'יוהנסבורג': {'lat': -26.2041, 'lng': 28.0473, 'elevation': 1753.0}, + 'קייפטאון': {'lat': -33.9249, 'lng': 18.4241, 'elevation': 42.0}, + }, + 'מצרים': { + 'אלכסנדריה': {'lat': 31.2001, 'lng': 29.9187, 'elevation': 12.0}, + 'קהיר': {'lat': 30.0444, 'lng': 31.2357, 'elevation': 74.0}, + }, + 'הודו': { + 'דלהי': {'lat': 28.7041, 'lng': 77.1025, 'elevation': 216.0}, + 'מומבאי': {'lat': 19.0760, 'lng': 72.8777, 'elevation': 14.0}, + }, + 'תאילנד': { + 'בנגקוק': {'lat': 13.7563, 'lng': 100.5018, 'elevation': 1.5}, + }, + 'סינגפור': { + 'סינגפור': {'lat': 1.3521, 'lng': 103.8198, 'elevation': 15.0}, + }, + 'הונג קונג': { + 'הונג קונג': {'lat': 22.3193, 'lng': 114.1694, 'elevation': 552.0}, + }, + 'יפן': { + 'טוקיו': {'lat': 35.6762, 'lng': 139.6503, 'elevation': 40.0}, + }, + 'דרום קוריאה': { + 'סיאול': {'lat': 37.5665, 'lng': 126.9780, 'elevation': 38.0}, + }, + 'סין': { + 'בייג\'ינג': {'lat': 39.9042, 'lng': 116.4074, 'elevation': 43.5}, + 'שנחאי': {'lat': 31.2304, 'lng': 121.4737, 'elevation': 4.0}, + }, + 'איחוד האמירויות': { + 'דובאי': {'lat': 25.2048, 'lng': 55.2708, 'elevation': 16.0}, + }, + 'כווית': { + 'כווית': {'lat': 29.3759, 'lng': 47.9774, 'elevation': 55.0}, + }, + 'אוסטרליה': { + 'בריסביין': {'lat': -27.4698, 'lng': 153.0251, 'elevation': 27.0}, + 'מלבורן': {'lat': -37.8136, 'lng': 144.9631, 'elevation': 31.0}, + 'פרת': {'lat': -31.9505, 'lng': 115.8605, 'elevation': 46.0}, + 'סידני': {'lat': -33.8688, 'lng': 151.2093, 'elevation': 58.0}, + }, }; +Map? _getCityData(String cityName) { + for (var country in cityCoordinates.values) { + if (country.containsKey(cityName)) { + return country[cityName]; + } + } + return null; +} + // Calculate daily times function Map _calculateDailyTimes(DateTime date, String city) { - final cityData = cityCoordinates[city]; + final cityData = _getCityData(city); if (cityData == null) { return {}; } @@ -462,13 +477,17 @@ Map _calculateDailyTimes(DateTime date, String city) { location.setLatitude(latitude: latitude); location.setLongitude(longitude: longitude); location.setDateTime(date); - location.setElevation(elevation); + location.setElevation(elevation > 0 ? elevation : 0); final zmanimCalendar = ComplexZmanimCalendar.intGeoLocation(location); final jewishCalendar = JewishCalendar.fromDateTime(date); final Map times = { 'alos': _formatTime(zmanimCalendar.getAlosHashachar()!), + 'alos16point1Degrees': + _formatTime(zmanimCalendar.getAlos16Point1Degrees()!), + 'alos19point8Degrees': + _formatTime(zmanimCalendar.getAlos19Point8Degrees()!), 'sunrise': _formatTime(zmanimCalendar.getSunrise()!), 'sofZmanShmaMGA': _formatTime(zmanimCalendar.getSofZmanShmaMGA()!), 'sofZmanShmaGRA': _formatTime(zmanimCalendar.getSofZmanShmaGRA()!), diff --git a/lib/navigation/calendar_widget.dart b/lib/navigation/calendar_widget.dart index faa4b5166..78234db4a 100644 --- a/lib/navigation/calendar_widget.dart +++ b/lib/navigation/calendar_widget.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:kosher_dart/kosher_dart.dart'; -import 'calendar_cubit.dart'; // ודא שהנתיב נכון +import 'calendar_cubit.dart'; import 'package:otzaria/daf_yomi/daf_yomi_helper.dart'; // הפכנו את הווידג'ט ל-Stateless כי הוא כבר לא מנהל מצב בעצמו. @@ -634,20 +634,8 @@ class CalendarWidget extends StatelessWidget { style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const Spacer(), - DropdownButton( - value: state.selectedCity, - items: cityCoordinates.keys.map((city) { - return DropdownMenuItem( - value: city, - child: Text(city), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - context.read().changeCity(value); - } - }, - ), + // החלפנו את הDropdownButton בCityDropdownWithSearch + _buildCityDropdownWithSearch(context, state), ], ), const SizedBox(height: 16), @@ -686,6 +674,14 @@ class CalendarWidget extends StatelessWidget { // זמנים בסיסיים final List> timesList = [ {'name': 'עלות השחר', 'time': dailyTimes['alos']}, + { + 'name': 'עלות השחר (שיטת 72 דקות) במעלות', + 'time': dailyTimes['alos16point1Degrees'] + }, + { + 'name': 'עלות השחר (שיטת 90 דקות) במעלות', + 'time': dailyTimes['alos19point8Degrees'] + }, {'name': 'זריחה', 'time': dailyTimes['sunrise']}, {'name': 'סוף זמן ק"ש - מג"א', 'time': dailyTimes['sofZmanShmaMGA']}, {'name': 'סוף זמן ק"ש - גר"א', 'time': dailyTimes['sofZmanShmaGRA']}, @@ -697,8 +693,8 @@ class CalendarWidget extends StatelessWidget { {'name': 'מנחה קטנה', 'time': dailyTimes['minchaKetana']}, {'name': 'פלג המנחה', 'time': dailyTimes['plagHamincha']}, {'name': 'שקיעה', 'time': dailyTimes['sunset']}, - {'name': 'שקיעה ר"ת', 'time': dailyTimes['sunsetRT']}, {'name': 'צאת הכוכבים', 'time': dailyTimes['tzais']}, + {'name': 'צאת הכוכבים ר"ת', 'time': dailyTimes['sunsetRT']}, ]; // הוספת זמנים מיוחדים לערב פסח @@ -813,7 +809,7 @@ class CalendarWidget extends StatelessWidget { physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, - childAspectRatio: 3, + childAspectRatio: 2.5, crossAxisSpacing: 8, mainAxisSpacing: 8, ), @@ -1390,6 +1386,36 @@ class CalendarWidget extends StatelessWidget { } // החלק של האירועים עדיין לא עבר ריפקטורינג, הוא יישאר לא פעיל בינתיים + // הוספת הוויג'ט החדש לבחירת עיר עם סינון + Widget _buildCityDropdownWithSearch( + BuildContext context, CalendarState state) { + return ElevatedButton( + onPressed: () => _showCitySearchDialog(context, state), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(state.selectedCity), + const SizedBox(width: 8), + const Icon(Icons.arrow_drop_down), + ], + ), + ); + } + + // דיאלוג חיפוש ערים + void _showCitySearchDialog(BuildContext context, CalendarState state) { + showDialog( + context: context, + builder: (dialogContext) => _CitySearchDialog( + currentCity: state.selectedCity, + onCitySelected: (city) { + context.read().changeCity(city); + Navigator.of(dialogContext).pop(); + }, + ), + ); + } + Widget _buildEventsCard(BuildContext context, CalendarState state) { return Card( child: Padding( @@ -1426,3 +1452,121 @@ class CalendarWidget extends StatelessWidget { ); } } + +// דיאלוג לחיפוש ובחירת עיר +class _CitySearchDialog extends StatefulWidget { + final String currentCity; + final ValueChanged onCitySelected; + + const _CitySearchDialog({ + required this.currentCity, + required this.onCitySelected, + }); + + @override + State<_CitySearchDialog> createState() => _CitySearchDialogState(); +} + +class _CitySearchDialogState extends State<_CitySearchDialog> { + final TextEditingController _searchController = TextEditingController(); + late Map>> _filteredCities; + + @override + void initState() { + super.initState(); + _filteredCities = cityCoordinates; + _searchController.addListener(_filterCities); + } + + @override + void dispose() { + _searchController.removeListener(_filterCities); + _searchController.dispose(); + super.dispose(); + } + + void _filterCities() { + final query = _searchController.text.toLowerCase(); + setState(() { + if (query.isEmpty) { + _filteredCities = cityCoordinates; + } else { + _filteredCities = {}; + cityCoordinates.forEach((country, cities) { + final matchingCities = Map.fromEntries(cities.entries.where( + (cityEntry) => cityEntry.key.toLowerCase().contains(query))); + if (matchingCities.isNotEmpty) { + _filteredCities[country] = matchingCities; + } + }); + } + }); + } + + @override + Widget build(BuildContext context) { + final List items = []; + _filteredCities.forEach((country, cities) { + items.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: Text( + country, + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.blue, + fontSize: 16, + ), + ), + ), + ); + cities.forEach((city, data) { + items.add( + ListTile( + title: Text(city), + onTap: () { + widget.onCitySelected(city); + }, + ), + ); + }); + items.add(const Divider()); + }); + if (items.isNotEmpty) { + items.removeLast(); // Remove last divider + } + + return AlertDialog( + title: const Text('חיפוש עיר'), + content: SizedBox( + width: 400, // הגדרת רוחב קבוע + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _searchController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'הקלד שם עיר...', + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + Expanded( + child: _filteredCities.isEmpty + ? const Center(child: Text('לא נמצאו ערים')) + : ListView(children: items), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('ביטול'), + ), + ], + ); + } +} From 54d4063e2692c7d91d6e500683e5a0944b1733a9 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 19 Aug 2025 00:07:54 +0300 Subject: [PATCH 133/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=91?= =?UTF-8?q?=D7=90=D7=92=20=D7=91=D7=90=D7=99=D7=A0=D7=93=D7=95=D7=A7=D7=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file_system_data_provider.dart | 100 ++++++++++-------- .../repository/indexing_repository.dart | 22 +++- 2 files changed, 77 insertions(+), 45 deletions(-) diff --git a/lib/data/data_providers/file_system_data_provider.dart b/lib/data/data_providers/file_system_data_provider.dart index 000b28f19..ff7780f0d 100644 --- a/lib/data/data_providers/file_system_data_provider.dart +++ b/lib/data/data_providers/file_system_data_provider.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'dart:isolate'; import 'dart:convert'; +import 'package:flutter/foundation.dart' show debugPrint; import 'package:csv/csv.dart'; import 'package:flutter/services.dart'; import 'package:otzaria/data/data_providers/hive_data_provider.dart'; @@ -71,58 +72,69 @@ class FileSystemData { // Process each entity in the directory await for (FileSystemEntity entity in dir.list()) { - if (entity is Directory) { - // Recursively process subdirectories as categories - category.subCategories.add( - await getAllCategoriesAndBooksFromDirectory( - Directory(entity.path), category)); - } else { - // Extract topics from the file path - var topics = entity.path - .split('אוצריא${Platform.pathSeparator}') - .last - .split(Platform.pathSeparator) - .toList(); - topics = topics.sublist(0, topics.length - 1); + // Check if entity is accessible before processing + try { + // Verify we can access the entity + await entity.stat(); + + if (entity is Directory) { + // Recursively process subdirectories as categories + category.subCategories.add( + await getAllCategoriesAndBooksFromDirectory( + Directory(entity.path), category)); + } else if (entity is File) { + // Only process actual files, not directories mistaken as files + // Extract topics from the file path + var topics = entity.path + .split('אוצריא${Platform.pathSeparator}') + .last + .split(Platform.pathSeparator) + .toList(); + topics = topics.sublist(0, topics.length - 1); // Handle special case where title contains " על " if (getTitleFromPath(entity.path).contains(' על ')) { topics.add(getTitleFromPath(entity.path).split(' על ')[1]); } - // Process PDF files - if (entity.path.toLowerCase().endsWith('.pdf')) { - final title = getTitleFromPath(entity.path); - category.books.add( - PdfBook( - title: title, - category: category, - path: entity.path, - author: metadata[title]?['author'], - heShortDesc: metadata[title]?['heShortDesc'], - pubDate: metadata[title]?['pubDate'], - pubPlace: metadata[title]?['pubPlace'], - order: metadata[title]?['order'] ?? 999, - topics: topics.join(', '), - ), - ); - } + // Process PDF files + if (entity.path.toLowerCase().endsWith('.pdf')) { + final title = getTitleFromPath(entity.path); + category.books.add( + PdfBook( + title: title, + category: category, + path: entity.path, + author: metadata[title]?['author'], + heShortDesc: metadata[title]?['heShortDesc'], + pubDate: metadata[title]?['pubDate'], + pubPlace: metadata[title]?['pubPlace'], + order: metadata[title]?['order'] ?? 999, + topics: topics.join(', '), + ), + ); + } - // Process text and docx files - if (entity.path.toLowerCase().endsWith('.txt') || - entity.path.toLowerCase().endsWith('.docx')) { - final title = getTitleFromPath(entity.path); - category.books.add(TextBook( - title: title, - category: category, - author: metadata[title]?['author'], - heShortDesc: metadata[title]?['heShortDesc'], - pubDate: metadata[title]?['pubDate'], - pubPlace: metadata[title]?['pubPlace'], - order: metadata[title]?['order'] ?? 999, - topics: topics.join(', '), - extraTitles: metadata[title]?['extraTitles'])); + // Process text and docx files + if (entity.path.toLowerCase().endsWith('.txt') || + entity.path.toLowerCase().endsWith('.docx')) { + final title = getTitleFromPath(entity.path); + category.books.add(TextBook( + title: title, + category: category, + author: metadata[title]?['author'], + heShortDesc: metadata[title]?['heShortDesc'], + pubDate: metadata[title]?['pubDate'], + pubPlace: metadata[title]?['pubPlace'], + order: metadata[title]?['order'] ?? 999, + topics: topics.join(', '), + extraTitles: metadata[title]?['extraTitles'])); + } } + } catch (e) { + // Skip entities that can't be accessed (like directories mistaken as files) + debugPrint('Skipping inaccessible entity: ${entity.path} - $e'); + continue; } } diff --git a/lib/indexing/repository/indexing_repository.dart b/lib/indexing/repository/indexing_repository.dart index 73fcd06eb..abaa95f3f 100644 --- a/lib/indexing/repository/indexing_repository.dart +++ b/lib/indexing/repository/indexing_repository.dart @@ -63,9 +63,19 @@ class IndexingRepository { // Report progress onProgress(processedBooks, totalBooks); } catch (e) { - debugPrint('Error adding ${book.title} to index: $e'); + // Use async error handling to prevent event loop blocking + await Future.microtask(() { + debugPrint('Error adding ${book.title} to index: $e'); + }); processedBooks++; + // Still report progress even after error + onProgress(processedBooks, totalBooks); + // Yield control back to event loop after error + await Future.delayed(Duration.zero); } + + await Future.delayed(Duration.zero); + } // Reset indexing flag after completion @@ -88,6 +98,11 @@ class IndexingRepository { if (!_tantivyDataProvider.isIndexing.value) { return; } + + // Yield control periodically to prevent blocking + if (i % 100 == 0) { + await Future.delayed(Duration.zero); + } String line = texts[i]; // get the reference from the headers @@ -155,6 +170,11 @@ class IndexingRepository { if (!_tantivyDataProvider.isIndexing.value) { return; } + + // Yield control periodically to prevent blocking + if (j % 50 == 0) { + await Future.delayed(Duration.zero); + } final bookmark = await refFromPageNumber(i + 1, outline, title); final ref = bookmark.isNotEmpty ? '$title, $bookmark, עמוד ${i + 1}' From 2bd9416938d8b1ed0caf3f632c12ae6da8556986 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 21 Aug 2025 15:41:33 +0300 Subject: [PATCH 134/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=94?= =?UTF-8?q?=D7=A2=D7=AA=D7=A7=D7=AA=20=D7=98=D7=A7=D7=A1=D7=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../combined_view/combined_book_screen.dart | 348 +++++++++++++----- .../view/splited_view/simple_book_view.dart | 265 ++++++++++++- linux/flutter/generated_plugin_registrant.cc | 8 + linux/flutter/generated_plugins.cmake | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 6 + pubspec.lock | 80 ++++ pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 9 files changed, 613 insertions(+), 105 deletions(-) diff --git a/lib/text_book/view/combined_view/combined_book_screen.dart b/lib/text_book/view/combined_view/combined_book_screen.dart index 8d43628ae..aa154309f 100644 --- a/lib/text_book/view/combined_view/combined_book_screen.dart +++ b/lib/text_book/view/combined_view/combined_book_screen.dart @@ -19,6 +19,7 @@ import 'package:otzaria/models/books.dart'; import 'package:otzaria/utils/text_manipulation.dart' as utils; import 'package:otzaria/text_book/bloc/text_book_event.dart'; import 'package:otzaria/notes/notes_system.dart'; +import 'package:super_clipboard/super_clipboard.dart'; class CombinedView extends StatefulWidget { CombinedView({ @@ -57,6 +58,9 @@ class _CombinedViewState extends State { String? _lastSelectedText; int? _lastSelectionStart; int? _lastSelectionEnd; + + // מעקב אחר האינדקס הנוכחי שנבחר + int? _currentSelectedIndex; /// helper קטן שמחזיר רשימת MenuEntry מקבוצה אחת, כולל כפתור הצג/הסתר הכל List> _buildGroup( @@ -102,7 +106,8 @@ class _CombinedViewState extends State { ]; } - ctx.ContextMenu _buildContextMenu(TextBookLoaded state) { +// + בניית תפריט קונטקסט "מקובע" לאינדקס ספציפי של פסקה +ctx.ContextMenu _buildContextMenuForIndex(TextBookLoaded state, int paragraphIndex) { // 1. קבלת מידע על גודל המסך final screenHeight = MediaQuery.of(context).size.height; @@ -121,14 +126,11 @@ class _CombinedViewState extends State { .toList(); return ctx.ContextMenu( - // 4. הגדרת הגובה המקסימלי ל-70% מגובה המסך maxHeight: screenHeight * 0.9, entries: [ - ctx.MenuItem( - label: 'חיפוש', onSelected: () => widget.openLeftPaneTab(1)), + ctx.MenuItem(label: 'חיפוש', onSelected: () => widget.openLeftPaneTab(1)), ctx.MenuItem.submenu( label: 'מפרשים', - items: [ ctx.MenuItem( label: 'הצג את כל המפרשים', @@ -151,27 +153,16 @@ class _CombinedViewState extends State { }, ), const ctx.MenuDivider(), - // תורה שבכתב ..._buildGroup('תורה שבכתב', state.torahShebichtav, state), - - // מוסיפים קו הפרדה רק אם יש גם תורה שבכתב וגם חזל if (state.torahShebichtav.isNotEmpty && state.chazal.isNotEmpty) const ctx.MenuDivider(), - - // חזל - ..._buildGroup('חז"ל', state.chazal, state), - - // מוסיפים קו הפרדה בין חז"ל לראשונים, או בין תורה שבכתב לראשונים אם אין חז"ל + ..._buildGroup('חז\"ל', state.chazal, state), if ((state.chazal.isNotEmpty && state.rishonim.isNotEmpty) || (state.chazal.isEmpty && state.torahShebichtav.isNotEmpty && state.rishonim.isNotEmpty)) const ctx.MenuDivider(), - - // ראשונים ..._buildGroup('הראשונים', state.rishonim, state), - - // מוסיפים קו הפרדה בין ראשונים לאחרונים, או מהקבוצה הקודמת לאחרונים if ((state.rishonim.isNotEmpty && state.acharonim.isNotEmpty) || (state.rishonim.isEmpty && state.chazal.isNotEmpty && @@ -181,11 +172,7 @@ class _CombinedViewState extends State { state.torahShebichtav.isNotEmpty && state.acharonim.isNotEmpty)) const ctx.MenuDivider(), - - // אחרונים ..._buildGroup('האחרונים', state.acharonim, state), - - // מוסיפים קו הפרדה בין אחרונים למחברי זמננו, או מהקבוצה הקודמת למחברי זמננו if ((state.acharonim.isNotEmpty && state.modernCommentators.isNotEmpty) || (state.acharonim.isEmpty && @@ -201,11 +188,7 @@ class _CombinedViewState extends State { state.torahShebichtav.isNotEmpty && state.modernCommentators.isNotEmpty)) const ctx.MenuDivider(), - - // מחברי זמננו ..._buildGroup('מחברי זמננו', state.modernCommentators, state), - - // הוסף קו הפרדה רק אם יש קבוצות אחרות וגם פרשנים לא-משויכים if ((state.torahShebichtav.isNotEmpty || state.chazal.isNotEmpty || state.rishonim.isNotEmpty || @@ -213,14 +196,12 @@ class _CombinedViewState extends State { state.modernCommentators.isNotEmpty) && ungrouped.isNotEmpty) const ctx.MenuDivider(), - - // הוסף את רשימת הפרשנים הלא משויכים ..._buildGroup('שאר המפרשים', ungrouped, state), ], ), ctx.MenuItem.submenu( label: 'קישורים', - enabled: LinksViewer.getLinks(state).isNotEmpty, // <--- חדש + enabled: LinksViewer.getLinks(state).isNotEmpty, items: LinksViewer.getLinks(state) .map( (link) => ctx.MenuItem( @@ -233,11 +214,8 @@ class _CombinedViewState extends State { ), index: link.index2 - 1, openLeftPane: - (Settings.getValue('key-pin-sidebar') ?? - false) || - (Settings.getValue( - 'key-default-sidebar-open') ?? - false), + (Settings.getValue('key-pin-sidebar') ?? false) || + (Settings.getValue('key-default-sidebar-open') ?? false), ), ); }, @@ -253,22 +231,52 @@ class _CombinedViewState extends State { if (text == null || text.trim().isEmpty) { return 'הוסף הערה'; } - final preview = - text.length > 12 ? '${text.substring(0, 12)}...' : text; + final preview = text.length > 12 ? '${text.substring(0, 12)}...' : text; return 'הוסף הערה ל: "$preview"'; }(), onSelected: () => _createNoteFromSelection(), ), const ctx.MenuDivider(), + // העתקה + ctx.MenuItem( + label: 'העתק', + enabled: (_lastSelectedText ?? _selectedText) != null && + (_lastSelectedText ?? _selectedText)!.trim().isNotEmpty, + onSelected: _copyFormattedText, + ), + // + שים לב לשינוי בפונקציה שנקראת וב-enabled ctx.MenuItem( - label: 'בחר את כל הטקסט', - onSelected: () => - _selectionKey.currentState?.selectableRegion.selectAll(), + label: 'העתק את כל הפסקה', + enabled: paragraphIndex >= 0 && paragraphIndex < widget.data.length, + onSelected: () => _copyParagraphByIndex(paragraphIndex), // <--- קריאה לפונקציה החדשה עם האינדקס + ), + ctx.MenuItem( + label: 'העתק את הטקסט המוצג', + onSelected: _copyVisibleText, ), ], ); } + /// זיהוי האינדקס של הטקסט הנבחר + int? _findIndexByText(String selectedText) { + final cleanedSelected = selectedText.replaceAll(RegExp(r'\s+'), ' ').trim(); + + for (int i = 0; i < widget.data.length; i++) { + final originalData = widget.data[i]; + final cleanedOriginal = originalData + .replaceAll(RegExp(r'<[^>]*>'), '') // הסרת תגי HTML + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + + if (cleanedOriginal.contains(cleanedSelected) || + cleanedSelected.contains(cleanedOriginal)) { + return i; + } + } + return null; + } + /// יצירת הערה מטקסט נבחר void _createNoteFromSelection() { // נשתמש בבחירה האחרונה שנשמרה, או בבחירה הנוכחית @@ -288,6 +296,171 @@ class _CombinedViewState extends State { _showNoteEditor(text, start, end); } + /// העתקת פסקה לפי אינדקס (משתמש ב־widget.data[index] ומייצר גם HTML) + Future _copyParagraphByIndex(int index) async { + if (index < 0 || index >= widget.data.length) return; + + final text = widget.data[index]; + if (text.trim().isEmpty) return; + + final item = DataWriterItem(); + item.add(Formats.plainText(text)); + item.add(Formats.htmlText(_formatTextAsHtml(text))); + + await SystemClipboard.instance?.write([item]); + } + + /// העתקת הטקסט המוצג במסך ללוח + void _copyVisibleText() async { + final state = context.read().state; + if (state is! TextBookLoaded || state.visibleIndices.isEmpty) return; + + // איסוף כל הטקסט הנראה במסך + final visibleTexts = []; + for (final index in state.visibleIndices) { + if (index >= 0 && index < widget.data.length) { + visibleTexts.add(widget.data[index]); + } + } + + if (visibleTexts.isEmpty) return; + + final combinedText = visibleTexts.join('\n\n'); + final combinedHtml = visibleTexts.map(_formatTextAsHtml).join('

'); + + final item = DataWriterItem(); + item.add(Formats.plainText(combinedText)); + item.add(Formats.htmlText(combinedHtml)); + + await SystemClipboard.instance?.write([item]); + } + + /// עיצוב טקסט כ-HTML עם הגדרות הגופן הנוכחיות + String _formatTextAsHtml(String text) { + final settingsState = context.read().state; + return ''' +
+$text +
+'''; + } + + /// העתקת טקסט רגיל ללוח + Future _copyPlainText() async { + final text = _lastSelectedText ?? _selectedText; + if (text == null || text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('אנא בחר טקסט להעתקה'), + duration: Duration(seconds: 2), + ), + ); + return; + } + + try { + final clipboard = SystemClipboard.instance; + if (clipboard != null) { + final item = DataWriterItem(); + item.add(Formats.plainText(text)); + await clipboard.write([item]); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('הטקסט הועתק ללוח'), + duration: Duration(seconds: 1), + ), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('שגיאה בהעתקה: $e'), + duration: const Duration(seconds: 2), + ), + ); + } + } + } + + /// העתקת טקסט מעוצב (HTML) ללוח + Future _copyFormattedText() async { + final plainText = _lastSelectedText ?? _selectedText; + if (plainText == null || plainText.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('אנא בחר טקסט להעתקה'), + duration: Duration(seconds: 2), + ), + ); + return; + } + + try { + final clipboard = SystemClipboard.instance; + if (clipboard != null) { + // קבלת ההגדרות הנוכחיות לעיצוב + final settingsState = context.read().state; + + // ניסיון למצוא את הטקסט המקורי עם תגי HTML + String htmlContentToUse = plainText; + + // אם יש לנו אינדקס נוכחי, ננסה למצוא את הטקסט המקורי + if (_currentSelectedIndex != null && + _currentSelectedIndex! >= 0 && + _currentSelectedIndex! < widget.data.length) { + final originalData = widget.data[_currentSelectedIndex!]; + + // בדיקה אם הטקסט הפשוט מופיע בטקסט המקורי + final plainTextCleaned = plainText.replaceAll(RegExp(r'\s+'), ' ').trim(); + final originalCleaned = originalData + .replaceAll(RegExp(r'<[^>]*>'), '') // הסרת תגי HTML + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + + // אם הטקסט הפשוט תואם לטקסט המקורי (או חלק ממנו), נשתמש במקורי + if (originalCleaned.contains(plainTextCleaned) || + plainTextCleaned.contains(originalCleaned)) { + htmlContentToUse = originalData; + } + } + + // יצירת HTML מעוצב עם הגדרות הגופן והגודל + final finalHtmlContent = ''' +
+$htmlContentToUse +
+'''; + + final item = DataWriterItem(); + item.add(Formats.plainText(plainText)); // טקסט רגיל כגיבוי + item.add(Formats.htmlText(finalHtmlContent)); // טקסט מעוצב עם תגי HTML מקוריים + await clipboard.write([item]); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('הטקסט המעוצב הועתק ללוח'), + duration: Duration(seconds: 1), + ), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('שגיאה בהעתקה מעוצבת: $e'), + duration: const Duration(seconds: 2), + ), + ); + } + } + } + /// הצגת עורך ההערות void _showNoteEditor(String selectedText, int charStart, int charEnd) { // שמירת ה-context המקורי וה-bloc @@ -361,6 +534,7 @@ class _CombinedViewState extends State { _selectedText = null; _selectionStart = null; _selectionEnd = null; + _currentSelectedIndex = null; // עדכון ה-BLoC שאין טקסט נבחר context .read() @@ -370,6 +544,9 @@ class _CombinedViewState extends State { _selectionStart = 0; _selectionEnd = text.length; + // ניסיון לזהות את האינדקס על בסיס התוכן + _currentSelectedIndex = _findIndexByText(text); + // שמירת הבחירה האחרונה _lastSelectedText = text; _lastSelectionStart = 0; @@ -382,11 +559,8 @@ class _CombinedViewState extends State { } // בלי setState – כדי לא לרנדר את כל העץ תוך כדי גרירת הבחירה }, - child: ctx.ContextMenuRegion( - // <-- ה-Region היחיד, במיקום הנכון - contextMenu: _buildContextMenu(state), - child: buildOuterList(state), - ), + // שים לב: אין כאן יותר ContextMenuRegion עוטף את כל הרשימה. + child: buildOuterList(state), ), ); }, @@ -408,56 +582,58 @@ class _CombinedViewState extends State { ); } - ExpansionTile buildExpansiomTile( + Widget buildExpansiomTile( ExpansibleController controller, int index, TextBookLoaded state, ) { - return ExpansionTile( - shape: const Border(), - //maintainState: true, - controller: controller, - key: PageStorageKey(widget.data[index]), - iconColor: Colors.transparent, - tilePadding: const EdgeInsets.all(0.0), - collapsedIconColor: Colors.transparent, - title: BlocBuilder( - builder: (context, settingsState) { - String data = widget.data[index]; - if (!settingsState.showTeamim) { - data = utils.removeTeamim(data); - } - - if (settingsState.replaceHolyNames) { - data = utils.replaceHolyNames(data); - } - return Html( - //remove nikud if needed - data: state.removeNikud - ? utils.highLight( - utils.removeVolwels('$data\n'), - state.searchText, - ) - : utils.highLight('$data\n', state.searchText), - style: { - 'body': Style( + // עוטפים את כל ה־ExpansionTile בתפריט קונטקסט ספציפי לאינדקס הנוכחי: + return ctx.ContextMenuRegion( + contextMenu: _buildContextMenuForIndex(state, index), + child: ExpansionTile( + shape: const Border(), + controller: controller, + key: PageStorageKey(widget.data[index]), + iconColor: Colors.transparent, + tilePadding: const EdgeInsets.all(0.0), + collapsedIconColor: Colors.transparent, + title: BlocBuilder( + builder: (context, settingsState) { + String data = widget.data[index]; + if (!settingsState.showTeamim) { + data = utils.removeTeamim(data); + } + if (settingsState.replaceHolyNames) { + data = utils.replaceHolyNames(data); + } + return Html( + data: state.removeNikud + ? utils.highLight( + utils.removeVolwels('$data\n'), + state.searchText, + ) + : utils.highLight('$data\n', state.searchText), + style: { + 'body': Style( fontSize: FontSize(widget.textSize), fontFamily: settingsState.fontFamily, - textAlign: TextAlign.justify), - }, - ); - }, + textAlign: TextAlign.justify, + ), + }, + ); + }, + ), + children: [ + widget.showSplitedView.value + ? const SizedBox.shrink() + : CommentaryListForCombinedView( + index: index, + fontSize: widget.textSize, + openBookCallback: widget.openBookCallback, + showSplitView: false, + ), + ], ), - children: [ - widget.showSplitedView.value - ? const SizedBox.shrink() - : CommentaryListForCombinedView( - index: index, - fontSize: widget.textSize, - openBookCallback: widget.openBookCallback, - showSplitView: false, - ), - ], ); } diff --git a/lib/text_book/view/splited_view/simple_book_view.dart b/lib/text_book/view/splited_view/simple_book_view.dart index 50634ccd7..c853d5930 100644 --- a/lib/text_book/view/splited_view/simple_book_view.dart +++ b/lib/text_book/view/splited_view/simple_book_view.dart @@ -16,6 +16,7 @@ import 'package:otzaria/text_book/view/links_screen.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:otzaria/models/books.dart'; import 'package:otzaria/notes/notes_system.dart'; +import 'package:super_clipboard/super_clipboard.dart'; class SimpleBookView extends StatefulWidget { const SimpleBookView({ @@ -54,6 +55,12 @@ class _SimpleBookViewState extends State { String? _lastSelectedText; int? _lastSelectionStart; int? _lastSelectionEnd; + + // מעקב אחר האינדקס הנוכחי שנבחר + int? _currentSelectedIndex; + + // מעקב אחר הפסקה שעליה לחץ המשתמש (לתפריט קונטקסט) + int? _contextMenuParagraphIndex; /// helper קטן שמחזיר רשימת MenuEntry מקבוצה אחת, כולל כפתור הצג/הסתר הכל List> _buildGroup( @@ -234,10 +241,21 @@ class _SimpleBookViewState extends State { onSelected: () => _createNoteFromSelection(), ), const ctx.MenuDivider(), + // העתקה ctx.MenuItem( - label: 'בחר את כל הטקסט', - onSelected: () => - _selectionKey.currentState?.selectableRegion.selectAll(), + label: 'העתק', + enabled: (_lastSelectedText ?? _selectedText) != null && + (_lastSelectedText ?? _selectedText)!.trim().isNotEmpty, + onSelected: _copyFormattedText, + ), + ctx.MenuItem( + label: 'העתק את כל הפסקה', + enabled: true, + onSelected: () => _copyContextMenuParagraph(), + ), + ctx.MenuItem( + label: 'העתק את הטקסט המוצג', + onSelected: _copyVisibleText, ), ], ); @@ -262,6 +280,204 @@ class _SimpleBookViewState extends State { _showNoteEditor(text, start, end); } + /// העתקת הפסקה הנוכחית ללוח + void _copyCurrentParagraph() async { + if (_currentSelectedIndex == null) return; + + final text = widget.data[_currentSelectedIndex!]; + if (text.trim().isEmpty) return; + + final item = DataWriterItem(); + item.add(Formats.plainText(text)); + item.add(Formats.htmlText(_formatTextAsHtml(text))); + + await SystemClipboard.instance?.write([item]); + } + + /// העתקת הפסקה מתפריט הקונטקסט ללוח + void _copyContextMenuParagraph() async { + // אם לא זוהתה פסקה ספציפית, נשתמש בפסקה הראשונה הנראית + final state = context.read().state; + if (state is! TextBookLoaded) return; + + int? indexToCopy = _contextMenuParagraphIndex; + if (indexToCopy == null && state.visibleIndices.isNotEmpty) { + indexToCopy = state.visibleIndices.first; + } + + if (indexToCopy == null || indexToCopy >= widget.data.length) return; + + final text = widget.data[indexToCopy]; + if (text.trim().isEmpty) return; + + final item = DataWriterItem(); + item.add(Formats.plainText(text)); + item.add(Formats.htmlText(_formatTextAsHtml(text))); + + await SystemClipboard.instance?.write([item]); + } + + /// העתקת הטקסט המוצג במסך ללוח + void _copyVisibleText() async { + final state = context.read().state; + if (state is! TextBookLoaded || state.visibleIndices.isEmpty) return; + + // איסוף כל הטקסט הנראה במסך + final visibleTexts = []; + for (final index in state.visibleIndices) { + if (index >= 0 && index < widget.data.length) { + visibleTexts.add(widget.data[index]); + } + } + + if (visibleTexts.isEmpty) return; + + final combinedText = visibleTexts.join('\n\n'); + final combinedHtml = visibleTexts.map(_formatTextAsHtml).join('

'); + + final item = DataWriterItem(); + item.add(Formats.plainText(combinedText)); + item.add(Formats.htmlText(combinedHtml)); + + await SystemClipboard.instance?.write([item]); + } + + /// עיצוב טקסט כ-HTML עם הגדרות הגופן הנוכחיות + String _formatTextAsHtml(String text) { + final settingsState = context.read().state; + return ''' +
+$text +
+'''; + } + + /// זיהוי הפסקה לפי מיקום הלחיצה + int? _findParagraphAtPosition(Offset localPosition, TextBookLoaded state) { + // פשטות: נשתמש באינדקס הראשון הנראה כברירת מחדל + // בעתיד ניתן לשפר עם חישוב מדויק יותר של המיקום + if (state.visibleIndices.isNotEmpty) { + return state.visibleIndices.first; + } + return null; + } + + /// העתקת טקסט רגיל ללוח + Future _copyPlainText() async { + final text = _lastSelectedText ?? _selectedText; + if (text == null || text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('אנא בחר טקסט להעתקה'), + duration: Duration(seconds: 2), + ), + ); + return; + } + + try { + final clipboard = SystemClipboard.instance; + if (clipboard != null) { + final item = DataWriterItem(); + item.add(Formats.plainText(text)); + await clipboard.write([item]); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('הטקסט הועתק ללוח'), + duration: Duration(seconds: 1), + ), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('שגיאה בהעתקה: $e'), + duration: const Duration(seconds: 2), + ), + ); + } + } + } + + /// העתקת טקסט מעוצב (HTML) ללוח + Future _copyFormattedText() async { + final plainText = _lastSelectedText ?? _selectedText; + if (plainText == null || plainText.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('אנא בחר טקסט להעתקה'), + duration: Duration(seconds: 2), + ), + ); + return; + } + + try { + final clipboard = SystemClipboard.instance; + if (clipboard != null) { + // קבלת ההגדרות הנוכחיות לעיצוב + final settingsState = context.read().state; + + // ניסיון למצוא את הטקסט המקורי עם תגי HTML + String htmlContentToUse = plainText; + + // אם יש לנו אינדקס נוכחי, ננסה למצוא את הטקסט המקורי + if (_currentSelectedIndex != null && + _currentSelectedIndex! >= 0 && + _currentSelectedIndex! < widget.data.length) { + final originalData = widget.data[_currentSelectedIndex!]; + + // בדיקה אם הטקסט הפשוט מופיע בטקסט המקורי + final plainTextCleaned = plainText.replaceAll(RegExp(r'\s+'), ' ').trim(); + final originalCleaned = originalData + .replaceAll(RegExp(r'<[^>]*>'), '') // הסרת תגי HTML + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + + // אם הטקסט הפשוט תואם לטקסט המקורי (או חלק ממנו), נשתמש במקורי + if (originalCleaned.contains(plainTextCleaned) || + plainTextCleaned.contains(originalCleaned)) { + htmlContentToUse = originalData; + } + } + + // יצירת HTML מעוצב עם הגדרות הגופן והגודל + final finalHtmlContent = ''' +
+$htmlContentToUse +
+'''; + + final item = DataWriterItem(); + item.add(Formats.plainText(plainText)); // טקסט רגיל כגיבוי + item.add(Formats.htmlText(finalHtmlContent)); // טקסט מעוצב עם תגי HTML מקוריים + await clipboard.write([item]); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('הטקסט המעוצב הועתק ללוח'), + duration: Duration(seconds: 1), + ), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('שגיאה בהעתקה מעוצבת: $e'), + duration: const Duration(seconds: 2), + ), + ); + } + } + } + /// הצגת עורך ההערות void _showNoteEditor(String selectedText, int charStart, int charEnd) { // שמירת ה-context המקורי וה-bloc @@ -356,19 +572,26 @@ class _SimpleBookViewState extends State { } // חשוב: לא קוראים ל-setState כאן כדי לא לפגוע בחוויית הבחירה }, - child: ctx.ContextMenuRegion( - contextMenu: _buildContextMenu(state), - child: ScrollablePositionedList.builder( - key: PageStorageKey(widget.tab), - initialScrollIndex: state.visibleIndices.first, - itemPositionsListener: state.positionsListener, - itemScrollController: state.scrollController, - scrollOffsetController: state.scrollOffsetController, - itemCount: widget.data.length, - itemBuilder: (context, index) { - return BlocBuilder( - builder: (context, settingsState) { - String data = widget.data[index]; + child: GestureDetector( + onSecondaryTapDown: (details) { + // זיהוי הפסקה לפי מיקום הלחיצה + final RenderBox renderBox = context.findRenderObject() as RenderBox; + final localPosition = renderBox.globalToLocal(details.globalPosition); + _contextMenuParagraphIndex = _findParagraphAtPosition(localPosition, state); + }, + child: ctx.ContextMenuRegion( + contextMenu: _buildContextMenu(state), + child: ScrollablePositionedList.builder( + key: PageStorageKey(widget.tab), + initialScrollIndex: state.visibleIndices.first, + itemPositionsListener: state.positionsListener, + itemScrollController: state.scrollController, + scrollOffsetController: state.scrollOffsetController, + itemCount: widget.data.length, + itemBuilder: (context, index) { + return BlocBuilder( + builder: (context, settingsState) { + String data = widget.data[index]; if (!settingsState.showTeamim) { data = utils.removeTeamim(data); } @@ -376,9 +599,12 @@ class _SimpleBookViewState extends State { data = utils.replaceHolyNames(data); } return InkWell( - onTap: () => context.read().add( - UpdateSelectedIndex(index), - ), + onTap: () { + _currentSelectedIndex = index; + context.read().add( + UpdateSelectedIndex(index), + ); + }, child: Html( // remove nikud if needed data: state.removeNikud @@ -402,6 +628,7 @@ class _SimpleBookViewState extends State { ), ), ), + ), ); // אם סרגל ההערות פתוח, הצג אותו לצד התוכן diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index f09c25cda..28dd9eb19 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,13 +6,18 @@ #include "generated_plugin_registrant.h" +#include #include #include #include +#include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) irondash_engine_context_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "IrondashEngineContextPlugin"); + irondash_engine_context_plugin_register_with_registrar(irondash_engine_context_registrar); g_autoptr(FlPluginRegistrar) isar_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "IsarFlutterLibsPlugin"); isar_flutter_libs_plugin_register_with_registrar(isar_flutter_libs_registrar); @@ -22,6 +27,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) screen_retriever_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin"); screen_retriever_plugin_register_with_registrar(screen_retriever_registrar); + g_autoptr(FlPluginRegistrar) super_native_extensions_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "SuperNativeExtensionsPlugin"); + super_native_extensions_plugin_register_with_registrar(super_native_extensions_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index b250791df..9bc13503d 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,9 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST + irondash_engine_context isar_flutter_libs printing screen_retriever + super_native_extensions url_launcher_linux window_manager ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index c3e5cfeaa..13ad59b1c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,10 @@ import FlutterMacOS import Foundation +import device_info_plus import file_picker import flutter_archive +import irondash_engine_context import isar_flutter_libs import package_info_plus import path_provider_foundation @@ -14,12 +16,15 @@ import printing import screen_retriever import shared_preferences_foundation import sqflite_darwin +import super_native_extensions import url_launcher_macos import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FlutterArchivePlugin.register(with: registry.registrar(forPlugin: "FlutterArchivePlugin")) + IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) IsarFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "IsarFlutterLibsPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) @@ -27,6 +32,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 5bbc4d808..02ed3acad 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -286,6 +286,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.7" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: "72d146c6d7098689ff5c5f66bcf593ac11efc530095385356e131070333e64da" + url: "https://pub.dev" + source: hosted + version: "11.3.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" diff_match_patch: dependency: transitive description: @@ -596,6 +612,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + irondash_engine_context: + dependency: transitive + description: + name: irondash_engine_context + sha256: "2bb0bc13dfda9f5aaef8dde06ecc5feb1379f5bb387d59716d799554f3f305d7" + url: "https://pub.dev" + source: hosted + version: "0.5.5" + irondash_message_channel: + dependency: transitive + description: + name: irondash_message_channel + sha256: b4101669776509c76133b8917ab8cfc704d3ad92a8c450b92934dd8884a2f060 + url: "https://pub.dev" + source: hosted + version: "0.7.0" isar: dependency: "direct main" description: @@ -948,6 +980,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.2" + pixel_snap: + dependency: transitive + description: + name: pixel_snap + sha256: "677410ea37b07cd37ecb6d5e6c0d8d7615a7cf3bd92ba406fd1ac57e937d1fb0" + url: "https://pub.dev" + source: hosted + version: "0.1.5" platform: dependency: transitive description: @@ -1178,6 +1218,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" sqflite: dependency: "direct main" description: @@ -1266,6 +1314,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + super_clipboard: + dependency: "direct main" + description: + name: super_clipboard + sha256: e73f3bb7e66cc9260efa1dc507f979138e7e106c3521e2dda2d0311f6d728a16 + url: "https://pub.dev" + source: hosted + version: "0.9.1" + super_native_extensions: + dependency: transitive + description: + name: super_native_extensions + sha256: b9611dcb68f1047d6f3ef11af25e4e68a21b1a705bbcc3eb8cb4e9f5c3148569 + url: "https://pub.dev" + source: hosted + version: "0.9.1" synchronized: dependency: transitive description: @@ -1410,6 +1474,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.4" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" vector_math: dependency: transitive description: @@ -1474,6 +1546,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.10.1" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" + url: "https://pub.dev" + source: hosted + version: "1.1.5" window_manager: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8160cddb9..f495c7acc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -108,6 +108,7 @@ dependencies: logging: ^1.3.0 sqflite_common_ffi: ^2.3.0 shared_preferences: ^2.5.3 + super_clipboard: ^0.9.1 dependency_overrides: # it forces the version of the intl package to be 0.19.0 across all dependencies, even if some packages specify a different compatible version. diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 1246d6063..b958e4b14 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,14 +6,18 @@ #include "generated_plugin_registrant.h" +#include #include #include #include #include +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + IrondashEngineContextPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("IrondashEngineContextPluginCApi")); IsarFlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("IsarFlutterLibsPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( @@ -22,6 +26,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("PrintingPlugin")); ScreenRetrieverPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverPlugin")); + SuperNativeExtensionsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SuperNativeExtensionsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); WindowManagerPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index bf56c4834..ca3e7628d 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,10 +3,12 @@ # list(APPEND FLUTTER_PLUGIN_LIST + irondash_engine_context isar_flutter_libs permission_handler_windows printing screen_retriever + super_native_extensions url_launcher_windows window_manager ) From 05003e2ce156ae4ce52fc4f8a9bc33f0e0ebc746 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 22 Aug 2025 00:39:57 +0300 Subject: [PATCH 135/197] =?UTF-8?q?=D7=A9=D7=99=D7=A0=D7=95=D7=99=20=D7=9C?= =?UTF-8?q?=D7=94=D7=A2=D7=A8=D7=94=20'=D7=90=D7=99=D7=A9=D7=99=D7=AA'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/notes/widgets/note_editor_dialog.dart | 2 +- lib/notes/widgets/notes_sidebar.dart | 4 ++-- lib/text_book/view/combined_view/combined_book_screen.dart | 4 ++-- lib/text_book/view/splited_view/simple_book_view.dart | 4 ++-- lib/text_book/view/text_book_screen.dart | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/notes/widgets/note_editor_dialog.dart b/lib/notes/widgets/note_editor_dialog.dart index cca1e2382..36bafe985 100644 --- a/lib/notes/widgets/note_editor_dialog.dart +++ b/lib/notes/widgets/note_editor_dialog.dart @@ -55,7 +55,7 @@ class _NoteEditorDialogState extends State { bool get _isEditing => widget.existingNote != null; /// Get dialog title - String get _dialogTitle => _isEditing ? 'עריכת הערה' : 'הערה חדשה'; + String get _dialogTitle => _isEditing ? 'עריכת הערה אישית' : 'הערה אישית חדשה'; /// Handle save operation Future _handleSave() async { diff --git a/lib/notes/widgets/notes_sidebar.dart b/lib/notes/widgets/notes_sidebar.dart index 56cce7533..085672110 100644 --- a/lib/notes/widgets/notes_sidebar.dart +++ b/lib/notes/widgets/notes_sidebar.dart @@ -383,14 +383,14 @@ class _NotesSidebarState extends State { Text( _searchQuery.isNotEmpty ? 'לא נמצאו תוצאות' - : 'אין הערות עדיין', + : 'אין הערות אישיות עדיין', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), Text( _searchQuery.isNotEmpty ? 'נסה מילות חיפוש אחרות' - : 'בחר טקסט והוסף הערה ראשונה', + : 'בחר טקסט והוסף הערה אישית ראשונה', style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), diff --git a/lib/text_book/view/combined_view/combined_book_screen.dart b/lib/text_book/view/combined_view/combined_book_screen.dart index aa154309f..4a9582cc4 100644 --- a/lib/text_book/view/combined_view/combined_book_screen.dart +++ b/lib/text_book/view/combined_view/combined_book_screen.dart @@ -229,7 +229,7 @@ ctx.ContextMenu _buildContextMenuForIndex(TextBookLoaded state, int paragraphInd label: () { final text = _lastSelectedText ?? _selectedText; if (text == null || text.trim().isEmpty) { - return 'הוסף הערה'; + return 'הוסף הערה אישית'; } final preview = text.length > 12 ? '${text.substring(0, 12)}...' : text; return 'הוסף הערה ל: "$preview"'; @@ -284,7 +284,7 @@ ctx.ContextMenu _buildContextMenuForIndex(TextBookLoaded state, int paragraphInd if (text == null || text.trim().isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('אנא בחר טקסט ליצירת הערה'), + content: Text('אנא בחר טקסט ליצירת הערה אישית'), duration: Duration(seconds: 2), ), ); diff --git a/lib/text_book/view/splited_view/simple_book_view.dart b/lib/text_book/view/splited_view/simple_book_view.dart index c853d5930..9e8a95b32 100644 --- a/lib/text_book/view/splited_view/simple_book_view.dart +++ b/lib/text_book/view/splited_view/simple_book_view.dart @@ -232,7 +232,7 @@ class _SimpleBookViewState extends State { label: () { final text = _lastSelectedText ?? _selectedText; if (text == null || text.trim().isEmpty) { - return 'הוסף הערה'; + return 'הוסף הערה אישית'; } final preview = text.length > 12 ? '${text.substring(0, 12)}...' : text; @@ -268,7 +268,7 @@ class _SimpleBookViewState extends State { if (text == null || text.trim().isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('אנא בחר טקסט ליצירת הערה'), + content: Text('אנא בחר טקסט ליצירת הערה אישית'), duration: Duration(seconds: 2), ), ); diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index f0f784797..7284361dc 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -355,7 +355,7 @@ class _TextBookViewerBlocState extends State if (selectedText == null || selectedText.trim().isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('אנא בחר טקסט ליצירת הערה'), + content: Text('אנא בחר טקסט ליצירת הערה אישית'), duration: Duration(milliseconds: 1500), ), ); @@ -372,7 +372,7 @@ class _TextBookViewerBlocState extends State ); }, icon: const Icon(Icons.note_add), - tooltip: 'הוסף הערה', + tooltip: 'הוסף הערה אישית', ); } From 580094ddae7aefa3a307082d829503f5e1aa8b9e Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 24 Aug 2025 00:17:41 +0300 Subject: [PATCH 136/197] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20=D7=97?= =?UTF-8?q?=D7=91=D7=99=D7=9C=D7=AA=20HTML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- installer/otzaria.iss | 2 +- installer/otzaria_full.iss | 2 +- .../combined_view/combined_book_screen.dart | 72 +++-- .../combined_view/commentary_content.dart | 35 ++- .../view/splited_view/simple_book_view.dart | 124 ++++---- macos/Flutter/GeneratedPluginRegistrant.swift | 10 + pubspec.lock | 290 +++++++++++++++++- pubspec.yaml | 6 +- version.json | 2 +- 10 files changed, 414 insertions(+), 133 deletions(-) diff --git a/.gitignore b/.gitignore index ad41e0dd5..b57d2ca8f 100644 --- a/.gitignore +++ b/.gitignore @@ -60,7 +60,7 @@ android/ build/ fonts/ -installer/otzaria-0.9.31-windows.exe -installer/otzaria-0.9.31-windows-full.exe +installer/otzaria-0.9.45-windows.exe +installer/otzaria-0.9.45-windows-full.exe pubspec.lock flutter/ diff --git a/installer/otzaria.iss b/installer/otzaria.iss index 39c23eb7e..62aa2987e 100644 --- a/installer/otzaria.iss +++ b/installer/otzaria.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.31" +#define MyAppVersion "0.9.45" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/installer/otzaria_full.iss b/installer/otzaria_full.iss index 4fa7afd41..7ef31340f 100644 --- a/installer/otzaria_full.iss +++ b/installer/otzaria_full.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.31" +#define MyAppVersion "0.9.45" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/lib/text_book/view/combined_view/combined_book_screen.dart b/lib/text_book/view/combined_view/combined_book_screen.dart index aa154309f..ce3e0ea49 100644 --- a/lib/text_book/view/combined_view/combined_book_screen.dart +++ b/lib/text_book/view/combined_view/combined_book_screen.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_html/flutter_html.dart'; +import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; import 'package:flutter_context_menu/flutter_context_menu.dart' as ctx; import 'package:otzaria/settings/settings_bloc.dart'; import 'package:otzaria/settings/settings_state.dart'; @@ -58,7 +58,7 @@ class _CombinedViewState extends State { String? _lastSelectedText; int? _lastSelectionStart; int? _lastSelectionEnd; - + // מעקב אחר האינדקס הנוכחי שנבחר int? _currentSelectedIndex; @@ -107,7 +107,8 @@ class _CombinedViewState extends State { } // + בניית תפריט קונטקסט "מקובע" לאינדקס ספציפי של פסקה -ctx.ContextMenu _buildContextMenuForIndex(TextBookLoaded state, int paragraphIndex) { + ctx.ContextMenu _buildContextMenuForIndex( + TextBookLoaded state, int paragraphIndex) { // 1. קבלת מידע על גודל המסך final screenHeight = MediaQuery.of(context).size.height; @@ -128,7 +129,8 @@ ctx.ContextMenu _buildContextMenuForIndex(TextBookLoaded state, int paragraphInd return ctx.ContextMenu( maxHeight: screenHeight * 0.9, entries: [ - ctx.MenuItem(label: 'חיפוש', onSelected: () => widget.openLeftPaneTab(1)), + ctx.MenuItem( + label: 'חיפוש', onSelected: () => widget.openLeftPaneTab(1)), ctx.MenuItem.submenu( label: 'מפרשים', items: [ @@ -214,8 +216,11 @@ ctx.ContextMenu _buildContextMenuForIndex(TextBookLoaded state, int paragraphInd ), index: link.index2 - 1, openLeftPane: - (Settings.getValue('key-pin-sidebar') ?? false) || - (Settings.getValue('key-default-sidebar-open') ?? false), + (Settings.getValue('key-pin-sidebar') ?? + false) || + (Settings.getValue( + 'key-default-sidebar-open') ?? + false), ), ); }, @@ -231,7 +236,8 @@ ctx.ContextMenu _buildContextMenuForIndex(TextBookLoaded state, int paragraphInd if (text == null || text.trim().isEmpty) { return 'הוסף הערה'; } - final preview = text.length > 12 ? '${text.substring(0, 12)}...' : text; + final preview = + text.length > 12 ? '${text.substring(0, 12)}...' : text; return 'הוסף הערה ל: "$preview"'; }(), onSelected: () => _createNoteFromSelection(), @@ -248,7 +254,8 @@ ctx.ContextMenu _buildContextMenuForIndex(TextBookLoaded state, int paragraphInd ctx.MenuItem( label: 'העתק את כל הפסקה', enabled: paragraphIndex >= 0 && paragraphIndex < widget.data.length, - onSelected: () => _copyParagraphByIndex(paragraphIndex), // <--- קריאה לפונקציה החדשה עם האינדקס + onSelected: () => _copyParagraphByIndex( + paragraphIndex), // <--- קריאה לפונקציה החדשה עם האינדקס ), ctx.MenuItem( label: 'העתק את הטקסט המוצג', @@ -261,15 +268,15 @@ ctx.ContextMenu _buildContextMenuForIndex(TextBookLoaded state, int paragraphInd /// זיהוי האינדקס של הטקסט הנבחר int? _findIndexByText(String selectedText) { final cleanedSelected = selectedText.replaceAll(RegExp(r'\s+'), ' ').trim(); - + for (int i = 0; i < widget.data.length; i++) { final originalData = widget.data[i]; final cleanedOriginal = originalData .replaceAll(RegExp(r'<[^>]*>'), '') // הסרת תגי HTML .replaceAll(RegExp(r'\s+'), ' ') .trim(); - - if (cleanedOriginal.contains(cleanedSelected) || + + if (cleanedOriginal.contains(cleanedSelected) || cleanedSelected.contains(cleanedOriginal)) { return i; } @@ -331,7 +338,7 @@ ctx.ContextMenu _buildContextMenuForIndex(TextBookLoaded state, int paragraphInd final item = DataWriterItem(); item.add(Formats.plainText(combinedText)); item.add(Formats.htmlText(combinedHtml)); - + await SystemClipboard.instance?.write([item]); } @@ -364,7 +371,7 @@ $text final item = DataWriterItem(); item.add(Formats.plainText(text)); await clipboard.write([item]); - + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -404,30 +411,31 @@ $text if (clipboard != null) { // קבלת ההגדרות הנוכחיות לעיצוב final settingsState = context.read().state; - + // ניסיון למצוא את הטקסט המקורי עם תגי HTML String htmlContentToUse = plainText; - + // אם יש לנו אינדקס נוכחי, ננסה למצוא את הטקסט המקורי - if (_currentSelectedIndex != null && - _currentSelectedIndex! >= 0 && + if (_currentSelectedIndex != null && + _currentSelectedIndex! >= 0 && _currentSelectedIndex! < widget.data.length) { final originalData = widget.data[_currentSelectedIndex!]; - + // בדיקה אם הטקסט הפשוט מופיע בטקסט המקורי - final plainTextCleaned = plainText.replaceAll(RegExp(r'\s+'), ' ').trim(); + final plainTextCleaned = + plainText.replaceAll(RegExp(r'\s+'), ' ').trim(); final originalCleaned = originalData .replaceAll(RegExp(r'<[^>]*>'), '') // הסרת תגי HTML .replaceAll(RegExp(r'\s+'), ' ') .trim(); - + // אם הטקסט הפשוט תואם לטקסט המקורי (או חלק ממנו), נשתמש במקורי - if (originalCleaned.contains(plainTextCleaned) || + if (originalCleaned.contains(plainTextCleaned) || plainTextCleaned.contains(originalCleaned)) { htmlContentToUse = originalData; } } - + // יצירת HTML מעוצב עם הגדרות הגופן והגודל final finalHtmlContent = '''
@@ -437,9 +445,10 @@ $htmlContentToUse final item = DataWriterItem(); item.add(Formats.plainText(plainText)); // טקסט רגיל כגיבוי - item.add(Formats.htmlText(finalHtmlContent)); // טקסט מעוצב עם תגי HTML מקוריים + item.add(Formats.htmlText( + finalHtmlContent)); // טקסט מעוצב עם תגי HTML מקוריים await clipboard.write([item]); - + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -606,20 +615,17 @@ $htmlContentToUse if (settingsState.replaceHolyNames) { data = utils.replaceHolyNames(data); } - return Html( - data: state.removeNikud + return HtmlWidget( + state.removeNikud ? utils.highLight( utils.removeVolwels('$data\n'), state.searchText, ) : utils.highLight('$data\n', state.searchText), - style: { - 'body': Style( - fontSize: FontSize(widget.textSize), - fontFamily: settingsState.fontFamily, - textAlign: TextAlign.justify, - ), - }, + textStyle: TextStyle( + fontSize: widget.textSize, + fontFamily: settingsState.fontFamily, + ), ); }, ), diff --git a/lib/text_book/view/combined_view/commentary_content.dart b/lib/text_book/view/combined_view/commentary_content.dart index 828d1c28a..21a6db39a 100644 --- a/lib/text_book/view/combined_view/commentary_content.dart +++ b/lib/text_book/view/combined_view/commentary_content.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_html/flutter_html.dart'; +import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:otzaria/models/books.dart'; import 'package:otzaria/models/links.dart'; @@ -43,12 +43,12 @@ class _CommentaryContentState extends State { int _countSearchMatches(String text, String searchQuery) { if (searchQuery.isEmpty) return 0; - + final RegExp regex = RegExp( RegExp.escape(searchQuery), caseSensitive: false, ); - + return regex.allMatches(text).length; } @@ -59,9 +59,8 @@ class _CommentaryContentState extends State { widget.openBookCallback(TextBookTab( book: TextBook(title: utils.getTitleFromPath(widget.link.path2)), index: widget.link.index2 - 1, - openLeftPane: - (Settings.getValue('key-pin-sidebar') ?? false) || - (Settings.getValue('key-default-sidebar-open') ?? false), + openLeftPane: (Settings.getValue('key-pin-sidebar') ?? false) || + (Settings.getValue('key-default-sidebar-open') ?? false), )); }, child: FutureBuilder( @@ -72,24 +71,28 @@ class _CommentaryContentState extends State { if (widget.removeNikud) { text = utils.removeVolwels(text); } - + // ספירת תוצאות החיפוש ועדכון הרכיב האב if (widget.searchQuery.isNotEmpty) { - final searchCount = _countSearchMatches(text, widget.searchQuery); + final searchCount = + _countSearchMatches(text, widget.searchQuery); WidgetsBinding.instance.addPostFrameCallback((_) { widget.onSearchResultsCountChanged?.call(searchCount); }); } - - text = utils.highLight(text, widget.searchQuery, currentIndex: widget.currentSearchIndex); + + text = utils.highLight(text, widget.searchQuery, + currentIndex: widget.currentSearchIndex); return BlocBuilder( builder: (context, settingsState) { - return Html(data: text, style: { - 'body': Style( - fontSize: FontSize(widget.fontSize / 1.2), - fontFamily: settingsState.fontFamily, - textAlign: TextAlign.justify), - }); + return DefaultTextStyle.merge( + textAlign: TextAlign.justify, + style: TextStyle( + fontSize: widget.fontSize / 1.2, + fontFamily: settingsState.fontFamily, + ), + child: HtmlWidget(text), + ); }, ); } diff --git a/lib/text_book/view/splited_view/simple_book_view.dart b/lib/text_book/view/splited_view/simple_book_view.dart index c853d5930..26fb5e893 100644 --- a/lib/text_book/view/splited_view/simple_book_view.dart +++ b/lib/text_book/view/splited_view/simple_book_view.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_html/flutter_html.dart'; +import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; import 'package:flutter_context_menu/flutter_context_menu.dart' as ctx; import 'package:otzaria/settings/settings_bloc.dart'; import 'package:otzaria/settings/settings_state.dart'; @@ -55,10 +55,10 @@ class _SimpleBookViewState extends State { String? _lastSelectedText; int? _lastSelectionStart; int? _lastSelectionEnd; - + // מעקב אחר האינדקס הנוכחי שנבחר int? _currentSelectedIndex; - + // מעקב אחר הפסקה שעליה לחץ המשתמש (לתפריט קונטקסט) int? _contextMenuParagraphIndex; @@ -244,8 +244,8 @@ class _SimpleBookViewState extends State { // העתקה ctx.MenuItem( label: 'העתק', - enabled: (_lastSelectedText ?? _selectedText) != null && - (_lastSelectedText ?? _selectedText)!.trim().isNotEmpty, + enabled: (_lastSelectedText ?? _selectedText) != null && + (_lastSelectedText ?? _selectedText)!.trim().isNotEmpty, onSelected: _copyFormattedText, ), ctx.MenuItem( @@ -283,14 +283,14 @@ class _SimpleBookViewState extends State { /// העתקת הפסקה הנוכחית ללוח void _copyCurrentParagraph() async { if (_currentSelectedIndex == null) return; - + final text = widget.data[_currentSelectedIndex!]; if (text.trim().isEmpty) return; final item = DataWriterItem(); item.add(Formats.plainText(text)); item.add(Formats.htmlText(_formatTextAsHtml(text))); - + await SystemClipboard.instance?.write([item]); } @@ -299,21 +299,21 @@ class _SimpleBookViewState extends State { // אם לא זוהתה פסקה ספציפית, נשתמש בפסקה הראשונה הנראית final state = context.read().state; if (state is! TextBookLoaded) return; - + int? indexToCopy = _contextMenuParagraphIndex; if (indexToCopy == null && state.visibleIndices.isNotEmpty) { indexToCopy = state.visibleIndices.first; } - + if (indexToCopy == null || indexToCopy >= widget.data.length) return; - + final text = widget.data[indexToCopy]; if (text.trim().isEmpty) return; final item = DataWriterItem(); item.add(Formats.plainText(text)); item.add(Formats.htmlText(_formatTextAsHtml(text))); - + await SystemClipboard.instance?.write([item]); } @@ -338,7 +338,7 @@ class _SimpleBookViewState extends State { final item = DataWriterItem(); item.add(Formats.plainText(combinedText)); item.add(Formats.htmlText(combinedHtml)); - + await SystemClipboard.instance?.write([item]); } @@ -381,7 +381,7 @@ $text final item = DataWriterItem(); item.add(Formats.plainText(text)); await clipboard.write([item]); - + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -421,30 +421,31 @@ $text if (clipboard != null) { // קבלת ההגדרות הנוכחיות לעיצוב final settingsState = context.read().state; - + // ניסיון למצוא את הטקסט המקורי עם תגי HTML String htmlContentToUse = plainText; - + // אם יש לנו אינדקס נוכחי, ננסה למצוא את הטקסט המקורי - if (_currentSelectedIndex != null && - _currentSelectedIndex! >= 0 && + if (_currentSelectedIndex != null && + _currentSelectedIndex! >= 0 && _currentSelectedIndex! < widget.data.length) { final originalData = widget.data[_currentSelectedIndex!]; - + // בדיקה אם הטקסט הפשוט מופיע בטקסט המקורי - final plainTextCleaned = plainText.replaceAll(RegExp(r'\s+'), ' ').trim(); + final plainTextCleaned = + plainText.replaceAll(RegExp(r'\s+'), ' ').trim(); final originalCleaned = originalData .replaceAll(RegExp(r'<[^>]*>'), '') // הסרת תגי HTML .replaceAll(RegExp(r'\s+'), ' ') .trim(); - + // אם הטקסט הפשוט תואם לטקסט המקורי (או חלק ממנו), נשתמש במקורי - if (originalCleaned.contains(plainTextCleaned) || + if (originalCleaned.contains(plainTextCleaned) || plainTextCleaned.contains(originalCleaned)) { htmlContentToUse = originalData; } } - + // יצירת HTML מעוצב עם הגדרות הגופן והגודל final finalHtmlContent = '''
@@ -454,9 +455,10 @@ $htmlContentToUse final item = DataWriterItem(); item.add(Formats.plainText(plainText)); // טקסט רגיל כגיבוי - item.add(Formats.htmlText(finalHtmlContent)); // טקסט מעוצב עם תגי HTML מקוריים + item.add(Formats.htmlText( + finalHtmlContent)); // טקסט מעוצב עם תגי HTML מקוריים await clipboard.write([item]); - + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -575,9 +577,12 @@ $htmlContentToUse child: GestureDetector( onSecondaryTapDown: (details) { // זיהוי הפסקה לפי מיקום הלחיצה - final RenderBox renderBox = context.findRenderObject() as RenderBox; - final localPosition = renderBox.globalToLocal(details.globalPosition); - _contextMenuParagraphIndex = _findParagraphAtPosition(localPosition, state); + final RenderBox renderBox = + context.findRenderObject() as RenderBox; + final localPosition = + renderBox.globalToLocal(details.globalPosition); + _contextMenuParagraphIndex = + _findParagraphAtPosition(localPosition, state); }, child: ctx.ContextMenuRegion( contextMenu: _buildContextMenu(state), @@ -592,43 +597,44 @@ $htmlContentToUse return BlocBuilder( builder: (context, settingsState) { String data = widget.data[index]; - if (!settingsState.showTeamim) { - data = utils.removeTeamim(data); - } - if (settingsState.replaceHolyNames) { - data = utils.replaceHolyNames(data); - } - return InkWell( - onTap: () { - _currentSelectedIndex = index; - context.read().add( - UpdateSelectedIndex(index), - ); - }, - child: Html( - // remove nikud if needed - data: state.removeNikud - ? utils.highLight( - utils.removeVolwels('$data\n'), - state.searchText, - ) - : utils.highLight('$data\n', state.searchText), - style: { - 'body': Style( - fontSize: FontSize(widget.textSize), + if (!settingsState.showTeamim) { + data = utils.removeTeamim(data); + } + if (settingsState.replaceHolyNames) { + data = utils.replaceHolyNames(data); + } + return InkWell( + onTap: () { + _currentSelectedIndex = index; + context.read().add( + UpdateSelectedIndex(index), + ); + }, + child: DefaultTextStyle.merge( + textAlign: TextAlign.justify, + style: TextStyle( + fontSize: widget.textSize, fontFamily: settingsState.fontFamily, - textAlign: TextAlign.justify, ), - }, - ), - ); - }, - ); - }, + child: HtmlWidget( + // remove nikud if needed + state.removeNikud + ? utils.highLight( + utils.removeVolwels('$data\n'), + state.searchText, + ) + : utils.highLight( + '$data\n', state.searchText), + ), + ), + ); + }, + ); + }, + ), ), ), ), - ), ); // אם סרגל ההערות פתוח, הצג אותו לצד התוכן diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 13ad59b1c..17a184706 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,11 +5,13 @@ import FlutterMacOS import Foundation +import audio_session import device_info_plus import file_picker import flutter_archive import irondash_engine_context import isar_flutter_libs +import just_audio import package_info_plus import path_provider_foundation import printing @@ -18,14 +20,19 @@ import shared_preferences_foundation import sqflite_darwin import super_native_extensions import url_launcher_macos +import video_player_avfoundation +import wakelock_plus +import webview_flutter_wkwebview import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FlutterArchivePlugin.register(with: registry.registrar(forPlugin: "FlutterArchivePlugin")) IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) IsarFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "IsarFlutterLibsPlugin")) + JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) @@ -34,5 +41,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 02ed3acad..be5f0423c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -46,6 +46,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7" + url: "https://pub.dev" + source: hosted + version: "0.2.2" barcode: dependency: transitive description: @@ -158,6 +166,30 @@ packages: url: "https://pub.dev" source: hosted version: "8.9.3" + cached_network_image: + dependency: transitive + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: "direct main" description: @@ -174,6 +206,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + chewie: + dependency: transitive + description: + name: chewie + sha256: "19b93a1e60e4ba640a792208a6543f1c7d5b124d011ce0199e2f18802199d984" + url: "https://pub.dev" + source: hosted + version: "1.12.1" cli_util: dependency: transitive description: @@ -286,6 +326,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.7" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" device_info_plus: dependency: transitive description: @@ -403,6 +451,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_context_menu: dependency: "direct main" description: @@ -421,14 +477,6 @@ packages: url: "https://github.com/sidlatau/flutter_document_picker" source: git version: "5.2.3" - flutter_html: - dependency: "direct main" - description: - name: flutter_html - sha256: "38a2fd702ffdf3243fb7441ab58aa1bc7e6922d95a50db76534de8260638558d" - url: "https://pub.dev" - source: hosted - version: "3.0.0" flutter_launcher_icons: dependency: "direct main" description: @@ -490,6 +538,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.1" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845 + url: "https://pub.dev" + source: hosted + version: "2.2.0" flutter_test: dependency: "direct dev" description: flutter @@ -500,6 +556,22 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_widget_from_html: + dependency: "direct main" + description: + name: flutter_widget_from_html + sha256: "71566eb82614cbf548d84e04cbc532a2444479edb05447c0b6121134ee95cc05" + url: "https://pub.dev" + source: hosted + version: "0.17.0" + flutter_widget_from_html_core: + dependency: transitive + description: + name: flutter_widget_from_html_core + sha256: "1120ee6ed3509ceff2d55aa6c6cbc7b6b1291434422de2411b5a59364dd6ff03" + url: "https://pub.dev" + source: hosted + version: "0.17.0" frontend_server_client: dependency: transitive description: @@ -516,6 +588,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + fwfh_cached_network_image: + dependency: transitive + description: + name: fwfh_cached_network_image + sha256: "484cb5f8047f02cfac0654fca5832bfa91bb715fd7fc651c04eb7454187c4af8" + url: "https://pub.dev" + source: hosted + version: "0.16.1" + fwfh_chewie: + dependency: transitive + description: + name: fwfh_chewie + sha256: ae74fc26798b0e74f3983f7b851e74c63b9eeb2d3015ecd4b829096b2c3f8818 + url: "https://pub.dev" + source: hosted + version: "0.16.1" + fwfh_just_audio: + dependency: transitive + description: + name: fwfh_just_audio + sha256: dfd622a0dfe049ac647423a2a8afa7f057d9b2b93d92710b624e3d370b1ac69a + url: "https://pub.dev" + source: hosted + version: "0.17.0" + fwfh_svg: + dependency: transitive + description: + name: fwfh_svg + sha256: "2e6bb241179eeeb1a7941e05c8c923b05d332d36a9085233e7bf110ea7deb915" + url: "https://pub.dev" + source: hosted + version: "0.16.1" + fwfh_url_launcher: + dependency: transitive + description: + name: fwfh_url_launcher + sha256: c38aa8fb373fda3a89b951fa260b539f623f6edb45eee7874cb8b492471af881 + url: "https://pub.dev" + source: hosted + version: "0.16.1" + fwfh_webview: + dependency: transitive + description: + name: fwfh_webview + sha256: "06595c7ca945c8d8522864a764e21abbcf50096852f8d256e45c0fa101b6fbc6" + url: "https://pub.dev" + source: hosted + version: "0.15.5" gematria: dependency: "direct main" description: @@ -660,6 +780,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.9.0" + just_audio: + dependency: transitive + description: + name: just_audio + sha256: "679637a3ec5b6e00f36472f5a3663667df00ee4822cbf5dafca0f568c710960a" + url: "https://pub.dev" + source: hosted + version: "0.10.4" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" + url: "https://pub.dev" + source: hosted + version: "0.4.16" kosher_dart: dependency: "direct main" description: @@ -700,14 +844,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" - list_counter: - dependency: transitive - description: - name: list_counter - sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237 - url: "https://pub.dev" - source: hosted - version: "1.0.2" logging: dependency: "direct main" description: @@ -812,6 +948,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" package_config: dependency: transitive description: @@ -1482,6 +1626,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.1" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: ca81fdfaf62a5ab45d7296614aea108d2c7d0efca8393e96174bf4d51e6725b0 + url: "https://pub.dev" + source: hosted + version: "1.1.18" vector_math: dependency: transitive description: @@ -1490,6 +1658,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + video_player: + dependency: transitive + description: + name: video_player + sha256: "0d55b1f1a31e5ad4c4967bfaa8ade0240b07d20ee4af1dfef5f531056512961a" + url: "https://pub.dev" + source: hosted + version: "2.10.0" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "53f3b57c7ac88c18e6074d0f94c7146e128c515f0a4503c3061b8e71dea3a0f2" + url: "https://pub.dev" + source: hosted + version: "2.8.12" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: f9a780aac57802b2892f93787e5ea53b5f43cc57dc107bee9436458365be71cd + url: "https://pub.dev" + source: hosted + version: "2.8.4" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: cf2a1d29a284db648fd66cbd18aacc157f9862d77d2cc790f6f9678a46c1db5a + url: "https://pub.dev" + source: hosted + version: "6.4.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" vm_service: dependency: transitive description: @@ -1498,6 +1706,22 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.0" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: a474e314c3e8fb5adef1f9ae2d247e57467ad557fa7483a2b895bc1b421c5678 + url: "https://pub.dev" + source: hosted + version: "1.3.2" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: e10444072e50dbc4999d7316fd303f7ea53d31c824aa5eb05d7ccbdd98985207 + url: "https://pub.dev" + source: hosted + version: "1.2.3" watcher: dependency: transitive description: @@ -1538,6 +1762,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + webview_flutter: + dependency: transitive + description: + name: webview_flutter + sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba + url: "https://pub.dev" + source: hosted + version: "4.13.0" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "0a42444056b24ed832bdf3442d65c5194f6416f7e782152384944053c2ecc9a3" + url: "https://pub.dev" + source: hosted + version: "4.10.0" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" + url: "https://pub.dev" + source: hosted + version: "2.14.0" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: fb46db8216131a3e55bcf44040ca808423539bc6732e7ed34fb6d8044e3d512f + url: "https://pub.dev" + source: hosted + version: "3.23.0" win32: dependency: transitive description: @@ -1588,4 +1844,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.8.0 <4.0.0" - flutter: ">=3.29.0" + flutter: ">=3.32.0" diff --git a/pubspec.yaml b/pubspec.yaml index f495c7acc..71c209960 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,7 @@ msix_config: publisher_display_name: sivan22 identity_name: sivan22.Otzaria description: "ספריית ספרים יהודיים עם אפשרויות חיפוש חכם" - msix_version: 0.9.31.0 + msix_version: 0.9.45.0 logo_path: assets/icon/icon.png publisher: CN=sivan22, O=sivan22, C=IL certificate_path: sivan22.pfx @@ -36,7 +36,7 @@ msix_config: # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.9.31 +version: 0.9.45 environment: sdk: ">=3.2.6 <4.0.0" @@ -64,7 +64,7 @@ dependencies: html: ^0.15.1 pdfrx: ^1.3.2 url_launcher: ^6.3.1 - flutter_html: ^3.0.0 + flutter_widget_from_html: ^0.17.0 scrollable_positioned_list: ^0.3.8 search_highlight_text: ^1.0.0+2 fuzzywuzzy: ^1.1.6 diff --git a/version.json b/version.json index 88abb7cac..2d70c4b20 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "0.9.31" + "version": "0.9.45" } \ No newline at end of file From d900410f6ec091b59fee9af3654dec41e78a9405 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 24 Aug 2025 14:14:10 +0300 Subject: [PATCH 137/197] =?UTF-8?q?=D7=93=D7=99=D7=95=D7=95=D7=97=20=D7=98?= =?UTF-8?q?=D7=9C=D7=A4=D7=95=D7=A0=D7=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .kiro/specs/personal-notes/design.md | 1311 ----------------- .kiro/specs/personal-notes/requirements.md | 231 --- .kiro/specs/personal-notes/tasks.md | 506 ------- lib/models/phone_report_data.dart | 116 ++ lib/services/data_collection_service.dart | 150 ++ lib/services/phone_report_service.dart | 144 ++ lib/text_book/view/text_book_screen.dart | 610 ++++++-- lib/widgets/phone_report_tab.dart | 385 +++++ lib/widgets/reporting_numbers_widget.dart | 276 ++++ test/models/phone_report_data_test.dart | 68 + .../data_collection_service_test.dart | 36 + 11 files changed, 1638 insertions(+), 2195 deletions(-) delete mode 100644 .kiro/specs/personal-notes/design.md delete mode 100644 .kiro/specs/personal-notes/requirements.md delete mode 100644 .kiro/specs/personal-notes/tasks.md create mode 100644 lib/models/phone_report_data.dart create mode 100644 lib/services/data_collection_service.dart create mode 100644 lib/services/phone_report_service.dart create mode 100644 lib/widgets/phone_report_tab.dart create mode 100644 lib/widgets/reporting_numbers_widget.dart create mode 100644 test/models/phone_report_data_test.dart create mode 100644 test/services/data_collection_service_test.dart diff --git a/.kiro/specs/personal-notes/design.md b/.kiro/specs/personal-notes/design.md deleted file mode 100644 index a4eb594ae..000000000 --- a/.kiro/specs/personal-notes/design.md +++ /dev/null @@ -1,1311 +0,0 @@ -# Design Document - Personal Notes System - -## Overview - -מערכת ההערות האישיות תאפשר למשתמשים להוסיף, לערוך ולנהל הערות אישיות על טקסטים בספרים השונים. המערכת תפתור את הבעיה הקיימת של אי-דיוק במיקום ההערות על ידי מעבר ממודל "בלוקים/שורות" למודל מסמך קנוני עם מערכת עיגון מתקדמת. - -המערכת תשתלב בארכיטקטורה הקיימת של האפליקציה שמבוססת על Flutter עם BLoC pattern, ותשתמש במסד נתונים מקומי לשמירת ההערות. - -## Architecture - -### High-Level Architecture - -```mermaid -graph TB - UI[UI Layer - Flutter Widgets] --> BLoC[BLoC Layer - State Management] - BLoC --> Repository[Repository Layer] - Repository --> DataProvider[Data Provider Layer] - DataProvider --> Storage[(Local Storage - SQLite)] - DataProvider --> FileSystem[(File System - Books)] - - subgraph "Notes System" - NotesUI[Notes UI Components] - NotesBloc[Notes BLoC] - NotesRepo[Notes Repository] - NotesData[Notes Data Provider] - CanonicalService[Canonical Text Service] - AnchorService[Anchoring Service] - end - - UI --> NotesUI - BLoC --> NotesBloc - Repository --> NotesRepo - DataProvider --> NotesData - NotesData --> CanonicalService - NotesData --> AnchorService -``` - -### Integration with Existing System - -המערכת תשתלב עם הרכיבים הקיימים: - -1. **TextBookBloc** - יורחב לכלול מצב הערות -2. **SimpleBookView** - יעודכן להציג הערות ולאפשר יצירתן -3. **FileSystemData** - יורחב לתמוך במסמכים קנוניים -4. **TextBookRepository** - יורחב לעבוד עם מערכת ההערות - -## Components and Interfaces - -### Core Components - -#### 1. Canonical Text Service -```dart -class CanonicalTextService { - /// יוצר מסמך קנוני מטקסט ספר - Future createCanonicalDocument(String bookTitle); - - /// מנרמל טקסט לפי התקן המוגדר - String normalizeText(String text); - - /// מחשב גרסת מסמך (checksum) - String calculateDocumentVersion(String canonicalText); - - /// מחלץ חלון הקשר מטקסט - ContextWindow extractContextWindow(String text, int start, int end); -} -``` - -#### 2. Anchoring Service -```dart -class AnchoringService { - /// יוצר עוגן חדש להערה - AnchorData createAnchor(String bookId, String canonicalText, - int charStart, int charEnd); - - /// מבצע re-anchoring להערה קיימת - Future reanchorNote(Note note, CanonicalDocument document); - - /// מחפש מיקום מדויק בטקסט - List findExactMatch(String textHash, CanonicalDocument doc); - - /// מחפש לפי הקשר - List findByContext(String beforeHash, String afterHash, - CanonicalDocument doc); - - /// מחפש דמיון מטושטש - List findFuzzyMatch(String normalizedText, - CanonicalDocument doc); -} -``` - -#### 3. Notes Repository -```dart -class NotesRepository { - /// יוצר הערה חדשה - Future createNote(CreateNoteRequest request); - - /// מעדכן הערה קיימת - Future updateNote(String noteId, UpdateNoteRequest request); - - /// מוחק הערה - Future deleteNote(String noteId); - - /// מחזיר הערות לספר - Future> getNotesForBook(String bookId); - - /// מחפש הערות - Future> searchNotes(String query); - - /// מייצא הערות - Future exportNotes(ExportOptions options); - - /// מייבא הערות - Future importNotes(String data, ImportOptions options); -} -``` - -### Data Models - -#### Note Model -```dart -class Note { - final String id; - final String bookId; - final String docVersionId; - final List? logicalPath; - final int charStart; - final int charEnd; - final String selectedTextNormalized; - final String textHash; - final String contextBefore; - final String contextAfter; - final String contextBeforeHash; - final String contextAfterHash; - final int rollingBefore; - final int rollingAfter; - final NoteStatus status; - final String contentMarkdown; - final String authorUserId; - final NotePrivacy privacy; - final List tags; - final DateTime createdAt; - final DateTime updatedAt; -} - -enum NoteStatus { anchored, shifted, orphan } -enum NotePrivacy { private, shared } -``` - -#### Canonical Document Model -```dart -class CanonicalDocument { - final String bookId; - final String versionId; - final String canonicalText; - final Map> textHashIndex; - final Map> contextHashIndex; - final Map> rollingHashIndex; - final List? logicalStructure; -} -``` - -#### Anchor Candidate Model -```dart -class AnchorCandidate { - final int start; - final int end; - final double score; // 0.0 to 1.0 - final String strategy; // "exact" | "context" | "fuzzy" - - const AnchorCandidate(this.start, this.end, this.score, this.strategy); -} -``` - -#### Anchor Data Model -```dart -class AnchorData { - final int charStart; - final int charEnd; - final String textHash; - final String contextBefore; - final String contextAfter; - final String contextBeforeHash; - final String contextAfterHash; - final int rollingBefore; - final int rollingAfter; - final NoteStatus status; -} -``` - -#### Anchor Result Model -```dart -class AnchorResult { - final NoteStatus status; // anchored|shifted|orphan - final int? start; - final int? end; - final List candidates; - final String? errorMessage; - - const AnchorResult( - this.status, { - this.start, - this.end, - this.candidates = const [], - this.errorMessage, - }); - - bool get isSuccess => status != NoteStatus.orphan || candidates.isNotEmpty; - bool get hasMultipleCandidates => candidates.length > 1; -} -``` - -## Data Models - -### Database Schema - -המערכת תשתמש ב-SQLite עם הטבלאות הבאות: - -#### Notes Table -```sql -CREATE TABLE notes ( - note_id TEXT PRIMARY KEY, - book_id TEXT NOT NULL, - doc_version_id TEXT NOT NULL, - logical_path TEXT, - char_start INTEGER NOT NULL, - char_end INTEGER NOT NULL, - selected_text_normalized TEXT NOT NULL, - text_hash TEXT NOT NULL, - ctx_before TEXT NOT NULL, - ctx_after TEXT NOT NULL, - ctx_before_hash TEXT NOT NULL, - ctx_after_hash TEXT NOT NULL, - rolling_before INTEGER NOT NULL, - rolling_after INTEGER NOT NULL, - status TEXT NOT NULL CHECK (status IN ('anchored', 'shifted', 'orphan')), - content_markdown TEXT NOT NULL, - author_user_id TEXT NOT NULL, - privacy TEXT NOT NULL CHECK (privacy IN ('private', 'shared')), - tags TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); -``` - -#### Canonical Documents Table -```sql -CREATE TABLE canonical_documents ( - id TEXT PRIMARY KEY, - book_id TEXT NOT NULL, - version_id TEXT NOT NULL, - canonical_text TEXT NOT NULL, - text_hash_index TEXT NOT NULL, -- JSON - context_hash_index TEXT NOT NULL, -- JSON - rolling_hash_index TEXT NOT NULL, -- JSON - logical_structure TEXT, -- JSON - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - UNIQUE(book_id, version_id) -); -``` - -#### Database Indexes -```sql --- Performance indexes -CREATE INDEX idx_notes_book_id ON notes(book_id); -CREATE INDEX idx_notes_doc_version ON notes(doc_version_id); -CREATE INDEX idx_notes_text_hash ON notes(text_hash); -CREATE INDEX idx_notes_ctx_hashes ON notes(ctx_before_hash, ctx_after_hash); -CREATE INDEX idx_notes_author ON notes(author_user_id); -CREATE INDEX idx_notes_status ON notes(status); -CREATE INDEX idx_notes_updated ON notes(updated_at); - --- Full-text search for Hebrew content -CREATE VIRTUAL TABLE notes_fts USING fts5( - content_markdown, tags, selected_text_normalized, - content='notes', content_rowid='rowid' -); - --- Triggers to sync FTS table -CREATE TRIGGER notes_fts_insert AFTER INSERT ON notes BEGIN - INSERT INTO notes_fts(rowid, content_markdown, tags, selected_text_normalized) - VALUES (new.rowid, new.content_markdown, new.tags, new.selected_text_normalized); -END; - -CREATE TRIGGER notes_fts_delete AFTER DELETE ON notes BEGIN - DELETE FROM notes_fts WHERE rowid = old.rowid; -END; - -CREATE TRIGGER notes_fts_update AFTER UPDATE ON notes BEGIN - DELETE FROM notes_fts WHERE rowid = old.rowid; - INSERT INTO notes_fts(rowid, content_markdown, tags, selected_text_normalized) - VALUES (new.rowid, new.content_markdown, new.tags, new.selected_text_normalized); -END; -``` - -#### SQLite Configuration -```sql --- Performance optimizations -PRAGMA journal_mode=WAL; -PRAGMA synchronous=NORMAL; -PRAGMA temp_store=MEMORY; -PRAGMA cache_size=10000; -PRAGMA foreign_keys=ON; -PRAGMA busy_timeout=5000; -PRAGMA analysis_limit=400; - --- Run after initial data population -ANALYZE; -``` - -### File Storage Structure - -``` -.kiro/ -├── notes/ -│ ├── notes.db # SQLite database -│ ├── exports/ # Exported notes -│ └── backups/ # Automatic backups -└── canonical/ - ├── documents/ # Cached canonical documents - └── indexes/ # Pre-built search indexes -```## Error Handling - -### Error Types and Handling Strategy - -#### 1. Anchoring Errors -```dart -enum AnchoringError { - documentNotFound, - multipleMatches, - noMatchFound, - corruptedAnchor, - versionMismatch -} - -class AnchoringException implements Exception { - final AnchoringError type; - final String message; - final Note? note; - final List? candidates; -} -``` - -#### 2. Storage Errors -```dart -enum StorageError { - databaseCorrupted, - diskSpaceFull, - permissionDenied, - networkError -} - -class StorageException implements Exception { - final StorageError type; - final String message; - final String? filePath; -} -``` - -#### 3. Error Recovery Strategies - -**Anchoring Failures:** -- Multiple matches → Present user with candidates dialog -- No match found → Mark as orphan and add to orphans list -- Corrupted anchor → Attempt fuzzy matching, fallback to orphan -- Version mismatch → Trigger re-anchoring process - -**Storage Failures:** -- Database corruption → Restore from backup, rebuild if necessary -- Disk space → Prompt user to free space or change location -- Permission denied → Request permissions or suggest alternative location - -### Logging and Monitoring - -```dart -class NotesLogger { - static void logAnchoringAttempt(String noteId, AnchoringResult result); - static void logPerformanceMetric(String operation, Duration duration); - static void logError(Exception error, StackTrace stackTrace); - static void logUserAction(String action, Map context); -} -``` - -## Testing Strategy - -### Unit Tests - -#### 1. Text Normalization Tests -```dart -group('Text Normalization', () { - test('should normalize multiple spaces to single space', () { - expect(normalizeText('שלום עולם'), equals('שלום עולם')); - }); - - test('should handle Hebrew punctuation consistently', () { - expect(normalizeText('שלום, עולם!'), equals('שלום, עולם!')); - }); - - test('should preserve nikud when configured', () { - expect(normalizeText('שָׁלוֹם עוֹלָם'), equals('שָׁלוֹם עוֹלָם')); - }); -}); -``` - -#### 2. Anchoring Algorithm Tests -```dart -group('Anchoring Service', () { - test('should find exact match by text hash', () async { - final result = await anchoringService.reanchorNote(note, document); - expect(result.status, equals(AnchorStatus.exact)); - }); - - test('should find match by context when text changed', () async { - final result = await anchoringService.reanchorNote(modifiedNote, document); - expect(result.status, equals(AnchorStatus.contextMatch)); - }); - - test('should mark as orphan when no match found', () async { - final result = await anchoringService.reanchorNote(orphanNote, document); - expect(result.status, equals(AnchorStatus.orphan)); - }); -}); -``` - -#### 3. Performance Tests -```dart -group('Performance Tests', () { - test('should reanchor 100 notes within 5 seconds', () async { - final stopwatch = Stopwatch()..start(); - await anchoringService.reanchorMultipleNotes(notes); - stopwatch.stop(); - expect(stopwatch.elapsedMilliseconds, lessThan(5000)); - }); - - test('should not delay page load by more than 16ms', () async { - final range = VisibleCharRange(0, 1000); - final stopwatch = Stopwatch()..start(); - await notesService.loadNotesForVisibleRange(bookId, range); - stopwatch.stop(); - expect(stopwatch.elapsedMilliseconds, lessThan(16)); - }); -}); -``` - -### Integration Tests - -#### 1. End-to-End Note Creation -```dart -testWidgets('should create note from text selection', (tester) async { - await tester.pumpWidget(app); - - // Select text - await tester.longPress(find.text('טקסט לדוגמה')); - await tester.pumpAndSettle(); - - // Add note - await tester.tap(find.text('הוסף הערה')); - await tester.pumpAndSettle(); - - // Enter note content - await tester.enterText(find.byType(TextField), 'הערה חשובה'); - await tester.tap(find.text('שמור')); - await tester.pumpAndSettle(); - - // Verify note appears - expect(find.byType(NoteHighlight), findsOneWidget); -}); -``` - -#### 2. Migration Testing -```dart -testWidgets('should migrate existing bookmarks to notes', (tester) async { - // Setup old bookmark data - await setupLegacyBookmarks(); - - // Run migration - await migrationService.migrateBookmarksToNotes(); - - // Verify migration results - final notes = await notesRepository.getAllNotes(); - expect(notes.length, equals(expectedCount)); - expect(notes.every((n) => n.status != NoteStatus.orphan), isTrue); -}); -``` - -### Acceptance Tests - -#### 1. Accuracy Requirements -- 98% of notes remain "exact" after 5% line changes -- 100% of notes remain "exact" after whitespace-only changes -- Deleted text sections are properly marked as "orphan" - -#### 2. Performance Requirements -- Re-anchoring: ≤ 50ms per note average -- Page load delay: ≤ 16ms for notes loading -- Search response: ≤ 200ms for 1000+ notes - -#### 3. User Experience Requirements -- Note creation: ≤ 3 clicks from text selection -- Note editing: In-place editing without navigation -- Orphan resolution: Clear visual indicators and easy resolution flow - -## UI/UX Design - -### Visual Design Principles - -#### 1. Non-Intrusive Integration -- הערות יוצגו כהדגשה דקה שלא מפריעה לקריאה -- צבעים עדינים שמתאימים לערכת הנושא הקיימת -- אנימציות חלקות למעברים - -#### 2. Contextual Actions -- תפריט הקשר יורחב לכלול פעולות הערות -- כפתורי פעולה יופיעו רק כשרלוונטי -- מקשי קיצור לפעולות נפוצות - -#### 3. Status Indicators -```dart -enum NoteStatusIndicator { - exact, // ירוק - מיקום מדויק - shifted, // כתום - מוזז אך אותר - orphan, // אדום - נדרש אימות ידני - loading // אפור - בטעינה -} -``` - -### Component Specifications - -#### 1. Note Highlight Widget -```dart -class NoteHighlight extends StatelessWidget { - final Note note; - final Widget child; - final VoidCallback? onTap; - final VoidCallback? onLongPress; - - // Visual properties based on note status - Color get highlightColor => switch (note.status) { - NoteStatus.anchored => Colors.blue.withOpacity(0.2), - NoteStatus.shifted => Colors.orange.withOpacity(0.2), - NoteStatus.orphan => Colors.red.withOpacity(0.2), - }; -} -``` - -#### 2. Note Editor Dialog -```dart -class NoteEditorDialog extends StatefulWidget { - final Note? existingNote; - final String? selectedText; - final Function(Note) onSave; - final VoidCallback? onDelete; -} -``` - -#### 3. Notes Sidebar -```dart -class NotesSidebar extends StatefulWidget { - final String bookId; - final Function(Note) onNoteSelected; - final TextEditingController searchController; -} -``` - -#### 4. Orphan Notes Manager -```dart -class OrphanNotesManager extends StatefulWidget { - final List orphanNotes; - final Function(Note, AnchorCandidate) onResolveOrphan; -} -``` - -### User Interaction Flows - -#### 1. Creating a Note -```mermaid -sequenceDiagram - participant U as User - participant UI as UI - participant B as BLoC - participant S as Service - - U->>UI: Select text - UI->>UI: Show context menu - U->>UI: Click "Add Note" - UI->>UI: Show note editor - U->>UI: Enter note content - U->>UI: Click save - UI->>B: CreateNoteEvent - B->>S: createNote() - S->>S: Generate anchor - S->>S: Save to database - S-->>B: Note created - B-->>UI: NoteCreated state - UI->>UI: Show highlight -``` - -#### 2. Resolving Orphan Notes -```mermaid -sequenceDiagram - participant U as User - participant UI as Orphan Manager - participant B as BLoC - participant S as Anchoring Service - - U->>UI: Open orphan manager - UI->>B: LoadOrphansEvent - B->>S: getOrphanNotes() - S-->>B: List of orphans - B-->>UI: OrphansLoaded state - UI->>UI: Show orphan list - U->>UI: Select orphan - UI->>B: FindCandidatesEvent - B->>S: findAnchorCandidates() - S-->>B: List of candidates - B-->>UI: CandidatesFound state - UI->>UI: Show candidates - U->>UI: Select candidate - UI->>B: ResolveOrphanEvent - B->>S: resolveOrphan() - S-->>B: Orphan resolved - B-->>UI: OrphanResolved state -```## - Implementation Details - -### Text Normalization Algorithm - -```dart -class TextNormalizer { - static final Map _quoteMap = { - '\u201C': '"', '\u201D': '"', // " " - '\u201E': '"', '\u00AB': '"', '\u00BB': '"', // „ « » - '\u2018': "'", '\u2019': "'", // ' ' - '\u05F4': '"', '\u05F3': "'", // ״ ׳ (Hebrew) - }; - - static String normalize(String text) { - // 1. Replace multiple whitespace with single space - text = text.replaceAll(RegExp(r'\s+'), ' '); - - // 2. Remove directional marks - text = text.replaceAll(RegExp(r'[\u200E\u200F\u202A-\u202E]'), ''); - - // 3. Normalize punctuation - _quoteMap.forEach((from, to) { - text = text.replaceAll(from, to); - }); - - // 4. Handle nikud based on settings - if (shouldRemoveNikud()) { - text = removeNikud(text); - } - - // 5. Trim whitespace - return text.trim(); - } -} -``` - -### Hash Generation - -```dart -class HashGenerator { - static String generateTextHash(String normalizedText) { - final bytes = utf8.encode(normalizedText); - final digest = sha256.convert(bytes); - return digest.toString(); - } - - static int generateRollingHash(String text) { - const int base = 256; - const int mod = 1000000007; - - int hash = 0; - int pow = 1; - - for (int i = 0; i < text.length; i++) { - hash = (hash + (text.codeUnitAt(i) * pow)) % mod; - pow = (pow * base) % mod; - } - - return hash; - } -} - -class RollingHashWindow { - static const int base = 256; - static const int mod = 1000000007; - - int hash = 0; - int power = 1; - final int windowSize; - - RollingHashWindow(this.windowSize) { - // Pre-calculate base^(windowSize-1) mod mod - for (int i = 0; i < windowSize - 1; i++) { - power = (power * base) % mod; - } - } - - void init(String text) { - hash = 0; - int currentPow = 1; - for (int i = 0; i < text.length && i < windowSize; i++) { - hash = (hash + (text.codeUnitAt(i) * currentPow)) % mod; - currentPow = (currentPow * base) % mod; - } - } - - int slide(int outChar, int inChar) { - hash = (hash - (outChar * power) % mod + mod) % mod; - hash = (hash * base + inChar) % mod; - return hash; - } -} -``` - -### Fuzzy Matching Algorithm - -```dart -class FuzzyMatcher { - static double calculateLevenshteinSimilarity(String a, String b) { - final distance = levenshteinDistance(a, b); - final maxLength = math.max(a.length, b.length); - return 1.0 - (distance / maxLength); - } - - static double calculateJaccardSimilarity(String a, String b) { - final ngramsA = generateNGrams(a, 3); - final ngramsB = generateNGrams(b, 3); - - final intersection = ngramsA.toSet().intersection(ngramsB.toSet()); - final union = ngramsA.toSet().union(ngramsB.toSet()); - - return intersection.length / union.length; - } - - static double calculateCosineSimilarity(String a, String b, int n) { - Map freq(List grams) { - final m = {}; - for (final g in grams) m[g] = (m[g] ?? 0) + 1; - return m; - } - - final ga = generateNGrams(a, n); - final gb = generateNGrams(b, n); - final fa = freq(ga), fb = freq(gb); - final keys = {...fa.keys, ...fb.keys}; - - double dot = 0, na = 0, nb = 0; - for (final k in keys) { - final va = (fa[k] ?? 0).toDouble(); - final vb = (fb[k] ?? 0).toDouble(); - dot += va * vb; - na += va * va; - nb += vb * vb; - } - - return dot == 0 ? 0.0 : dot / (math.sqrt(na) * math.sqrt(nb)); - } - - static List generateNGrams(String text, int n) { - final ngrams = []; - for (int i = 0; i <= text.length - n; i++) { - ngrams.add(text.substring(i, i + n)); - } - return ngrams; - } -} -``` - -### Performance Optimizations - -#### 1. Lazy Loading -```dart -class NotesLoader { - final Map> _cache = {}; - - Future> loadNotesForCharRange(String bookId, int startChar, int endChar) async { - final cacheKey = '$bookId:$startChar:$endChar'; - - if (_cache.containsKey(cacheKey)) { - return _cache[cacheKey]!; - } - - // Load only notes visible in current character range - final notes = await _repository.getNotesForCharRange( - bookId, - startChar, - endChar - ); - - _cache[cacheKey] = notes; - return notes; - } - - Future> loadNotesForVisibleRange(String bookId, VisibleCharRange range) async { - return loadNotesForCharRange(bookId, range.start, range.end); - } -} - -class VisibleCharRange { - final int start; - final int end; - - const VisibleCharRange(this.start, this.end); -} -} -``` - -#### 2. Background Processing -```dart -class BackgroundProcessor { - static Future processReanchoring(List notes) async { - await compute(_reanchorNotes, notes); - } - - static List _reanchorNotes(List notes) { - // Heavy computation in isolate - return notes.map((note) => _reanchorSingleNote(note)).toList(); - } -} -``` - -#### 3. Index Optimization -```dart -class SearchIndex { - final Map> _textHashIndex = {}; - final Map> _contextIndex = {}; - final Map> _rollingHashIndex = {}; - - void buildIndex(CanonicalDocument document) { - // Build inverted indexes for fast lookup - _buildTextHashIndex(document); - _buildContextIndex(document); - _buildRollingHashIndex(document); - } - - List findByTextHash(String hash) { - return (_textHashIndex[hash] ?? const {}).toList(); - } - - List findByContextHash(String beforeHash, String afterHash) { - final beforePositions = _contextIndex[beforeHash] ?? const {}; - final afterPositions = _contextIndex[afterHash] ?? const {}; - - return beforePositions.intersection(afterPositions).toList(); - } - - List findByRollingHash(int hash) { - return (_rollingHashIndex[hash] ?? const {}).toList(); - } -} -``` - -## Migration Strategy - -### Phase 1: Infrastructure Setup -1. Create database schema -2. Implement core services (CanonicalTextService, AnchoringService) -3. Add basic UI components -4. Create migration utilities - -### Phase 2: Basic Functionality -1. Implement note creation and editing -2. Add text highlighting -3. Implement basic anchoring -4. Add notes sidebar - -### Phase 3: Advanced Features -1. Implement re-anchoring algorithm -2. Add fuzzy matching -3. Implement orphan notes management -4. Add search functionality - -### Phase 4: Import/Export and Polish -1. Implement export/import functionality -2. Add performance optimizations -3. Implement backup system -4. Add advanced UI features - -### Data Migration from Existing System - -```dart -class BookmarkMigrator { - Future migrateBookmarksToNotes() async { - final bookmarks = await _getExistingBookmarks(); - - for (final bookmark in bookmarks) { - try { - final note = await _convertBookmarkToNote(bookmark); - await _notesRepository.createNote(note); - } catch (e) { - _logger.logError('Failed to migrate bookmark: ${bookmark.id}', e); - } - } - } - - Future _convertBookmarkToNote(Bookmark bookmark) async { - // Convert bookmark to note with proper anchoring - final canonicalDoc = await _canonicalService - .createCanonicalDocument(bookmark.bookTitle); - - final anchor = _anchoringService.createAnchor( - bookmark.bookTitle, - canonicalDoc.canonicalText, - bookmark.charStart, - bookmark.charEnd, - ); - - return Note( - id: _generateId(), - bookId: bookmark.bookTitle, - docVersionId: canonicalDoc.versionId, - charStart: anchor.charStart, - charEnd: anchor.charEnd, - // ... other fields - ); - } -} -``` - -## Security Considerations - -### Data Protection -1. **Local Encryption**: הערות רגישות יוצפנו מקומית -2. **Access Control**: הרשאות גישה לפי משתמש -3. **Backup Security**: גיבויים מוצפנים -4. **Data Validation**: אימות נתונים בכל שכבה - -### Privacy -1. **User Consent**: בקשת הסכמה לשמירת נתונים -2. **Data Minimization**: שמירת מינימום נתונים נדרש -3. **Right to Delete**: יכולת מחיקת כל הנתונים -4. **Export Control**: שליטה מלאה על ייצוא נתונים - -## Monitoring and Analytics - -### Performance Metrics -```dart -class PerformanceMonitor { - static void trackAnchoringPerformance(Duration duration, bool success) { - _analytics.track('anchoring_performance', { - 'duration_ms': duration.inMilliseconds, - 'success': success, - }); - } - - static void trackSearchPerformance(String query, int resultCount, Duration duration) { - _analytics.track('search_performance', { - 'query_length': query.length, - 'result_count': resultCount, - 'duration_ms': duration.inMilliseconds, - }); - } -} -``` - -### Error Tracking -```dart -class ErrorTracker { - static void trackAnchoringFailure(Note note, AnchoringError error) { - _analytics.track('anchoring_failure', { - 'note_id': note.id, - 'book_id': note.bookId, - 'error_type': error.toString(), - }); - } -} -``` - -## Future Enhancements - -### Phase 2 Features -1. **Collaborative Notes**: שיתוף הערות בין משתמשים -2. **Note Categories**: קטגוריזציה של הערות -3. **Advanced Search**: חיפוש מתקדם עם פילטרים -4. **Note Templates**: תבניות להערות נפוצות - -### Phase 3 Features -1. **AI-Powered Suggestions**: הצעות אוטומטיות להערות -2. **Cross-Reference Detection**: זיהוי אוטומטי של הפניות -3. **Semantic Search**: חיפוש סמנטי בהערות -4. **Integration with External Tools**: אינטגרציה עם כלים חיצוניים - -### Technical Debt Considerations -1. **Database Optimization**: אופטימיזציה של שאילתות -2. **Memory Management**: ניהול זיכרון יעיל יותר -3. **Code Refactoring**: ארגון מחדש של הקוד -4. **Test Coverage**: הרחבת כיסוי הבדיקות## Confi -guration Constants - -### Anchoring Parameters -```dart -class AnchoringConstants { - // Context window size (characters before and after selected text) - static const int contextWindowSize = 40; - - // Maximum distance between prefix and suffix for context matching - static const int maxContextDistance = 300; - - // Similarity thresholds - static const double levenshteinThreshold = 0.18; // ≤ 18% of original length - static const double jaccardThreshold = 0.82; // ≥ 82% similarity - static const double cosineThreshold = 0.82; // ≥ 82% similarity - - // N-gram size for fuzzy matching - static const int ngramSize = 3; - - // Performance limits - static const int maxReanchoringTimeMs = 50; // per note - static const int maxPageLoadDelayMs = 16; // UI responsiveness - - // Rolling hash window size - static const int rollingHashWindowSize = 20; -} -``` - -### Database Configuration -```dart -class DatabaseConfig { - static const String databaseName = 'notes.db'; - static const int databaseVersion = 1; - static const String notesTable = 'notes'; - static const String canonicalDocsTable = 'canonical_documents'; - static const String notesFtsTable = 'notes_fts'; - - // Cache settings - static const int maxCacheSize = 10000; - static const Duration cacheExpiry = Duration(hours: 1); -} -``` - -## Security and Encryption - -### Key Management -```dart -class EncryptionManager { - static const String keyAlias = 'otzaria_notes_key'; - static const int keySize = 256; // AES-256 - - /// Generate or retrieve encryption key from secure storage - Future getOrCreateKey() async { - // Use platform-specific secure storage - // Android: Android Keystore - // iOS: Keychain Services - // Windows: DPAPI - // Linux: Secret Service API - } - - /// Encrypt note content with AES-GCM - Future encryptContent(String content, SecretKey key) async { - final algorithm = AesGcm.with256bits(); - final nonce = algorithm.newNonce(); - - final secretBox = await algorithm.encrypt( - utf8.encode(content), - secretKey: key, - nonce: nonce, - ); - - return EncryptedData( - ciphertext: secretBox.cipherText, - nonce: nonce, - mac: secretBox.mac.bytes, - ); - } - - /// Decrypt note content - Future decryptContent(EncryptedData data, SecretKey key) async { - final algorithm = AesGcm.with256bits(); - - final secretBox = SecretBox( - data.ciphertext, - nonce: data.nonce, - mac: Mac(data.mac), - ); - - final clearText = await algorithm.decrypt(secretBox, secretKey: key); - return utf8.decode(clearText); - } -} - -class EncryptedData { - final List ciphertext; - final List nonce; - final List mac; - - const EncryptedData({ - required this.ciphertext, - required this.nonce, - required this.mac, - }); - - /// Serialize to JSON for storage - Map toJson() => { - 'ciphertext': base64.encode(ciphertext), - 'nonce': base64.encode(nonce), - 'mac': base64.encode(mac), - 'version': 1, // For future key rotation - }; - - /// Deserialize from JSON - factory EncryptedData.fromJson(Map json) => EncryptedData( - ciphertext: base64.decode(json['ciphertext']), - nonce: base64.decode(json['nonce']), - mac: base64.decode(json['mac']), - ); -} -``` - -### Privacy Controls -```dart -class PrivacyManager { - /// Check if user has consented to data collection - Future hasUserConsent() async { - return Settings.getValue('notes_data_consent') ?? false; - } - - /// Request user consent for data collection - Future requestUserConsent() async { - // Show consent dialog - // Store user choice - // Return consent status - } - - /// Export all user data for GDPR compliance - Future exportAllUserData(String userId) async { - final notes = await _notesRepository.getNotesForUser(userId); - final exportData = { - 'user_id': userId, - 'export_date': DateTime.now().toIso8601String(), - 'notes': notes.map((n) => n.toJson()).toList(), - }; - - return jsonEncode(exportData); - } - - /// Delete all user data - Future deleteAllUserData(String userId) async { - await _notesRepository.deleteAllNotesForUser(userId); - await _canonicalService.clearCacheForUser(userId); - // Clear any other user-specific data - } -} -``` - -## UI Theme Integration - -### Color Scheme -```dart -class NotesTheme { - static Color getHighlightColor(BuildContext context, NoteStatus status) { - final colorScheme = Theme.of(context).colorScheme; - - return switch (status) { - NoteStatus.anchored => colorScheme.primary.withOpacity(0.2), - NoteStatus.shifted => colorScheme.warning.withOpacity(0.2), - NoteStatus.orphan => colorScheme.error.withOpacity(0.2), - }; - } - - static Color getStatusIndicatorColor(BuildContext context, NoteStatus status) { - final colorScheme = Theme.of(context).colorScheme; - - return switch (status) { - NoteStatus.anchored => colorScheme.primary, - NoteStatus.shifted => colorScheme.warning, - NoteStatus.orphan => colorScheme.error, - }; - } - - static IconData getStatusIcon(NoteStatus status) { - return switch (status) { - NoteStatus.anchored => Icons.check_circle, - NoteStatus.shifted => Icons.warning, - NoteStatus.orphan => Icons.error, - }; - } -} - -extension ColorSchemeExtension on ColorScheme { - Color get warning => const Color(0xFFFF9800); -} -``` - -### Keyboard Shortcuts -```dart -class NotesShortcuts { - static const Map shortcuts = { - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyN): - CreateNoteIntent(), - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyE): - EditNoteIntent(), - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyD): - DeleteNoteIntent(), - LogicalKeySet(LogicalKeyboardKey.f3): - FindNextNoteIntent(), - LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.f3): - FindPreviousNoteIntent(), - LogicalKeySet(LogicalKeyboardKey.escape): - CancelNoteActionIntent(), - - // Orphan manager shortcuts - LogicalKeySet(LogicalKeyboardKey.arrowUp): - SelectPreviousCandidateIntent(), - LogicalKeySet(LogicalKeyboardKey.arrowDown): - SelectNextCandidateIntent(), - LogicalKeySet(LogicalKeyboardKey.enter): - ConfirmCandidateIntent(), - }; -} - -// Intent classes -class CreateNoteIntent extends Intent {} -class EditNoteIntent extends Intent {} -class DeleteNoteIntent extends Intent {} -class FindNextNoteIntent extends Intent {} -class FindPreviousNoteIntent extends Intent {} -class CancelNoteActionIntent extends Intent {} -class SelectPreviousCandidateIntent extends Intent {} -class SelectNextCandidateIntent extends Intent {} -class ConfirmCandidateIntent extends Intent {} -``` - -## Ready for Development Checklist - -### Core Infrastructure ✅ -- [x] Database schema with proper indexes -- [x] Full-text search configuration -- [x] SQLite performance optimizations -- [x] Encryption and key management -- [x] Configuration constants defined - -### Data Models ✅ -- [x] Note model with all required fields -- [x] CanonicalDocument model with correct index types -- [x] AnchorCandidate model defined -- [x] Error handling types defined - -### Algorithms ✅ -- [x] Text normalization with safe punctuation handling -- [x] Both Jaccard and Cosine similarity implementations -- [x] Rolling hash with proper sliding window -- [x] Fuzzy matching with configurable thresholds - -### Performance ✅ -- [x] Character-range based loading instead of page-based -- [x] Proper caching strategy -- [x] Background processing for heavy operations -- [x] Search indexes for fast lookups - -### Security ✅ -- [x] AES-GCM encryption with proper key management -- [x] Privacy controls and GDPR compliance -- [x] Secure storage integration -- [x] Data validation and sanitization - -### UI/UX ✅ -- [x] Theme integration with dynamic colors -- [x] Status indicators and icons -- [x] Keyboard shortcuts defined -- [x] Accessibility considerations - -The design document is now complete and ready for implementation. All technical issues have been resolved and the system is properly architected for scalable, secure, and performant note management.## Fi -nal Implementation Checklist - -### ✅ Core Infrastructure Ready -- [x] Database schema with proper indexes and FTS -- [x] SQLite performance optimizations with all PRAGMA settings -- [x] Encryption and key management system -- [x] Configuration constants centralized in AnchoringConstants - -### ✅ Data Models Complete -- [x] Note model with all required fields -- [x] CanonicalDocument model with correct List index types -- [x] AnchorCandidate model with score and strategy -- [x] AnchorResult model for consistent return values -- [x] Error handling types defined - -### ✅ Algorithms Implemented -- [x] Text normalization with safe Unicode quote mapping -- [x] Both Jaccard and true Cosine similarity implementations -- [x] Rolling hash with proper sliding window capability -- [x] Fuzzy matching with configurable thresholds from constants - -### ✅ Performance Optimized -- [x] Character-range based loading with VisibleCharRange -- [x] SearchIndex with consistent Set types -- [x] Background processing for heavy operations -- [x] Proper caching strategy with expiry - -### ✅ Security Complete -- [x] AES-GCM encryption with versioned envelope format -- [x] Platform-specific secure key storage -- [x] Privacy controls and GDPR compliance -- [x] Data validation and sanitization - -### ✅ UI/UX Ready -- [x] Theme integration with dynamic colors -- [x] Status indicators with accessibility -- [x] Keyboard shortcuts including orphan manager navigation -- [x] Score display for anchor candidates - -### ✅ Testing Strategy -- [x] Unit tests for all algorithms -- [x] Integration tests with visible char range -- [x] Performance tests with realistic thresholds -- [x] Migration tests for existing bookmarks - -The design document is now complete, consistent, and ready for immediate development. All technical issues have been resolved, types are aligned, and the system is properly architected for scalable, secure, and performant note management with Hebrew text support. \ No newline at end of file diff --git a/.kiro/specs/personal-notes/requirements.md b/.kiro/specs/personal-notes/requirements.md deleted file mode 100644 index 978396c86..000000000 --- a/.kiro/specs/personal-notes/requirements.md +++ /dev/null @@ -1,231 +0,0 @@ -# Requirements Document - -## Introduction - -תכונת ההערות האישיות תאפשר למשתמשים להוסיף הערות אישיות לטקסטים בספרים השונים. ההערות יישמרו בצורה מדויקת ויוצגו במיקום הנכון גם כאשר מבנה הטקסט משתנה. התכונה תפתור את הבעיה הקיימת של קריאת ספרים בבלוקים שיכולה לגרום לאי-דיוק במיקום ההערות. - -הפתרון יתבסס על מעבר ממודל "בלוקים/שורות" למודל מסמך קנוני עם מערכת עיגון מתקדמת שכוללת שמירת הקשר טקסטואלי ו-fingerprints קריפטוגרפיים לזיהוי מדויק של מיקום ההערות. - -## Requirements - -### Requirement 1 - -**User Story:** כמשתמש, אני רוצה להוסיף הערה אישית לטקסט ספציפי בספר, כדי שאוכל לשמור מחשבות ותובנות אישיות. - -#### Acceptance Criteria - -1. WHEN המשתמש בוחר טקסט בספר THEN המערכת SHALL להציג אפשרות להוסיף הערה -2. WHEN המשתמש לוחץ על "הוסף הערה" THEN המערכת SHALL לפתוח חלון עריכת הערה -3. WHEN המשתמש כותב הערה ושומר THEN המערכת SHALL לשמור את ההערה עם מיקום מדויק -4. IF ההערה נשמרה בהצלחה THEN המערכת SHALL להציג סימון ויזואלי על הטקסט המוערה - -### Requirement 2 - -**User Story:** כמשתמש, אני רוצה לראות את ההערות שלי במיקום המדויק בטקסט, כדי שההקשר יישמר גם כאשר מבנה הספר משתנה. - -#### Acceptance Criteria - -1. WHEN המשתמש פותח ספר עם הערות קיימות THEN המערכת SHALL להציג את ההערות במיקום הנכון -2. WHEN מבנה הטקסט משתנה (הוספה/הסרה של שורות) THEN המערכת SHALL לשמור על מיקום ההערות היחסי -3. WHEN המשתמש מעביר עכבר על טקסט מוערה THEN המערכת SHALL להציג את תוכן ההערה -4. IF ההערה לא יכולה להיות ממוקמת במדויק THEN המערכת SHALL להציג התראה למשתמש - -### Requirement 3 - -**User Story:** כמשתמש, אני רוצה לערוך ולמחוק הערות קיימות, כדי שאוכל לעדכן ולנהל את ההערות שלי. - -#### Acceptance Criteria - -1. WHEN המשתמש לוחץ על הערה קיימת THEN המערכת SHALL להציג אפשרויות עריכה ומחיקה -2. WHEN המשתמש בוחר "ערוך הערה" THEN המערכת SHALL לפתוח את חלון העריכה עם התוכן הקיים -3. WHEN המשתמש בוחר "מחק הערה" THEN המערכת SHALL לבקש אישור ולמחוק את ההערה -4. IF ההערה נמחקה THEN המערכת SHALL להסיר את הסימון הויזואלי מהטקסט - -### Requirement 4 - -**User Story:** כמשתמש, אני רוצה שההערות שלי יישמרו בצורה עמידה ומדויקת, כדי שלא אאבד אותן בעת עדכונים או שינויים בתוכנה. - -#### Acceptance Criteria - -1. WHEN המשתמש שומר הערה THEN המערכת SHALL לשמור אותה עם מזהה ייחודי ומיקום מדויק -2. WHEN הספר נטען מחדש THEN המערכת SHALL לטעון את כל ההערות הרלוונטיות -3. WHEN התוכנה מתעדכנת THEN המערכת SHALL לשמור על תאימות עם הערות קיימות -4. IF קובץ ההערות פגום THEN המערכת SHALL לנסות לשחזר הערות או להציג הודעת שגיאה מתאימה - -### Requirement 5 - -**User Story:** כמשתמש, אני רוצה לחפש בהערות שלי, כדי שאוכל למצוא במהירות הערות ספציפיות. - -#### Acceptance Criteria - -1. WHEN המשתמש פותח את תפריט ההערות THEN המערכת SHALL להציג רשימה של כל ההערות -2. WHEN המשתמש מקליד בשדה החיפוש THEN המערכת SHALL לסנן הערות לפי תוכן הטקסט -3. WHEN המשתמש לוחץ על הערה ברשימה THEN המערכת SHALL לנווט למיקום ההערה בספר -4. IF אין הערות התואמות לחיפוש THEN המערכת SHALL להציג הודעה מתאימה - -### Requirement 6 - -**User Story:** כמשתמש, אני רוצה לייצא ולייבא הערות, כדי שאוכל לגבות אותן ולשתף אותן בין מכשירים. - -#### Acceptance Criteria - -1. WHEN המשתמש בוחר "ייצא הערות" THEN המערכת SHALL ליצור קובץ עם כל ההערות -2. WHEN המשתמש בוחר "ייבא הערות" THEN המערכת SHALL לטעון הערות מקובץ חיצוני -3. WHEN מתבצע ייבוא THEN המערכת SHALL לבדוק תאימות ולמזג עם הערות קיימות -4. IF יש התנגשות בין הערות THEN המערכת SHALL לבקש מהמשתמש כיצד לפתור את ההתנגשות - -### Requirement 7 - -**User Story:** כמפתח, אני רוצה שהמערכת תשמור הערות עם מערכת עיגון מתקדמת, כדי שההערות יישארו מדויקות גם כאשר מבנה הטקסט משתנה. - -#### Acceptance Criteria - -1. WHEN הערה נשמרת THEN המערכת SHALL לשמור מזהה ספר, גרסת מסמך, אופסטים של תווים, וטקסט מנורמל -2. WHEN הערה נשמרת THEN המערכת SHALL לשמור חלון הקשר לפני ואחרי (prefix/suffix) בגודל N תווים -3. WHEN הערה נשמרת THEN המערכת SHALL לחשב ולשמור fingerprints קריפטוגרפיים (SHA-256) של הטקסט המסומן והקשר -4. WHEN הערה נשמרת THEN המערכת SHALL לשמור rolling hash (Rabin-Karp) לחלון הקשר להאצת חיפוש - -### Requirement 8 - -**User Story:** כמפתח, אני רוצה שהמערכת תיישם אלגוריתם re-anchoring מתקדם, כדי לאתר הערות גם לאחר שינויים במסמך. - -#### Acceptance Criteria - -1. WHEN מסמך נטען עם גרסה זהה THEN המערכת SHALL למקם הערות לפי אופסטים (O(1)) -2. WHEN גרסת מסמך שונה THEN המערכת SHALL לחפש text_hash מדויק במסמך הקנוני -3. IF חיפוש מדויק נכשל THEN המערכת SHALL לחפש הקשר (prefix/suffix) במרחק ≤ K תווים -4. IF חיפוש הקשר נכשל THEN המערכת SHALL להשתמש בחיפוש דמיון מטושטש (Levenshtein/Cosine) -5. IF נמצאו מספר מועמדים THEN המערכת SHALL לבקש הכרעת משתמש -6. IF לא נמצא מיקום מתאים THEN המערכת SHALL לסמן הערה כ"יתומה" - -### Requirement 9 - -**User Story:** כמפתח, אני רוצה שהמערכת תשתמש במסמך קנוני אחיד, כדי להבטיח עקביות במיקום ההערות. - -#### Acceptance Criteria - -1. WHEN ספר נטען THEN המערכת SHALL ליצור ייצוג מסמך קנוני רציף -2. WHEN מסמך קנוני נוצר THEN המערכת SHALL לחשב גרסת מסמך (checksum) -3. WHEN טקסט מנורמל THEN המערכת SHALL להחליף רווחים מרובים ולאחד סימני פיסוק -4. IF קיימת היררכיה פנימית THEN המערכת SHALL לשמור נתיב לוגי (פרקים/פסקאות) - -### Requirement 10 - -**User Story:** כמשתמש, אני רוצה לראות סטטוס עיגון ההערות, כדי לדעת עד כמה המיקום מדויק. - -#### Acceptance Criteria - -1. WHEN הערה מוצגת THEN המערכת SHALL להציג סטטוס עיגון: "מדויק", "מוזז אך אותר", או "נדרש אימות ידני" -2. WHEN יש הערות יתומות THEN המערכת SHALL להציג מסך "הערות יתומות" עם אשף התאמה -3. WHEN מוצגים מועמדי התאמה THEN המערכת SHALL להציג 1-3 מועמדים קרובים עם ציון דמיון -4. IF משתמש בוחר מועמד THEN המערכת SHALL לעדכן את העיגון ולסמן כ"מדויק" - -### Requirement 11 - -**User Story:** כמפתח, אני רוצה שהמערכת תהיה יעילה בביצועים, כדי שהוספת הערות לא תשפיע על חוויית המשתמש. - -#### Acceptance Criteria - -1. WHEN מתבצע עיגון/רה-עיגון THEN המערכת SHALL להשלים את התהליך ב≤ 50ms להערה בממוצע -2. WHEN עמוד נטען עם הערות THEN המערכת SHALL לא לעכב טעינה > 16ms (ביצוע ברקע) -3. WHEN נשמר אינדקס הקשר THEN המערכת SHALL להשתמש בדחיסה (n-grams בגודל 3-5) -4. IF יש מעל 1000 הערות בספר THEN המערכת SHALL להשתמש באינדקס מהיר (rolling-hash) - -### Requirement 12 - -**User Story:** כמשתמש, אני רוצה שההערות שלי יהיו מוגנות ופרטיות, כדי שרק אני אוכל לגשת אליהן. - -#### Acceptance Criteria - -1. WHEN הערה נשמרת THEN המערכת SHALL להצפין את תוכן ההערה מקומית -2. WHEN הערות מיוצאות THEN המערכת SHALL לאפשר הצפנה בפורמט AES-GCM במפתח משתמש -3. WHEN הערות משותפות THEN המערכת SHALL לנהל הרשאות גישה לפי משתמש -4. IF קובץ הערות פגום THEN המערכת SHALL לבדוק שלמות ולנסות שחזור - -### Requirement 13 - -**User Story:** כמפתח, אני רוצה שהמערכת תעבור מהמודל הקיים בצורה חלקה, כדי שלא יאבדו הערות קיימות. - -#### Acceptance Criteria - -1. WHEN מתבצעת מיגרציה THEN המערכת SHALL לבנות מסמך קנוני מכל ספר קיים -2. WHEN הערות קיימות מומרות THEN המערכת SHALL להפיק hash-ים וחלונות הקשר -3. WHEN מיגרציה מושלמת THEN המערכת SHALL להריץ re-anchoring ראשונית -4. IF יש בעיות במיגרציה THEN המערכת SHALL לסמן חריגות ולאפשר תיקון ידני - -### Requirement 14 - -**User Story:** כמפתח, אני רוצה שהמערכת תעמוד בבדיקות קבלה מחמירות, כדי להבטיח איכות ואמינות. - -#### Acceptance Criteria - -1. WHEN נוספות 100 הערות ומשתנות 5% שורות THEN המערכת SHALL לשמור ≥ 98% הערות כ"מדויק" -2. WHEN משתנים רק ריווח ושבירת שורות THEN המערכת SHALL לשמור 100% הערות כ"מדויק" -3. WHEN נמחק קטע מסומן לחלוטין THEN המערכת SHALL לסמן הערה כ"יתומה" -4. WHEN מתבצע ייבוא/ייצוא THEN המערכת SHALL לשמור זהות מספר הערות, תוכן ומצב עיגון## T -echnical Specifications - -### Default Values and Constants - -- **N (חלון הקשר):** 40 תווים לפני ואחרי הטקסט המסומן -- **K (מרחק מקסימלי בין prefix/suffix):** 300 תווים -- **ספי דמיון:** - - Levenshtein: ≤ 0.18 מהאורך המקורי - - Cosine n-grams: ≥ 0.82 -- **גודל n-grams לאינדקס:** 3-5 תווים -- **מגבלת זמן re-anchoring:** 50ms להערה בממוצע -- **מגבלת עיכוב טעינת עמוד:** 16ms - -### Text Normalization Standard - -הנירמול יכלול: -1. החלפת רווחים מרובים ברווח יחיד -2. הסרת סימני כיווניות לא מודפסים (LTR/RTL marks) -3. יוניפיקציה של גרשיים ומירכאות לסוג אחיד -4. שמירה על ניקוד עברי (אופציונלי לפי הגדרות משתמש) -5. trim של רווחים בתחילת וסוף הטקסט - -### Data Schema (SQLite/Database) - -```sql -CREATE TABLE notes ( - note_id TEXT PRIMARY KEY, -- UUID - book_id TEXT NOT NULL, - doc_version_id TEXT NOT NULL, - logical_path TEXT, -- JSON array: ["chapter:3", "para:12"] - char_start INTEGER NOT NULL, - char_end INTEGER NOT NULL, - selected_text_normalized TEXT NOT NULL, - text_hash TEXT NOT NULL, -- SHA-256 - ctx_before TEXT NOT NULL, - ctx_after TEXT NOT NULL, - ctx_before_hash TEXT NOT NULL, -- SHA-256 - ctx_after_hash TEXT NOT NULL, -- SHA-256 - rolling_before INTEGER NOT NULL, -- Rabin-Karp hash - rolling_after INTEGER NOT NULL, -- Rabin-Karp hash - status TEXT NOT NULL CHECK (status IN ('anchored', 'shifted', 'orphan')), - content_markdown TEXT NOT NULL, - author_user_id TEXT NOT NULL, - privacy TEXT NOT NULL CHECK (privacy IN ('private', 'shared')), - tags TEXT, -- JSON array - created_at TEXT NOT NULL, -- ISO8601 - updated_at TEXT NOT NULL -- ISO8601 -); - -CREATE INDEX idx_notes_book_id ON notes(book_id); -CREATE INDEX idx_notes_doc_version ON notes(doc_version_id); -CREATE INDEX idx_notes_text_hash ON notes(text_hash); -CREATE INDEX idx_notes_ctx_hashes ON notes(ctx_before_hash, ctx_after_hash); -CREATE INDEX idx_notes_author ON notes(author_user_id); -``` - -### API Endpoints Structure - -- `POST /api/notes` - יצירת הערה חדשה -- `GET /api/notes?book_id={id}` - שליפת הערות לספר -- `PATCH /api/notes/{id}` - עדכון תוכן הערה -- `DELETE /api/notes/{id}` - מחיקה רכה של הערה -- `POST /api/notes/reanchor` - הפעלת re-anchoring ידני -- `GET /api/notes/orphans` - שליפת הערות יתומות -- `POST /api/notes/export` - ייצוא הערות -- `POST /api/notes/import` - ייבוא הערות \ No newline at end of file diff --git a/.kiro/specs/personal-notes/tasks.md b/.kiro/specs/personal-notes/tasks.md deleted file mode 100644 index c32ef62b4..000000000 --- a/.kiro/specs/personal-notes/tasks.md +++ /dev/null @@ -1,506 +0,0 @@ -# Implementation Plan - Personal Notes System - -## Overview - -תכנית יישום מדורגת למערכת ההערות האישיות, המבוססת על הדרישות והעיצוב שהוגדרו. התכנית מחולקת לשלבים עם משימות קונקרטיות שניתן לבצע בצורה מדורגת. - -## Phase 1: Core Infrastructure Setup - -### 1.1 Database Schema and Configuration -- יצירת סכמת מסד הנתונים SQLite עם טבלאות notes ו-canonical_documents -- הוספת אינדקסים לביצועים ו-FTS לחיפוש עברי -- הגדרת PRAGMA optimizations (WAL, foreign_keys, cache_size) -- יצירת triggers לסנכרון FTS table -- _Requirements: 4.1, 7.1, 11.3, 14.4_ - -### 1.2 Core Data Models -- יצירת Note model עם כל השדות הנדרשים (id, bookId, anchoring data, content) -- יצירת CanonicalDocument model עם indexes מסוג Map> -- יצירת AnchorCandidate model עם score ו-strategy -- יצירת AnchorResult model לתוצאות re-anchoring עקביות -- יצירת enum types: NoteStatus, NotePrivacy, AnchoringError -- _Requirements: 7.1, 8.1, 10.1_ - -### 1.3 Configuration Constants -- יצירת AnchoringConstants class עם כל הקבועים (N=40, K=300, ספי דמיון) -- יצירת DatabaseConfig class עם הגדרות מסד נתונים -- יצירת NotesTheme class לאינטגרציה עם Theme system -- _Requirements: 8.3, 11.1, 11.3_ - -## Phase 2: Text Processing and Anchoring Core - -### 2.1 Text Normalization Service -- יצירת TextNormalizer class עם normalize() method -- מימוש מפת Unicode בטוחה לסימני פיסוק (_quoteMap) -- הוספת תמיכה בהסרת/שמירת ניקוד לפי הגדרות משתמש -- טיפול במקרי קצה: RTL marks, Hebrew quotes (׳/״), ZWJ/ZWNJ -- יצירת golden tests עם corpus RTL/ניקוד לוודא offset stability -- _Requirements: 9.3, 13.2_ - -### 2.2 Hash Generation Service -- יצירת HashGenerator class עם generateTextHash() ו-generateRollingHash() -- מימוש RollingHashWindow class עם sliding window אמיתי -- יצירת unit tests לוודא hash consistency -- _Requirements: 7.3, 7.4, 8.2_ - -### 2.3 Canonical Text Service -- יצירת CanonicalTextService class -- מימוש createCanonicalDocument() method שיוצר מסמך קנוני מטקסט ספר -- מימוש calculateDocumentVersion() method עם checksum -- מימוש extractContextWindow() method -- אינטגרציה עם FileSystemData הקיים לקריאת טקסטי ספרים -- _Requirements: 9.1, 9.2, 13.1_ - -### 2.4 Fuzzy Matching Algorithms -- יצירת FuzzyMatcher class -- מימוש calculateLevenshteinSimilarity() method -- מימוש calculateJaccardSimilarity() method (intersection/union) -- מימוש calculateCosineSimilarity() method אמיתי עם תדירות n-grams -- מימוש generateNGrams() helper method -- יצירת unit tests עם ספי דמיון מהקבועים -- _Requirements: 8.4, 14.1_ - -## Phase 3: Anchoring and Re-anchoring System - -### 3.1 Search Index Service -- יצירת SearchIndex class עם Map> indexes פנימיים -- מימוש buildIndex() method לבניית אינדקסים מהירים -- מימוש findByTextHash(), findByContextHash(), findByRollingHash() methods -- החזרת List offsets (לא ערך בודד) מכל find method -- אופטימיזציה לביצועים עם pre-computed indexes -- _Requirements: 8.2, 11.4_ - -### 3.2 Anchoring Service Core -- יצירת AnchoringService class -- מימוש createAnchor() method ליצירת עוגן חדש להערה -- מימוש findExactMatch() method לחיפוש text_hash מדויק -- מימוש findByContext() method לחיפוש prefix/suffix במרחק K -- מימוש findFuzzyMatch() method עם Levenshtein ו-Cosine -- _Requirements: 8.1, 8.2, 8.3, 8.4_ - -### 3.3 Re-anchoring Algorithm -- מימוש reanchorNote() method עם אלגוריתם מדורג: - 1. בדיקת גרסה זהה → אופסטים (O(1)) - 2. חיפוש text_hash מדויק - 3. חיפוש הקשר במרחק ≤ K תווים - 4. חיפוש דמיון מטושטש - 5. מועמדים מרובים → בדיקת score difference (Δ≤0.03 → Orphan Manager) - 6. כישלון → סימון כ-orphan -- מימוש batch re-anchoring עם transaction boundaries -- הוספת performance target: ≤50ms per note average -- יצירת unit tests לכל שלב באלגוריתם -- _Requirements: 8.1-8.6, 14.1-14.3_ - -## Phase 4: Data Layer and Repository - -### 4.1 Notes Data Provider -- יצירת NotesDataProvider class לגישה ישירה למסד נתונים -- מימוש CRUD operations: create, read, update, delete עם transaction boundaries -- מימוש getNotesForCharRange() לטעינה יעילה לפי VisibleCharRange -- מימוש searchNotes() עם FTS + n-grams normalization לעברית -- הוספת transaction management: BEGIN IMMEDIATE...COMMIT עם busy_timeout -- שמירת normalization config string עם כל הערה: "norm=v1;nikud=skip;quotes=ascii;unicode=NFKC" -- הגדרת limits: max note size (32KB), max notes per book (5,000) -- _Requirements: 1.3, 3.1, 3.3, 5.2_ - -### 4.2 Notes Repository -- יצירת NotesRepository class כשכבת business logic -- מימוש createNote() method עם יצירת anchor אוטומטי -- מימוש updateNote() ו-deleteNote() methods -- מימוש getNotesForBook() ו-searchNotes() methods -- אינטגרציה עם AnchoringService לre-anchoring אוטומטי -- _Requirements: 1.1-1.4, 2.1, 3.1-3.4, 5.1-5.4_ - -### 4.3 Background Processing Service -- יצירת BackgroundProcessor class לעבודות כבדות -- מימוש processReanchoring() method ב-isolate נפרד -- מימוש batch processing עם requestId/epoch לביטול תשובות ישנות -- הוספת progress reporting למשתמש -- הוספת stale work detection (race-proof) -- _Requirements: 11.1, 11.2_ - -## Phase 5: State Management (BLoC) - -### 5.1 Notes Events -- יצירת NotesEvent base class -- יצירת events: CreateNoteEvent, UpdateNoteEvent, DeleteNoteEvent -- יצירת LoadNotesEvent, SearchNotesEvent, ReanchorNotesEvent -- יצירת ResolveOrphanEvent, FindCandidatesEvent -- _Requirements: 1.1-1.4, 3.1-3.4, 5.1-5.4_ - -### 5.2 Notes States -- יצירת NotesState base class -- יצירת states: NotesInitial, NotesLoading, NotesLoaded, NotesError -- יצירת OrphansLoaded, CandidatesFound, NoteCreated states -- הוספת immutable state properties עם copyWith methods -- _Requirements: 2.1-2.4, 10.1-10.4_ - -### 5.3 Notes BLoC -- יצירת NotesBloc class עם event handling -- מימוש _onCreateNote, _onUpdateNote, _onDeleteNote handlers -- מימוש _onLoadNotes, _onSearchNotes handlers -- מימוש _onReanchorNotes, _onResolveOrphan handlers -- אינטגרציה עם NotesRepository ו-BackgroundProcessor -- הוספת error handling ו-loading states -- _Requirements: כל הדרישות הפונקציונליות_ - -## Phase 6: UI Components - -### 6.1 Note Highlight Widget -- יצירת NoteHighlight widget לסימון טקסט מוערה -- מימוש dynamic colors לפי NoteStatus (anchored/shifted/orphan) -- הוספת hover effects ו-tap handling -- אינטגרציה עם Theme system לנגישות -- _Requirements: 1.4, 2.3, 10.1_ - -### 6.2 Note Editor Dialog -- יצירת NoteEditorDialog widget ליצירה ועריכה -- מימוש markdown editor עם preview -- הוספת tags input ו-privacy controls -- מימוש validation ו-error display -- הוספת keyboard shortcuts (Ctrl+S לשמירה) -- _Requirements: 1.2, 3.2, 12.3_ -כן! -### 6.3 Context Menu Integration -- הרחבת context menu הקיים ב-SimpleBookView -- הוספת "הוסף הערה" option לטקסט נבחר -- הוספת "ערוך הערה" ו-"מחק הערה" להערות קיימות -- מימוש keyboard shortcuts (Ctrl+N, Ctrl+E, Ctrl+D) -- _Requirements: 1.1, 3.1, 3.3_ - -### 6.4 Notes Sidebar -- יצירת NotesSidebar widget לרשימת הערות -- מימוש search functionality עם real-time filtering -- הוספת sorting options (date, status, relevance) -- מימוש click-to-navigate לmיקום הערה בטקסט -- הוספת status indicators עם icons וצבעים -- _Requirements: 5.1-5.4, 10.1-10.4_ - -## Phase 7: Advanced Features - -### 7.1 Orphan Notes Manager -- יצירת OrphanNotesManager widget -- מימוש candidate selection עם score display -- הוספת keyboard navigation (↑/↓/Enter/Esc) -- מימוש preview של מיקום מוצע -- הוספת bulk resolution options -- _Requirements: 8.5, 8.6, 10.2-10.4_ - -### 7.2 Performance Optimizations -- מימוש NotesLoader עם VisibleCharRange-based caching -- הוספת lazy loading לhערות מחוץ לviewport -- מימוש viewport tracking לטעינה דינמית -- אופטימיזציה של re-anchoring לbackground isolate -- הוספת performance telemetry: anchored_exact, anchored_shifted, orphan_rate, avg_reanchor_ms -- מימוש kill-switch: notes.enabled=false config flag -- הבטחת <16ms per frame rendering עם 1000+ notes -- _Requirements: 11.1, 11.2, 11.4_ - -### 7.3 Import/Export Functionality -- יצירת ImportExportService class -- מימוש exportNotes() method עם JSON/JSONL format -- מימוש importNotes() method עם conflict resolution -- הוספת encryption options לexport (AES-GCM) -- מימוש progress tracking לoperations גדולות -- _Requirements: 6.1-6.4, 12.2_ - -## Phase 8: Security and Privacy - -### 8.1 Encryption System -- יצירת EncryptionManager class -- מימוש platform-specific key storage (Android Keystore/iOS Keychain/Windows DPAPI) -- מימוש AES-GCM encryption עם versioned envelope format -- שמירת unique nonce per note + authentication tag -- הוספת key rotation capabilities עם version tracking -- יצירת unit tests לencryption/decryption determinism -- _Requirements: 12.1, 12.2, 12.4_ - -### 8.2 Privacy Controls -- יצירת PrivacyManager class -- מימוש user consent management -- מימוש exportAllUserData() לGDPR compliance -- מימוש deleteAllUserData() method -- הוספת privacy settings UI -- _Requirements: 12.3, 12.4_ - -## Phase 9: Migration and Integration - -### 9.1 Bookmark Migration -- יצירת BookmarkMigrator class -- מימוש migrateBookmarksToNotes() method -- הוספת progress tracking ו-error handling -- מימוש rollback capabilities במקרה של כישלון -- יצירת migration tests -- _Requirements: 13.1-13.4_ - -### 9.2 TextBookBloc Integration -- הרחבת TextBookBloc לכלול notes state -- הוספת notes loading לTextBookLoaded state -- מימוש notes filtering ו-highlighting בSimpleBookView -- אינטגרציה עם existing scroll controllers -- _Requirements: 2.1, 2.2_ - -### 9.3 FileSystemData Extension -- הרחבת FileSystemData לתמוך במסמכים קנוניים -- הוספת caching למסמכים קנוניים -- מימוש version tracking לספרים -- אופטימיזציה לביצועים עם background processing -- _Requirements: 9.1, 9.2, 13.1_ - -## Phase 10: Testing and Quality Assurance - -### 10.1 Unit Tests -- יצירת tests לכל השירותים (TextNormalizer, HashGenerator, etc.) -- יצירת tests לאלגוריתמי fuzzy matching -- יצירת tests לre-anchoring algorithm עם test cases מגוונים -- יצירת tests לencryption/decryption -- השגת 90%+ code coverage -- _Requirements: 14.1-14.4_ - -### 10.2 Integration Tests -- יצירת end-to-end tests ליצירת הערות מselection -- יצירת tests למיגרציה מbookmarks -- יצירת tests לimport/export functionality -- יצירת tests לorphan resolution flow -- _Requirements: כל הדרישות הפונקציונליות_ - -### 10.3 Performance Tests -- יצירת tests לre-anchoring performance (≤50ms per note) -- יצירת tests לviewport load delay (≤16ms per frame) -- יצירת tests לsearch performance (≤200ms) -- יצירת stress tests עם 1000+ הערות + fast scrolling -- יצירת determinism tests: same platform/version → same hash -- יצירת back-pressure tests: rapid scrolling with lazy loading -- _Requirements: 11.1, 11.2, 14.1-14.4_ - -### 10.4 Acceptance Tests -- יצירת automated tests לaccuracy requirements (98% exact after 5% changes) -- יצירת tests לwhitespace-only changes (100% exact) -- יצירת tests לdeleted text handling (proper orphan marking) -- יצירת tests לimport/export round-trip integrity -- יצירת tests לnormalization snapshot consistency -- יצירת tests לcandidate ambiguity handling (score difference <0.03) -- _Requirements: 14.1-14.4_ - -## Phase 11: Documentation and Polish - -### 11.1 Code Documentation -- הוספת comprehensive dartdoc comments לכל הpublic APIs -- יצירת architecture documentation -- יצירת API reference documentation -- הוספת code examples ו-usage patterns -- _Requirements: כללי_ - -### 11.2 User Documentation -- יצירת user guide להערות אישיות -- יצירת troubleshooting guide לorphan notes -- יצירת privacy and security guide -- יצירת import/export instructions -- _Requirements: כללי_ - -### 11.3 Final Polish -- code review ו-refactoring -- performance profiling ו-optimization -- accessibility testing ו-improvements -- UI/UX polish ו-animations -- final testing ו-bug fixes -- _Requirements: כללי_## Vert -ical Slice for V1 (Minimal Viable Product) - -### Week 1: Core Foundation -- **Phase 1.1-1.3**: Database schema + core models + constants -- **Phase 2.1-2.3**: Text normalization + hash generation + canonical text service -- **Phase 3.2**: Basic anchoring (exact match + context only, no fuzzy yet) - -### Week 2: Basic Functionality -- **Phase 4.1-4.2**: Repository + data provider (minimal CRUD) -- **Phase 5.1-5.3**: BLoC events/states for Create/Load/Reanchor -- **Phase 6.1-6.2**: Note highlight widget + basic editor dialog - -### Week 3: Integration & Testing -- **Phase 6.3**: Context menu integration ("Add Note" only) -- **Phase 9.2**: Basic TextBookBloc integration -- **Phase 10.1**: Core unit tests (20 test cases) - -### V1 Success Criteria -- ✅ Create notes from text selection -- ✅ Display notes with highlighting -- ✅ Basic re-anchoring on book load (exact + context) -- ✅ Handle whitespace changes (100% accuracy) -- ✅ Handle small text changes (basic orphan detection) -- ✅ Performance: <16ms rendering, <50ms re-anchoring - -### V1 Limitations (Acceptable) -- No fuzzy matching (orphan instead) -- No import/export -- No encryption -- No advanced UI (sidebar, orphan manager) -- No search functionality - -## Technical Debt Prevention - -### Code Quality Gates -- All public APIs must have dartdoc comments -- All services must have corresponding unit tests -- All database operations must use transactions -- All async operations must have proper error handling -- All UI components must support theme integration - -### Performance Gates -- Re-anchoring batch operations must complete in <5 seconds for 100 notes -- UI rendering must maintain 60fps during scrolling with notes -- Memory usage must not exceed 50MB additional for 1000 notes -- Database queries must use proper indexes (no table scans) - -### Security Gates -- All user input must be validated and sanitized -- All sensitive data must be encrypted at rest -- All database operations must prevent SQL injection -- All file operations must validate paths and permissions - -## Configuration Management - -### Feature Flags -```dart -class NotesConfig { - static const bool enabled = true; // Kill switch - static const bool highlightEnabled = true; // Emergency disable highlights - static const bool fuzzyMatchingEnabled = false; // V2 feature - static const bool encryptionEnabled = false; // V2 feature - static const bool importExportEnabled = false; // V2 feature - static const int maxNotesPerBook = 5000; // Resource limit - static const int maxNoteSize = 32768; // 32KB limit - static const int reanchoringTimeoutMs = 50; // Performance limit - static const int maxReanchoringBatchSize = 100; // Emergency batch limit -} -``` - -### Environment-Specific Settings -```dart -class NotesEnvironment { - static const bool debugMode = kDebugMode; - static const bool telemetryEnabled = !kDebugMode; - static const bool performanceLogging = kDebugMode; - static const String databasePath = kDebugMode ? 'notes_debug.db' : 'notes.db'; -} -``` - -## Monitoring and Telemetry - -### Key Metrics to Track -- **Anchoring Success Rate**: anchored_exact / total_notes -- **Performance Metrics**: avg_reanchor_ms, max_reanchor_ms, p95_reanchor_ms -- **User Engagement**: notes_created_per_day, notes_edited_per_day -- **Error Rates**: orphan_rate, reanchoring_failures, database_errors -- **Resource Usage**: memory_usage_mb, database_size_mb, cache_hit_rate - -### Telemetry Implementation -```dart -class NotesTelemetry { - static void trackAnchoringResult(String requestId, NoteStatus status, Duration duration, String strategy) { - if (!NotesEnvironment.telemetryEnabled) return; - - // NEVER log note content or context windows - _analytics.track('anchoring_result', { - 'request_id': requestId, - 'status': status.toString(), - 'strategy': strategy, - 'duration_ms': duration.inMilliseconds, - 'timestamp': DateTime.now().toIso8601String(), - }); - } - - static void trackBatchReanchoring(String requestId, int noteCount, int successCount, Duration totalDuration) { - if (!NotesEnvironment.telemetryEnabled) return; - - _analytics.track('batch_reanchoring', { - 'request_id': requestId, - 'note_count': noteCount, - 'success_count': successCount, - 'success_rate': successCount / noteCount, - 'avg_duration_ms': totalDuration.inMilliseconds / noteCount, - 'total_duration_ms': totalDuration.inMilliseconds, - }); - } - - static void trackPerformanceMetric(String operation, Duration duration) { - if (!NotesEnvironment.performanceLogging) return; - - _logger.info('Performance: $operation took ${duration.inMilliseconds}ms'); - } -} -``` - -## Ready for Development Checklist - -### ✅ Architecture -- [x] High-level architecture defined with clear component boundaries -- [x] Integration points with existing system identified -- [x] Data flow and state management patterns established -- [x] Error handling and recovery strategies defined - -### ✅ Technical Specifications -- [x] Database schema with proper indexes and constraints -- [x] Data models with all required fields and relationships -- [x] API contracts between layers clearly defined -- [x] Performance requirements and limits specified - -### ✅ Implementation Plan -- [x] Tasks broken down into manageable, testable units -- [x] Dependencies between tasks clearly identified -- [x] Acceptance criteria linked to original requirements -- [x] Vertical slice defined for rapid validation - -### ✅ Quality Assurance -- [x] Testing strategy covering unit, integration, and acceptance tests -- [x] Performance benchmarks and monitoring defined -- [x] Security considerations and privacy controls specified -- [x] Code quality gates and technical debt prevention measures - -### ✅ Risk Mitigation -- [x] Feature flags for safe rollout and rollback -- [x] Configuration management for different environments -- [x] Telemetry and monitoring for production insights -- [x] Migration strategy from existing bookmarks system - -The Personal Notes System specification is now complete and ready for immediate development. The implementation plan provides a clear roadmap from initial infrastructure to full-featured note management system, with proper attention to performance, security, and user experience.## Final - Development Readiness Checklist - -### ✅ Architecture & Design -- [x] No pageIndex usage - only VisibleCharRange throughout all layers -- [x] AnchoringResult unified return type for all anchoring operations -- [x] AnchorCandidate model with score and strategy fields -- [x] SearchIndex with Map> internal, List external -- [x] Transaction boundaries defined with BEGIN IMMEDIATE...COMMIT - -### ✅ Data Integrity -- [x] Normalization config string saved with each note for deterministic hashing -- [x] FTS triggers for automatic index synchronization -- [x] Golden tests for RTL/nikud/ZWJ edge cases -- [x] Stale work detection with requestId/epoch for background processing - -### ✅ Performance & Limits -- [x] Performance targets: <50ms avg re-anchoring, <16ms rendering -- [x] Resource limits: 32KB note size, 5000 notes per book -- [x] Batch size limits with emergency controls -- [x] P95/P99 performance testing for 1000+ notes - -### ✅ Security & Privacy -- [x] Telemetry that never logs note content or context windows -- [x] Platform-specific key storage (Keystore/Keychain/DPAPI) -- [x] Versioned encryption envelope with unique nonce per note -- [x] Input validation and sanitization for all user data - -### ✅ Operational Readiness -- [x] Kill switch and granular feature flags -- [x] Comprehensive telemetry with request tracking -- [x] Error handling and recovery strategies -- [x] Migration path from existing bookmarks - -### ✅ Testing Strategy -- [x] Unit tests for all core algorithms -- [x] Integration tests for end-to-end workflows -- [x] Performance tests with realistic data volumes -- [x] Acceptance tests matching original requirements - -The Personal Notes System specification is now complete, battle-tested, and ready for immediate production development. All technical debt prevention measures are in place, performance targets are defined, and the implementation path is clear from MVP to full-featured system. \ No newline at end of file diff --git a/lib/models/phone_report_data.dart b/lib/models/phone_report_data.dart new file mode 100644 index 000000000..67145e732 --- /dev/null +++ b/lib/models/phone_report_data.dart @@ -0,0 +1,116 @@ +import 'package:equatable/equatable.dart'; + +/// Enum representing different types of reporting actions +enum ReportAction { + regular, + phone, +} + +/// Represents an error type with ID and Hebrew label for phone reporting +class ErrorType extends Equatable { + final int id; + final String hebrewLabel; + + const ErrorType({ + required this.id, + required this.hebrewLabel, + }); + + @override + List get props => [id, hebrewLabel]; + + /// Static list of common error types with their IDs and Hebrew labels + static const List errorTypes = [ + ErrorType(id: 1, hebrewLabel: 'שגיאת כתיב'), + ErrorType(id: 2, hebrewLabel: 'טקסט חסר'), + ErrorType(id: 3, hebrewLabel: 'טקסט מיותר'), + ErrorType(id: 4, hebrewLabel: 'שגיאת עיצוב'), + ErrorType(id: 5, hebrewLabel: 'שגיאת מקור'), + ErrorType(id: 6, hebrewLabel: 'אחר'), + ]; + + /// Get error type by ID + static ErrorType? getById(int id) { + try { + return errorTypes.firstWhere((type) => type.id == id); + } catch (e) { + return null; + } + } +} + +/// Data model for phone-based error reporting +class PhoneReportData extends Equatable { + final String selectedText; + final int errorId; + final String moreInfo; + final String libraryVersion; + final int bookId; + final int lineNumber; + + const PhoneReportData({ + required this.selectedText, + required this.errorId, + required this.moreInfo, + required this.libraryVersion, + required this.bookId, + required this.lineNumber, + }); + + /// Convert to JSON for API submission + Map toJson() => { + 'library_ver': libraryVersion, + 'book_id': bookId, + 'line': lineNumber, + 'error_id': errorId, + 'more_info': moreInfo, + }; + + /// Create from JSON (for testing purposes) + factory PhoneReportData.fromJson(Map json) { + return PhoneReportData( + selectedText: '', // Not included in API payload + errorId: json['error_id'] as int, + moreInfo: json['more_info'] as String, + libraryVersion: json['library_ver'] as String, + bookId: json['book_id'] as int, + lineNumber: json['line'] as int, + ); + } + + /// Create a copy with updated fields + PhoneReportData copyWith({ + String? selectedText, + int? errorId, + String? moreInfo, + String? libraryVersion, + int? bookId, + int? lineNumber, + }) { + return PhoneReportData( + selectedText: selectedText ?? this.selectedText, + errorId: errorId ?? this.errorId, + moreInfo: moreInfo ?? this.moreInfo, + libraryVersion: libraryVersion ?? this.libraryVersion, + bookId: bookId ?? this.bookId, + lineNumber: lineNumber ?? this.lineNumber, + ); + } + + @override + List get props => [ + selectedText, + errorId, + moreInfo, + libraryVersion, + bookId, + lineNumber, + ]; + + @override + String toString() { + return 'PhoneReportData(selectedText: $selectedText, errorId: $errorId, ' + 'moreInfo: $moreInfo, libraryVersion: $libraryVersion, ' + 'bookId: $bookId, lineNumber: $lineNumber)'; + } +} diff --git a/lib/services/data_collection_service.dart b/lib/services/data_collection_service.dart new file mode 100644 index 000000000..3a6a174b6 --- /dev/null +++ b/lib/services/data_collection_service.dart @@ -0,0 +1,150 @@ +import 'dart:io'; +import 'dart:convert'; +import 'package:csv/csv.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_settings_screens/flutter_settings_screens.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +/// Service for collecting data required for phone error reporting +class DataCollectionService { + static String get _libraryVersionPath => + 'אוצריא${Platform.pathSeparator}אודות התוכנה${Platform.pathSeparator}גירסת ספריה.txt'; + static String get _sourceBooksPath => + 'אוצריא${Platform.pathSeparator}אודות התוכנה${Platform.pathSeparator}SourcesBooks.csv'; + + /// Read library version from the version file + /// Returns "unknown" if file is missing or cannot be read + Future readLibraryVersion() async { + try { + final libraryPath = Settings.getValue('key-library-path'); + if (libraryPath == null || libraryPath.isEmpty) { + debugPrint('Library path not set'); + return 'unknown'; + } + + final versionFile = + File('$libraryPath${Platform.pathSeparator}$_libraryVersionPath'); + + if (!await versionFile.exists()) { + debugPrint('Library version file not found: ${versionFile.path}'); + return 'unknown'; + } + + final version = await versionFile.readAsString(encoding: utf8); + return version.trim(); + } catch (e) { + debugPrint('Error reading library version: $e'); + return 'unknown'; + } + } + + /// Find book ID in SourcesBooks.csv by matching the book title + /// Returns the line number (1-based) if found, null if not found or error + Future findBookIdInCsv(String bookTitle) async { + try { + final libraryPath = Settings.getValue('key-library-path'); + if (libraryPath == null || libraryPath.isEmpty) { + debugPrint('Library path not set'); + return null; + } + + final csvFile = + File('$libraryPath${Platform.pathSeparator}$_sourceBooksPath'); + + if (!await csvFile.exists()) { + debugPrint('SourcesBooks.csv file not found: ${csvFile.path}'); + return null; + } + + final inputStream = csvFile.openRead(); + final converter = const CsvToListConverter(); + + int lineNumber = 0; + bool isFirstLine = true; + + await for (final line in inputStream + .transform(utf8.decoder) + .transform(const LineSplitter())) { + lineNumber++; + + // Skip header line + if (isFirstLine) { + isFirstLine = false; + continue; + } + + try { + final row = converter.convert(line).first; + + if (row.isNotEmpty) { + final fileNameRaw = row[0].toString(); + final fileName = fileNameRaw.replaceAll('.txt', ''); + + if (fileName == bookTitle) { + return lineNumber; // Return 1-based line number + } + } + } catch (e) { + debugPrint('Error parsing CSV line $lineNumber: $line, Error: $e'); + continue; + } + } + + debugPrint('Book not found in CSV: $bookTitle'); + return null; + } catch (e) { + debugPrint('Error reading SourcesBooks.csv: $e'); + return null; + } + } + + /// Get current line number from ItemPosition data + /// Returns the first visible item index, or 0 if no positions available + int getCurrentLineNumber(List positions) { + try { + if (positions.isEmpty) { + return 0; + } + + // Sort positions by index and return the first one + final sortedPositions = positions.toList() + ..sort((a, b) => a.index.compareTo(b.index)); + + return sortedPositions.first.index + 1; // Convert to 1-based + } catch (e) { + debugPrint('Error getting current line number: $e'); + return 0; + } + } + + /// Check if all required data is available for phone reporting + /// Returns a map with availability status and error messages + Future> checkDataAvailability(String bookTitle) async { + final result = { + 'available': true, + 'errors': [], + 'libraryVersion': null, + 'bookId': null, + }; + + // Check library version + final libraryVersion = await readLibraryVersion(); + result['libraryVersion'] = libraryVersion; + + if (libraryVersion == 'unknown') { + result['available'] = false; + result['errors'].add('לא ניתן לקרוא את גירסת הספרייה'); + } + + // Check book ID + final bookId = await findBookIdInCsv(bookTitle); + result['bookId'] = bookId; + + if (bookId == null) { + result['available'] = false; + result['errors'].add('לא ניתן למצוא את הספר במאגר הנתונים'); + } + + return result; + } +} diff --git a/lib/services/phone_report_service.dart b/lib/services/phone_report_service.dart new file mode 100644 index 000000000..7e8b94a71 --- /dev/null +++ b/lib/services/phone_report_service.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../models/phone_report_data.dart'; + +/// Service for submitting phone error reports to Google Apps Script +class PhoneReportService { + static const String _endpoint = + 'https://script.google.com/macros/s/AKfycbwlEoUMQf-QwTvnLqk3jD8eIgptRAKR5Rzwx67CxD0xYu6SpWupeE4SI3o9BS3eE5fs/exec'; + + static const Duration _timeout = Duration(seconds: 10); + static const int _maxRetries = 2; + + /// Submit a phone error report + /// Returns true if successful, false otherwise + Future submitReport(PhoneReportData reportData) async { + for (int attempt = 1; attempt <= _maxRetries; attempt++) { + try { + debugPrint('Submitting phone report (attempt $attempt/$_maxRetries)'); + + final response = await http + .post( + Uri.parse(_endpoint), + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Accept': 'application/json', + }, + body: jsonEncode(reportData.toJson()), + ) + .timeout(_timeout); + + debugPrint('Response status: ${response.statusCode}'); + debugPrint('Response body: ${response.body}'); + + if (response.statusCode == 200) { + return PhoneReportResult.success('הדיווח נשלח בהצלחה'); + } else if (response.statusCode >= 400 && response.statusCode < 500) { + // Client error - don't retry + return PhoneReportResult.error( + _getClientErrorMessage(response.statusCode)); + } else if (response.statusCode >= 500) { + // Server error - retry if not last attempt + if (attempt == _maxRetries) { + return PhoneReportResult.error( + 'השרת אינו זמין כעת. נסה שוב מאוחר יותר'); + } + // Continue to next attempt + await Future.delayed(Duration(seconds: attempt)); + continue; + } else { + return PhoneReportResult.error( + 'שגיאה לא צפויה: ${response.statusCode}'); + } + } on SocketException catch (e) { + debugPrint('Network error on attempt $attempt: $e'); + if (attempt == _maxRetries) { + return PhoneReportResult.error( + 'אין חיבור לאינטרנט. בדוק את החיבור ונסה שוב'); + } + await Future.delayed(Duration(seconds: attempt)); + } on http.ClientException catch (e) { + debugPrint('HTTP client error on attempt $attempt: $e'); + if (attempt == _maxRetries) { + return PhoneReportResult.error( + 'שגיאה בשליחת הנתונים. נסה שוב מאוחר יותר'); + } + await Future.delayed(Duration(seconds: attempt)); + } on Exception catch (e) { + debugPrint('Unexpected error on attempt $attempt: $e'); + if (attempt == _maxRetries) { + return PhoneReportResult.error('שגיאה לא צפויה. נסה שוב מאוחר יותר'); + } + await Future.delayed(Duration(seconds: attempt)); + } + } + + return PhoneReportResult.error('שגיאה לא צפויה'); + } + + /// Get user-friendly error message for client errors + String _getClientErrorMessage(int statusCode) { + switch (statusCode) { + case 400: + return 'שגיאה בנתוני הדיווח. בדוק שכל השדות מלאים'; + case 401: + return 'שגיאת הרשאה. פנה לתמיכה טכנית'; + case 403: + return 'אין הרשאה לשלוח דיווח. פנה לתמיכה טכנית'; + case 404: + return 'שירות הדיווח אינו זמין. פנה לתמיכה טכנית'; + case 429: + return 'יותר מדי בקשות. המתן מספר דקות ונסה שוב'; + default: + return 'שגיאה בשליחת הנתונים ($statusCode)'; + } + } + + /// Test connection to the reporting endpoint + Future testConnection() async { + try { + final response = await http + .head(Uri.parse(_endpoint)) + .timeout(const Duration(seconds: 5)); + return response.statusCode < 500; + } catch (e) { + debugPrint('Connection test failed: $e'); + return false; + } + } +} + +/// Result of a phone report submission +class PhoneReportResult { + final bool isSuccess; + final String message; + final String? errorCode; + + const PhoneReportResult._({ + required this.isSuccess, + required this.message, + this.errorCode, + }); + + factory PhoneReportResult.success(String message) { + return PhoneReportResult._( + isSuccess: true, + message: message, + ); + } + + factory PhoneReportResult.error(String message, [String? errorCode]) { + return PhoneReportResult._( + isSuccess: false, + message: message, + errorCode: errorCode, + ); + } + + @override + String toString() { + return 'PhoneReportResult(isSuccess: $isSuccess, message: $message, errorCode: $errorCode)'; + } +} diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index f0f784797..04d433bc9 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -31,6 +31,10 @@ import 'package:otzaria/utils/ref_helper.dart'; import 'package:otzaria/utils/text_manipulation.dart' as utils; import 'package:url_launcher/url_launcher.dart'; import 'package:otzaria/notes/notes_system.dart'; +import 'package:otzaria/models/phone_report_data.dart'; +import 'package:otzaria/services/data_collection_service.dart'; +import 'package:otzaria/services/phone_report_service.dart'; +import 'package:otzaria/widgets/phone_report_tab.dart'; /// נתוני הדיווח שנאספו מתיבת סימון הטקסט + פירוט הטעות שהמשתמש הקליד. class ReportedErrorData { @@ -45,6 +49,7 @@ enum ReportAction { cancel, sendEmail, saveForLater, + phone, } class TextBookViewerBloc extends StatefulWidget { @@ -82,6 +87,24 @@ class _TextBookViewerBlocState extends State .join('&'); } + int _getCurrentLineNumber() { + try { + final state = context.read().state; + if (state is TextBookLoaded) { + final positions = state.positionsListener.itemPositions.value; + if (positions.isNotEmpty) { + final firstVisible = + positions.reduce((a, b) => a.index < b.index ? a : b); + return firstVisible.index + 1; + } + } + return 1; // Fallback to line 1 + } catch (e) { + debugPrint('Error getting current line number: $e'); + return 1; + } + } + @override void initState() { super.initState(); @@ -563,171 +586,51 @@ class _TextBookViewerBlocState extends State if (!mounted) return; - final ReportedErrorData? reportData = await _showTextSelectionDialog( + final dynamic result = await _showTabbedReportDialog( context, visibleText, state.fontSize, - ); - - if (reportData == null) return; // בוטל או לא נבחר טקסט - if (!mounted) return; - - final ReportAction? action = - await _showConfirmationDialog(context, reportData); - - if (action == null || action == ReportAction.cancel) return; - - // נבנה את גוף המייל (נעשה שימוש גם לשליחה וגם לשמירה) - final emailBody = _buildEmailBody( state.book.title, - currentRef, - bookDetails, - reportData.selectedText, - reportData.errorDetails, ); - if (action == ReportAction.sendEmail) { - // בחירת כתובת דוא"ל לקבלת הדיווח - final emailAddress = - bookDetails['תיקיית המקור']?.contains('sefaria') == true - ? 'corrections@sefaria.org' - : _fallbackMail; - - final emailUri = Uri( - scheme: 'mailto', - path: emailAddress, - query: encodeQueryParameters({ - 'subject': 'דיווח על טעות: ${state.book.title}', - 'body': emailBody, - }), - ); + if (result == null) return; // בוטל + if (!mounted) return; - try { - if (!await launchUrl(emailUri, mode: LaunchMode.externalApplication)) { - _showSimpleSnack('לא ניתן לפתוח את תוכנת הדואר'); - } - } catch (_) { - _showSimpleSnack('לא ניתן לפתוח את תוכנת הדואר'); - } - return; - } + // Handle different result types + if (result is ReportedErrorData) { + // Regular report - continue with existing flow + final ReportAction? action = + await _showConfirmationDialog(context, result); - if (action == ReportAction.saveForLater) { - final saved = await _saveReportToFile(emailBody); - if (!saved) { - _showSimpleSnack('שמירת הדיווח נכשלה.'); - return; - } + if (action == null || action == ReportAction.cancel) return; - final count = await _countReportsInFile(); - _showSavedSnack(count); - return; + // Handle regular report actions + await _handleRegularReportAction( + action, result, state, currentRef, bookDetails); + } else if (result is PhoneReportData) { + // Phone report - handle directly + await _handlePhoneReport(result); } } - Future _showTextSelectionDialog( + Future _showTabbedReportDialog( BuildContext context, String text, double fontSize, + String bookTitle, ) async { - String? selectedContent; - final TextEditingController detailsController = TextEditingController(); - return showDialog( + // קבל את מספר השורה ההתחלתי לפני פתיחת הדיאלוג + final currentLineNumber = _getCurrentLineNumber(); + + return showDialog( context: context, builder: (BuildContext context) { - return StatefulBuilder( - builder: (context, setDialogState) { - return AlertDialog( - title: const Text('בחר את הטקסט שבו יש טעות'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('סמן את הטקסט שבו נמצאת הטעות:'), - const SizedBox(height: 8), - // השתמשנו ב-ConstrainedBox כדי לתת גובה מקסימלי, במקום Expanded - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.4, - ), - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey), - borderRadius: BorderRadius.circular(4), - ), - child: SingleChildScrollView( - child: SelectableText( - text, - style: TextStyle( - fontSize: fontSize, - fontFamily: - Settings.getValue('key-font-family') ?? - 'candara', - ), - onSelectionChanged: (selection, cause) { - if (selection.start != selection.end) { - final newContent = text.substring( - selection.start, - selection.end, - ); - if (newContent.isNotEmpty) { - setDialogState(() { - selectedContent = newContent; - }); - } - } - }, - ), - ), - ), - ), - const SizedBox(height: 16), - Align( - alignment: Alignment.centerRight, - child: Text( - 'פירוט הטעות (חובה לפרט מהי הטעות, בלא פירוט לא נוכל לטפל):', - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - ), - const SizedBox(height: 4), - TextField( - controller: detailsController, - minLines: 2, - maxLines: 4, - decoration: const InputDecoration( - isDense: true, - border: OutlineInputBorder(), - hintText: 'כתוב כאן מה לא תקין, הצע תיקון וכו\'', - ), - textDirection: TextDirection.rtl, - ), - ], - ), - ), - actions: [ - TextButton( - child: const Text('ביטול'), - onPressed: () => Navigator.of(context).pop(), - ), - TextButton( - onPressed: selectedContent == null || selectedContent!.isEmpty - ? null - : () => Navigator.of(context).pop( - ReportedErrorData( - selectedText: selectedContent!, - errorDetails: detailsController.text.trim(), - ), - ), - child: const Text('המשך'), - ), - ], - ); - }, + return _TabbedReportDialog( + visibleText: text, + fontSize: fontSize, + bookTitle: bookTitle, + // העבר רק את מספר השורה ההתחלתי + currentLineNumber: currentLineNumber, ); }, ); @@ -808,6 +711,116 @@ $detailsSection '''; } + /// Handle regular report action (email or save) + Future _handleRegularReportAction( + ReportAction action, + ReportedErrorData reportData, + TextBookLoaded state, + String currentRef, + Map bookDetails, + ) async { + final emailBody = _buildEmailBody( + state.book.title, + currentRef, + bookDetails, + reportData.selectedText, + reportData.errorDetails, + ); + + if (action == ReportAction.sendEmail) { + final emailAddress = + bookDetails['תיקיית המקור']?.contains('sefaria') == true + ? 'corrections@sefaria.org' + : _fallbackMail; + + final emailUri = Uri( + scheme: 'mailto', + path: emailAddress, + query: encodeQueryParameters({ + 'subject': 'דיווח על טעות: ${state.book.title}', + 'body': emailBody, + }), + ); + + try { + if (!await launchUrl(emailUri, mode: LaunchMode.externalApplication)) { + _showSimpleSnack('לא ניתן לפתוח את תוכנת הדואר'); + } + } catch (_) { + _showSimpleSnack('לא ניתן לפתוח את תוכנת הדואר'); + } + } else if (action == ReportAction.saveForLater) { + final saved = await _saveReportToFile(emailBody); + if (!saved) { + _showSimpleSnack('שמירת הדיווח נכשלה.'); + return; + } + + final count = await _countReportsInFile(); + _showSavedSnack(count); + } + } + + /// Handle phone report submission + Future _handlePhoneReport(PhoneReportData reportData) async { + try { + // Show loading indicator + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const Center( + child: CircularProgressIndicator(), + ), + ); + + final phoneReportService = PhoneReportService(); + final result = await phoneReportService.submitReport(reportData); + + // Hide loading indicator + if (mounted) Navigator.of(context).pop(); + + if (result.isSuccess) { + _showPhoneReportSuccessDialog(); + } else { + _showSimpleSnack(result.message); + } + } catch (e) { + // Hide loading indicator + if (mounted) Navigator.of(context).pop(); + + debugPrint('Phone report error: $e'); + _showSimpleSnack('שגיאה בשליחת הדיווח: ${e.toString()}'); + } + } + + /// Show success dialog for phone report + void _showPhoneReportSuccessDialog() { + if (!mounted) return; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('דיווח נשלח בהצלחה'), + content: const Text('הדיווח נשלח בהצלחה לצוות אוצריא. תודה על הדיווח!'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('סגור'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + // Open another report dialog + _showReportBugDialog(context, + context.read().state as TextBookLoaded); + }, + child: const Text('פתח דוח שגיאות אחר'), + ), + ], + ), + ); + } + /// שמירת דיווח לקובץ בתיקייה הראשית של הספרייה (libraryPath). Future _saveReportToFile(String reportContent) async { try { @@ -1300,3 +1313,306 @@ $detailsSection return const CommentatorsListView(); } } + +// החלף את כל המחלקה הזו בקובץ text_book_screen.TXT + +/// Tabbed dialog for error reporting with regular and phone options +class _TabbedReportDialog extends StatefulWidget { + final String visibleText; + final double fontSize; + final String bookTitle; + final int currentLineNumber; // חזרנו לפרמטר המקורי והנכון + + const _TabbedReportDialog({ + required this.visibleText, + required this.fontSize, + required this.bookTitle, + required this.currentLineNumber, // וגם כאן + }); + + @override + State<_TabbedReportDialog> createState() => _TabbedReportDialogState(); +} + +class _TabbedReportDialogState extends State<_TabbedReportDialog> + with SingleTickerProviderStateMixin { + late TabController _tabController; + String? _selectedText; + final DataCollectionService _dataService = DataCollectionService(); + + // Phone report data + String _libraryVersion = 'unknown'; + int? _bookId; + bool _isLoadingData = true; + List _dataErrors = []; + + // הסרנו את הפונקציה המיותרת _calculateLineNumberForSelectedText + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _loadPhoneReportData(); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + Future _loadPhoneReportData() async { + // קוד זה נשאר זהה + try { + final availability = + await _dataService.checkDataAvailability(widget.bookTitle); + + setState(() { + _libraryVersion = availability['libraryVersion'] ?? 'unknown'; + _bookId = availability['bookId']; + _dataErrors = List.from(availability['errors'] ?? []); + _isLoadingData = false; + }); + } catch (e) { + debugPrint('Error loading phone report data: $e'); + setState(() { + _dataErrors = ['שגיאה בטעינת נתוני הדיווח']; + _isLoadingData = false; + }); + } + } + + @override + Widget build(BuildContext context) { + // קוד זה נשאר זהה + return Dialog( + child: Container( + width: MediaQuery.of(context).size.width * 0.9, + height: MediaQuery.of(context).size.height * 0.8, + child: Column( + children: [ + // Dialog title + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'דיווח על טעות בספר', + style: Theme.of(context).textTheme.headlineSmall, + textDirection: TextDirection.rtl, + ), + ), + // Tab bar + TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'דיווח רגיל'), + Tab(text: 'דיווח דרך קו אוצריא'), + ], + ), + // Tab content + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildRegularReportTab(), + _buildPhoneReportTab(), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildRegularReportTab() { + // קוד זה נשאר זהה + return _RegularReportTab( + visibleText: widget.visibleText, + fontSize: widget.fontSize, + initialSelectedText: _selectedText, + onTextSelected: (text) { + setState(() { + _selectedText = text; + }); + }, + onSubmit: (reportData) { + Navigator.of(context).pop(reportData); + }, + onCancel: () { + Navigator.of(context).pop(); + }, + ); + } + + Widget _buildPhoneReportTab() { + if (_isLoadingData) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('טוען נתוני דיווח...'), + ], + ), + ); + } + + // --- כאן התיקון המרכזי --- + return PhoneReportTab( + visibleText: widget.visibleText, + fontSize: widget.fontSize, + libraryVersion: _libraryVersion, + bookId: _bookId, + lineNumber: widget.currentLineNumber, // העבר את מספר השורה ההתחלתי + initialSelectedText: _selectedText, + // עדכן את ה-onSubmit כדי לקבל את מספר השורה המחושב בחזרה + onSubmit: (selectedText, errorId, moreInfo, lineNumber) async { + final reportData = PhoneReportData( + selectedText: selectedText, + errorId: errorId, + moreInfo: moreInfo, + libraryVersion: _libraryVersion, + bookId: _bookId!, + lineNumber: lineNumber, // השתמש במספר השורה המעודכן שהתקבל! + ); + Navigator.of(context).pop(reportData); + }, + onCancel: () { + Navigator.of(context).pop(); + }, + ); + } +} + +/// Regular report tab widget +class _RegularReportTab extends StatefulWidget { + final String visibleText; + final double fontSize; + final String? initialSelectedText; + final Function(String) onTextSelected; + final Function(ReportedErrorData) onSubmit; + final VoidCallback onCancel; + + const _RegularReportTab({ + required this.visibleText, + required this.fontSize, + this.initialSelectedText, + required this.onTextSelected, + required this.onSubmit, + required this.onCancel, + }); + + @override + State<_RegularReportTab> createState() => _RegularReportTabState(); +} + +class _RegularReportTabState extends State<_RegularReportTab> { + String? _selectedContent; + final TextEditingController _detailsController = TextEditingController(); + + @override + void initState() { + super.initState(); + _selectedContent = widget.initialSelectedText; + } + + @override + void dispose() { + _detailsController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('סמן את הטקסט שבו נמצאת הטעות:'), + const SizedBox(height: 8), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.3, + ), + child: Container( + padding: const EdgeInsets.all(8), + child: SingleChildScrollView( + child: SelectableText( + widget.visibleText, + style: TextStyle( + fontSize: widget.fontSize, + fontFamily: + Settings.getValue('key-font-family') ?? 'candara', + ), + onSelectionChanged: (selection, cause) { + if (selection.start != selection.end) { + final newContent = widget.visibleText.substring( + selection.start, + selection.end, + ); + if (newContent.isNotEmpty) { + setState(() { + _selectedContent = newContent; + }); + widget.onTextSelected(newContent); + } + } + }, + ), + ), + ), + ), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerRight, + child: Text( + 'פירוט הטעות (חובה לפרט מהי הטעות, בלא פירוט לא נוכל לטפל):', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 4), + TextField( + controller: _detailsController, + minLines: 2, + maxLines: 4, + decoration: const InputDecoration( + isDense: true, + border: OutlineInputBorder(), + hintText: 'כתוב כאן מה לא תקין, הצע תיקון וכו\'', + ), + textDirection: TextDirection.rtl, + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: widget.onCancel, + child: const Text('ביטול'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _selectedContent == null || _selectedContent!.isEmpty + ? null + : () { + widget.onSubmit( + ReportedErrorData( + selectedText: _selectedContent!, + errorDetails: _detailsController.text.trim(), + ), + ); + }, + child: const Text('המשך'), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/widgets/phone_report_tab.dart b/lib/widgets/phone_report_tab.dart new file mode 100644 index 000000000..fbb4b086b --- /dev/null +++ b/lib/widgets/phone_report_tab.dart @@ -0,0 +1,385 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_settings_screens/flutter_settings_screens.dart'; +import '../models/phone_report_data.dart'; +import '../widgets/reporting_numbers_widget.dart'; + +/// Tab widget for phone-based error reporting +class PhoneReportTab extends StatefulWidget { + final String visibleText; + final double fontSize; + final String libraryVersion; + final int? bookId; + final int lineNumber; + final String? initialSelectedText; + final Function( + String selectedText, int errorId, String moreInfo, int lineNumber)? + onSubmit; + final VoidCallback? onCancel; + + const PhoneReportTab({ + super.key, + required this.visibleText, + required this.fontSize, + required this.libraryVersion, + required this.bookId, + required this.lineNumber, + this.initialSelectedText, + this.onSubmit, + this.onCancel, + }); + + @override + State createState() => _PhoneReportTabState(); +} + +class _PhoneReportTabState extends State { + String? _selectedText; + ErrorType? _selectedErrorType; + bool _isSubmitting = false; + + late int _updatedLineNumber; + + @override + void initState() { + super.initState(); + _selectedText = widget.initialSelectedText; + // אתחול מספר השורה עם הערך ההתחלתי שקיבלנו + _updatedLineNumber = widget.lineNumber; + } + + @override + void dispose() { + super.dispose(); + } + + bool get _canSubmit { + return !_isSubmitting && + _selectedText != null && + _selectedText!.isNotEmpty && + _selectedErrorType != null && + widget.bookId != null && + widget.libraryVersion != 'unknown'; + } + + List get _validationErrors { + final errors = []; + + if (_selectedText == null || _selectedText!.isEmpty) { + errors.add('יש לבחור טקסט שבו נמצאת השגיאה'); + } + + if (_selectedErrorType == null) { + errors.add('יש לבחור סוג שגיאה'); + } + + if (widget.bookId == null) { + errors.add('לא ניתן למצוא את הספר במאגר הנתונים'); + } + + if (widget.libraryVersion == 'unknown') { + errors.add('לא ניתן לקרוא את גירסת הספרייה'); + } + + return errors; + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInstructions(context), + const SizedBox(height: 16), + _buildTextSelection(context), + const SizedBox(height: 16), + _buildErrorTypeSelection(context), + const SizedBox(height: 16), + _buildReportingNumbers(context), + const SizedBox(height: 16), + _buildValidationErrors(context), + const SizedBox(height: 16), + _buildActionButtons(context), + ], + ), + ); + } + + Widget _buildInstructions(BuildContext context) { + return Card( + color: Theme.of(context).colorScheme.primaryContainer, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'הוראות לדיווח טלפוני:', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + textDirection: TextDirection.rtl, + ), + const SizedBox(height: 8), + Text( + '1. סמן את הטקסט שבו נמצאת הטעות • ' + '2. בחר את סוג השגיאה מהרשימה • ' + '3. השתמש במספרים המוצגים למטה כשתתקשר', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + textDirection: TextDirection.rtl, + ), + ], + ), + ), + ); + } + + Widget _buildTextSelection(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'סמן את הטקסט שבו נמצאת הטעות:', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textDirection: TextDirection.rtl, + ), + const SizedBox(height: 8), + Container( + height: 200, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + // הוספת מסגרת + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(8), + ), + child: SingleChildScrollView( + child: SelectableText( + widget.visibleText, + style: TextStyle( + fontSize: widget.fontSize, + fontFamily: Settings.getValue('key-font-family') ?? 'candara', + ), + textDirection: TextDirection.rtl, + onSelectionChanged: (selection, cause) { + if (selection.start != selection.end) { + final newContent = widget.visibleText.substring( + selection.start, + selection.end, + ); + + // --- כאן הלוגיקה הנכונה והמתוקנת --- + final textBeforeSelection = + widget.visibleText.substring(0, selection.start); + final lineOffset = + '\n'.allMatches(textBeforeSelection).length; + final newLineNumber = widget.lineNumber + lineOffset; + // --- סוף הלוגיקה --- + + if (newContent.isNotEmpty) { + setState(() { + _selectedText = newContent; + _updatedLineNumber = + newLineNumber; // עדכון מספר השורה ב-state + }); + } + } + }, + ), + ), + ), + if (_selectedText != null && _selectedText!.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'הטקסט שנבחר:', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textDirection: TextDirection.rtl, + ), + const SizedBox(height: 4), + Text( + _selectedText!, + style: Theme.of(context).textTheme.bodyMedium, + textDirection: TextDirection.rtl, + ), + ], + ), + ), + ], + ], + ); + } + + Widget _buildErrorTypeSelection(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'בחר סוג שגיאה:', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textDirection: TextDirection.rtl, + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _selectedErrorType, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'בחר סוג שגיאה...', + ), + isExpanded: true, + items: ErrorType.errorTypes.map((errorType) { + return DropdownMenuItem( + value: errorType, + child: Text( + errorType.hebrewLabel, + textDirection: TextDirection.rtl, + ), + ); + }).toList(), + onChanged: (_selectedText != null && _selectedText!.isNotEmpty) + ? (ErrorType? value) { + setState(() { + _selectedErrorType = value; + }); + } + : null, + ), + ], + ); + } + + Widget _buildReportingNumbers(BuildContext context) { + return ReportingNumbersWidget( + libraryVersion: widget.libraryVersion, + bookId: widget.bookId, + // השתמש במספר השורה המעודכן מה-state + lineNumber: _updatedLineNumber, + errorId: _selectedErrorType?.id, + ); + } + + Widget _buildValidationErrors(BuildContext context) { + final errors = _validationErrors; + if (errors.isEmpty) return const SizedBox.shrink(); + + return Card( + color: Theme.of(context).colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.error_outline, + color: Theme.of(context).colorScheme.onErrorContainer, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'יש לתקן את השגיאות הבאות:', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onErrorContainer, + ), + textDirection: TextDirection.rtl, + ), + ], + ), + const SizedBox(height: 8), + ...errors.map((error) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '• ', + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ), + Expanded( + child: Text( + error, + style: + Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context) + .colorScheme + .onErrorContainer, + ), + textDirection: TextDirection.rtl, + ), + ), + ], + ), + )), + ], + ), + ), + ); + } + + Widget _buildActionButtons(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: widget.onCancel, + child: const Text('ביטול'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _canSubmit ? _handleSubmit : null, + child: _isSubmitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('שלח דיווח'), + ), + ], + ); + } + + void _handleSubmit() { + if (!_canSubmit) return; + + setState(() { + _isSubmitting = true; + }); + + try { + widget.onSubmit?.call( + _selectedText!, + _selectedErrorType!.id, + '', // Empty string instead of moreInfo + _updatedLineNumber, + ); + } finally { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + } + } + } +} diff --git a/lib/widgets/reporting_numbers_widget.dart b/lib/widgets/reporting_numbers_widget.dart new file mode 100644 index 000000000..5d5a5f85a --- /dev/null +++ b/lib/widgets/reporting_numbers_widget.dart @@ -0,0 +1,276 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Widget that displays reporting numbers with copy functionality +class ReportingNumbersWidget extends StatelessWidget { + final String libraryVersion; + final int? bookId; + final int lineNumber; + final int? errorId; + final bool showPhoneNumber; + + const ReportingNumbersWidget({ + super.key, + required this.libraryVersion, + required this.bookId, + required this.lineNumber, + this.errorId, + this.showPhoneNumber = true, + }); + + static const String _phoneNumber = '077-4636-198'; + + @override + Widget build(BuildContext context) { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'מספרי הדיווח:', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textDirection: TextDirection.rtl, + ), + const SizedBox(height: 12), + + // IntrinsicHeight חיוני כדי שה-VerticalDivider יידע מה הגובה שלו + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // טור ימין + Expanded( + child: Column( + children: [ + _buildNumberRow( + context, + 'מספר גירסה', + libraryVersion, + ), + const SizedBox(height: 8), + _buildNumberRow( + context, + 'מספר ספר', + bookId?.toString() ?? 'לא זמין', + enabled: bookId != null, + ), + ], + ), + ), + + // קו מפריד אנכי בין הטורים + const VerticalDivider( + width: 20, // הרוחב הכולל שהמפריד תופס + thickness: 1, // עובי הקו + indent: 5, // ריפוד עליון + endIndent: 5, // ריפוד תחתון + color: Colors.grey, // צבע הקו (אופציונלי) + ), + + // טור שמאל + Expanded( + child: Column( + children: [ + _buildNumberRow( + context, + 'מספר שורה', + lineNumber.toString(), + ), + const SizedBox(height: 8), + _buildNumberRow( + context, + 'מספר שגיאה', + errorId?.toString() ?? 'לא נבחר', + enabled: errorId != null, + ), + ], + ), + ), + ], + ), + ), + + if (showPhoneNumber) ...[ + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + _buildPhoneSection(context), + ], + ], + ), + ), + ); + } + + Widget _buildNumberRow( + BuildContext context, + String label, + String value, { + bool enabled = true, + }) { + return Row( + children: [ + Expanded( + child: Text( + '$label: $value', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: enabled ? null : Theme.of(context).disabledColor, + ), + textDirection: TextDirection.rtl, + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: enabled ? () => _copyToClipboard(context, value) : null, + icon: const Icon(Icons.copy, size: 18), + tooltip: 'העתק', + visualDensity: VisualDensity.compact, + ), + ], + ); + } + + Widget _buildPhoneSection(BuildContext context) { + final isMobile = Platform.isAndroid || Platform.isIOS; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + // 1. הכותרת שתוצג בצד ימין + Text( + 'קו אוצריא:', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textDirection: TextDirection.rtl, + ), + + // 2. Spacer שתופס את כל המקום הפנוי ודוחף את שאר הווידג'טים שמאלה + const Spacer(), + + // 3. מספר הטלפון (כבר לא צריך להיות בתוך Expanded) + isMobile + ? InkWell( + onTap: () => _makePhoneCall(context), + child: Text( + _phoneNumber, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).primaryColor, + decoration: TextDecoration.underline, + ), + textDirection: TextDirection.ltr, + ), + ) + : SelectableText( + _phoneNumber, + style: Theme.of(context).textTheme.bodyLarge, + textDirection: TextDirection.ltr, + ), + const SizedBox(width: 8), + + // 4. כפתור ההעתקה + IconButton( + onPressed: () => _copyToClipboard(context, _phoneNumber), + icon: const Icon(Icons.copy, size: 18), + tooltip: 'העתק מספר טלפון', + visualDensity: VisualDensity.compact, + ), + + // 5. כפתור החיוג (למובייל) + if (isMobile) ...[ + const SizedBox(width: 4), + IconButton( + onPressed: () => _makePhoneCall(context), + icon: const Icon(Icons.phone, size: 18), + tooltip: 'התקשר', + visualDensity: VisualDensity.compact, + ), + ], + ], + ), + const SizedBox(height: 8), + + // טקסט המשנה נשאר כמו שהיה + Text( + 'לפירוט נוסף, השאר הקלטה ברורה!', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textDirection: TextDirection.rtl, + ), + ], + ); + } + + Future _copyToClipboard(BuildContext context, String text) async { + try { + await Clipboard.setData(ClipboardData(text: text)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'הועתק ללוח: $text', + textDirection: TextDirection.rtl, + ), + duration: const Duration(seconds: 2), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'שגיאה בהעתקה ללוח', + textDirection: TextDirection.rtl, + ), + duration: Duration(seconds: 2), + ), + ); + } + } + } + + Future _makePhoneCall(BuildContext context) async { + try { + final phoneUri = Uri(scheme: 'tel', path: _phoneNumber); + if (await canLaunchUrl(phoneUri)) { + await launchUrl(phoneUri); + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'לא ניתן לפתוח את אפליקציית הטלפון', + textDirection: TextDirection.rtl, + ), + duration: Duration(seconds: 3), + ), + ); + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'שגיאה בפתיחת אפליקציית הטלפון', + textDirection: TextDirection.rtl, + ), + duration: Duration(seconds: 3), + ), + ); + } + } + } +} diff --git a/test/models/phone_report_data_test.dart b/test/models/phone_report_data_test.dart new file mode 100644 index 000000000..b901a722c --- /dev/null +++ b/test/models/phone_report_data_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:otzaria/models/phone_report_data.dart'; + +void main() { + group('PhoneReportData', () { + test('should serialize to JSON correctly', () { + final reportData = PhoneReportData( + selectedText: 'Test text', + errorId: 1, + moreInfo: 'Additional info', + libraryVersion: '1.0.0', + bookId: 123, + lineNumber: 456, + ); + + final json = reportData.toJson(); + + expect(json['library_ver'], equals('1.0.0')); + expect(json['book_id'], equals(123)); + expect(json['line'], equals(456)); + expect(json['error_id'], equals(1)); + expect(json['more_info'], equals('Additional info')); + }); + + test('should create copy with updated fields', () { + final original = PhoneReportData( + selectedText: 'Original text', + errorId: 1, + moreInfo: 'Original info', + libraryVersion: '1.0.0', + bookId: 123, + lineNumber: 456, + ); + + final updated = original.copyWith( + errorId: 2, + moreInfo: 'Updated info', + ); + + expect(updated.selectedText, equals('Original text')); + expect(updated.errorId, equals(2)); + expect(updated.moreInfo, equals('Updated info')); + expect(updated.libraryVersion, equals('1.0.0')); + expect(updated.bookId, equals(123)); + expect(updated.lineNumber, equals(456)); + }); + }); + + group('ErrorType', () { + test('should find error type by ID', () { + final errorType = ErrorType.getById(1); + expect(errorType, isNotNull); + expect(errorType!.id, equals(1)); + expect(errorType.hebrewLabel, equals('שגיאת כתיב')); + }); + + test('should return null for invalid ID', () { + final errorType = ErrorType.getById(999); + expect(errorType, isNull); + }); + + test('should have all expected error types', () { + expect(ErrorType.errorTypes.length, equals(6)); + expect(ErrorType.errorTypes[0].hebrewLabel, equals('שגיאת כתיב')); + expect(ErrorType.errorTypes[5].hebrewLabel, equals('אחר')); + }); + }); +} diff --git a/test/services/data_collection_service_test.dart b/test/services/data_collection_service_test.dart new file mode 100644 index 000000000..7ac32fbe3 --- /dev/null +++ b/test/services/data_collection_service_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:otzaria/services/data_collection_service.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +void main() { + group('DataCollectionService', () { + late DataCollectionService service; + + setUp(() { + service = DataCollectionService(); + }); + + test('should return unknown when library version file is missing', + () async { + // This test would need proper mocking of file system + // For now, we just test the basic structure + expect(service, isA()); + }); + + test('should calculate current line number correctly', () { + final positions = [ + ItemPosition(index: 5, itemLeadingEdge: 0.0, itemTrailingEdge: 1.0), + ItemPosition(index: 3, itemLeadingEdge: 0.0, itemTrailingEdge: 1.0), + ItemPosition(index: 7, itemLeadingEdge: 0.0, itemTrailingEdge: 1.0), + ]; + + final lineNumber = service.getCurrentLineNumber(positions); + expect(lineNumber, equals(4)); // 3 + 1 (1-based) + }); + + test('should return 0 when no positions available', () { + final lineNumber = service.getCurrentLineNumber([]); + expect(lineNumber, equals(0)); + }); + }); +} From 099b09276c11014c931e179d5f8bbde480113305 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 24 Aug 2025 17:55:52 +0300 Subject: [PATCH 138/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=94?= =?UTF-8?q?=D7=92=D7=9C=D7=99=D7=9C=D7=94=20=D7=94=D7=90=D7=99=D7=98=D7=99?= =?UTF-8?q?=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/notes/config/notes_config.dart | 18 + lib/notes/data/notes_data_provider.dart | 12 + lib/notes/repository/notes_repository.dart | 190 ++- lib/notes/services/background_processor.dart | 1293 ++++++++++++++++- lib/notes/services/text_normalizer.dart | 21 + lib/notes/widgets/notes_sidebar.dart | 316 ++-- lib/text_book/bloc/text_book_bloc.dart | 42 +- .../combined_view/combined_book_screen.dart | 36 +- .../view/splited_view/simple_book_view.dart | 41 +- 9 files changed, 1785 insertions(+), 184 deletions(-) diff --git a/lib/notes/config/notes_config.dart b/lib/notes/config/notes_config.dart index 203491362..d5cd0d4c2 100644 --- a/lib/notes/config/notes_config.dart +++ b/lib/notes/config/notes_config.dart @@ -224,6 +224,24 @@ class NormalizationConfig { ); } + /// Convert to map for serialization + Map toMap() { + return { + 'removeNikud': removeNikud, + 'quoteStyle': quoteStyle, + 'unicodeForm': unicodeForm, + }; + } + + /// Create from map + factory NormalizationConfig.fromMap(Map map) { + return NormalizationConfig( + removeNikud: map['removeNikud'] ?? false, + quoteStyle: map['quoteStyle'] ?? 'ascii', + unicodeForm: map['unicodeForm'] ?? 'NFKC', + ); + } + @override String toString() => toConfigString(); } \ No newline at end of file diff --git a/lib/notes/data/notes_data_provider.dart b/lib/notes/data/notes_data_provider.dart index 2a899564c..f53678440 100644 --- a/lib/notes/data/notes_data_provider.dart +++ b/lib/notes/data/notes_data_provider.dart @@ -164,6 +164,18 @@ class NotesDataProvider { return result.map((json) => Note.fromJson(json)).toList(); } + /// Get all notes across all books + Future> getAllNotes() async { + final db = await database; + + final result = await db.query( + DatabaseConfig.notesTable, + orderBy: 'updated_at DESC', + ); + + return result.map((json) => Note.fromJson(json)).toList(); + } + /// Get notes for a specific character range Future> getNotesForCharRange( String bookId, diff --git a/lib/notes/repository/notes_repository.dart b/lib/notes/repository/notes_repository.dart index 79a2964a0..742cb6590 100644 --- a/lib/notes/repository/notes_repository.dart +++ b/lib/notes/repository/notes_repository.dart @@ -144,7 +144,27 @@ class NotesRepository { return []; } - return await _dataProvider.searchNotes(query, bookId: bookId); + // For simple queries, use database FTS + if (query.length < 3 || query.split(' ').length == 1) { + return await _dataProvider.searchNotes(query, bookId: bookId); + } + + // For complex queries, use background processor for better performance + final allNotes = bookId != null + ? await _dataProvider.getNotesForBook(bookId) + : await _dataProvider.getAllNotes(); + + if (allNotes.length > 100) { + // Use isolate for large datasets + return await _backgroundProcessor.processTextSearch( + query, + allNotes, + bookId: bookId, + ); + } else { + // Use database for small datasets + return await _dataProvider.searchNotes(query, bookId: bookId); + } } catch (e) { throw RepositoryException('Failed to search notes: $e'); } @@ -485,4 +505,172 @@ class RepositoryException implements Exception { @override String toString() => 'RepositoryException: $message'; +} + +/// Extension methods for NotesRepository with advanced processing capabilities +extension NotesRepositoryAdvanced on NotesRepository { + /// Export notes in various formats using background processing + Future> exportNotes({ + String? bookId, + String format = 'json', + bool includeMetadata = true, + }) async { + try { + // Get all notes or filter by book + final allNotes = await _dataProvider.getAllNotes(); + final notesToExport = bookId != null + ? allNotes.where((note) => note.bookId == bookId).toList() + : allNotes; + + // Use background processor for export + final result = await _backgroundProcessor.processBatchOperation( + 'export', + notesToExport, + { + 'format': format, + 'includeMetadata': includeMetadata, + }, + ); + + return result; + } catch (e) { + throw Exception('Failed to export notes: $e'); + } + } + + /// Validate all notes using background processing + Future> validateAllNotes() async { + try { + final allNotes = await _dataProvider.getAllNotes(); + + // Use background processor for validation + final result = await _backgroundProcessor.processBatchOperation( + 'validate', + allNotes, + {}, + ); + + return result; + } catch (e) { + throw Exception('Failed to validate notes: $e'); + } + } + + /// Calculate comprehensive statistics using background processing + Future> calculateStatistics({String? bookId}) async { + try { + final allNotes = await _dataProvider.getAllNotes(); + final notesToAnalyze = bookId != null + ? allNotes.where((note) => note.bookId == bookId).toList() + : allNotes; + + // Use background processor for statistics + final result = await _backgroundProcessor.processBatchOperation( + 'statistics', + notesToAnalyze, + {'bookId': bookId}, + ); + + return result; + } catch (e) { + throw Exception('Failed to calculate statistics: $e'); + } + } + + /// Cleanup and optimize notes data using background processing + Future> cleanupNotes() async { + try { + final allNotes = await _dataProvider.getAllNotes(); + + // Use background processor for cleanup + final result = await _backgroundProcessor.processBatchOperation( + 'cleanup', + allNotes, + {}, + ); + + return result; + } catch (e) { + throw Exception('Failed to cleanup notes: $e'); + } + } + + /// Process multiple texts in parallel (e.g., for bulk normalization) + Future> normalizeTextsInParallel(List texts) async { + try { + // Use parallel processing for normalization + final results = await _backgroundProcessor.processParallelOperations( + texts, + 'normalize_texts', + {}, + ); + + return results; + } catch (e) { + throw Exception('Failed to normalize texts in parallel: $e'); + } + } + + /// Generate hashes for multiple texts in parallel + Future> generateHashesInParallel(List texts) async { + try { + // Use parallel processing for hash generation + final results = await _backgroundProcessor.processParallelOperations( + texts, + 'generate_hashes', + {}, + ); + + return results; + } catch (e) { + throw Exception('Failed to generate hashes in parallel: $e'); + } + } + + /// Validate multiple notes in parallel + Future> validateNotesInParallel(List notes) async { + try { + // Use parallel processing for validation + final results = await _backgroundProcessor.processParallelOperations( + notes, + 'validate_notes', + {}, + ); + + return results; + } catch (e) { + throw Exception('Failed to validate notes in parallel: $e'); + } + } + + /// Extract keywords from multiple texts in parallel + Future>> extractKeywordsInParallel(List texts) async { + try { + // Use parallel processing for keyword extraction + final results = await _backgroundProcessor.processParallelOperations>( + texts, + 'extract_keywords', + {}, + ); + + return results; + } catch (e) { + throw Exception('Failed to extract keywords in parallel: $e'); + } + } + + /// Get comprehensive performance and cache statistics + Map getProcessingStatistics() { + return _backgroundProcessor.getProcessingStats(); + } + + /// Clear all cached results to free memory + void clearProcessingCache() { + _backgroundProcessor.clearCache(); + } + + /// Reset performance statistics + void resetPerformanceStats() { + _backgroundProcessor.resetPerformanceStats(); + } } \ No newline at end of file diff --git a/lib/notes/services/background_processor.dart b/lib/notes/services/background_processor.dart index 3460b89c2..4cb9dac24 100644 --- a/lib/notes/services/background_processor.dart +++ b/lib/notes/services/background_processor.dart @@ -3,6 +3,7 @@ import 'dart:async'; import '../models/note.dart'; import '../models/anchor_models.dart'; import '../services/anchoring_service.dart'; +import '../services/text_normalizer.dart'; import '../services/notes_telemetry.dart'; import '../config/notes_config.dart'; @@ -10,8 +11,25 @@ import '../config/notes_config.dart'; class BackgroundProcessor { static BackgroundProcessor? _instance; final Map>> _activeRequests = {}; + final Map>> _activeSearchRequests = {}; + final Map> _activeNormalizationRequests = {}; + final Map> _activeHashRequests = {}; + final Map>> _activeBatchRequests = {}; + final Map>> _activeParallelRequests = {}; + final Map _resultCache = {}; + final Map _cacheTimestamps = {}; int _requestCounter = 0; + // Cache settings + static const Duration _cacheExpiration = Duration(minutes: 10); + static const int _maxCacheSize = 100; + + // Performance monitoring + final Map> _performanceMetrics = {}; + final Map _operationCounts = {}; + int _cacheHits = 0; + int _cacheMisses = 0; + BackgroundProcessor._(); /// Singleton instance @@ -20,6 +38,399 @@ class BackgroundProcessor { return _instance!; } + /// Process text search in background isolate + Future> processTextSearch( + String query, + List allNotes, { + String? bookId, + }) async { + // Check cache first + final cacheKey = _generateSearchCacheKey(query, bookId, allNotes.length); + final cachedResult = _getCachedResult>(cacheKey); + if (cachedResult != null) { + return cachedResult; + } + final requestId = _generateRequestId(); + final stopwatch = Stopwatch()..start(); + + try { + // Create completer for this request + final completer = Completer>(); + _activeSearchRequests[requestId] = completer; + + // Prepare data for isolate + final isolateData = IsolateSearchData( + requestId: requestId, + query: query, + notes: allNotes, + bookId: bookId, + ); + + // Spawn isolate for heavy computation + final receivePort = ReceivePort(); + await Isolate.spawn(_searchNotesIsolate, [receivePort.sendPort, isolateData]); + + // Listen for results + receivePort.listen((message) { + if (message is IsolateSearchResult) { + final activeCompleter = _activeSearchRequests.remove(message.requestId); + if (activeCompleter != null && !activeCompleter.isCompleted) { + if (message.error != null) { + activeCompleter.completeError(message.error!); + } else { + activeCompleter.complete(message.results); + } + } + } + receivePort.close(); + }); + + // Wait for completion with timeout + final results = await completer.future.timeout( + const Duration(seconds: 10), + onTimeout: () { + _activeSearchRequests.remove(requestId); + throw TimeoutException('Search timed out', const Duration(seconds: 10)); + }, + ); + + // Track search performance + NotesTelemetry.trackSearchPerformance( + query, + results.length, + stopwatch.elapsed, + ); + + // Track internal performance + _trackPerformance('text_search', stopwatch.elapsed); + + // Cache the results + _cacheResult(cacheKey, results); + + return results; + } catch (e) { + _activeSearchRequests.remove(requestId); + rethrow; + } + } + + /// Process hash generation in background isolate + Future processHashGeneration( + String text, + ) async { + // Check cache first + final cacheKey = _generateHashCacheKey(text); + final cachedResult = _getCachedResult(cacheKey); + if (cachedResult != null) { + return cachedResult; + } + + final requestId = _generateRequestId(); + final stopwatch = Stopwatch()..start(); + + try { + // Create completer for this request + final completer = Completer(); + _activeHashRequests[requestId] = completer; + + // Prepare data for isolate + final isolateData = IsolateHashData( + requestId: requestId, + text: text, + ); + + // Spawn isolate for heavy computation + final receivePort = ReceivePort(); + await Isolate.spawn(_generateHashIsolate, [receivePort.sendPort, isolateData]); + + // Listen for results + receivePort.listen((message) { + if (message is IsolateHashResult) { + final activeCompleter = _activeHashRequests.remove(message.requestId); + if (activeCompleter != null && !activeCompleter.isCompleted) { + if (message.error != null) { + activeCompleter.completeError(message.error!); + } else { + activeCompleter.complete(message.result!); + } + } + } + receivePort.close(); + }); + + // Wait for completion with timeout + final result = await completer.future.timeout( + const Duration(seconds: 5), + onTimeout: () { + _activeHashRequests.remove(requestId); + throw TimeoutException('Hash generation timed out', const Duration(seconds: 5)); + }, + ); + + // Track performance + _trackPerformance('hash_generation', stopwatch.elapsed); + + // Cache the result + _cacheResult(cacheKey, result); + + return result; + } catch (e) { + _activeHashRequests.remove(requestId); + rethrow; + } + } + + /// Process text normalization in background isolate + Future processTextNormalization( + String text, + Map configData, + ) async { + // Check cache first + final cacheKey = _generateNormalizationCacheKey(text, configData); + final cachedResult = _getCachedResult(cacheKey); + if (cachedResult != null) { + return cachedResult; + } + + final requestId = _generateRequestId(); + final stopwatch = Stopwatch()..start(); + + try { + // Create completer for this request + final completer = Completer(); + _activeNormalizationRequests[requestId] = completer; + + // Prepare data for isolate + final isolateData = IsolateNormalizationData( + requestId: requestId, + text: text, + configData: configData, + ); + + // Spawn isolate for heavy computation + final receivePort = ReceivePort(); + await Isolate.spawn(_normalizeTextIsolate, [receivePort.sendPort, isolateData]); + + // Listen for results + receivePort.listen((message) { + if (message is IsolateNormalizationResult) { + final activeCompleter = _activeNormalizationRequests.remove(message.requestId); + if (activeCompleter != null && !activeCompleter.isCompleted) { + if (message.error != null) { + activeCompleter.completeError(message.error!); + } else { + activeCompleter.complete(message.result); + } + } + } + receivePort.close(); + }); + + // Wait for completion with timeout + final result = await completer.future.timeout( + const Duration(seconds: 5), + onTimeout: () { + _activeNormalizationRequests.remove(requestId); + throw TimeoutException('Normalization timed out', const Duration(seconds: 5)); + }, + ); + + // Track performance + _trackPerformance('text_normalization', stopwatch.elapsed); + + // Cache the result + _cacheResult(cacheKey, result); + + return result; + } catch (e) { + _activeNormalizationRequests.remove(requestId); + rethrow; + } + } + + /// Process batch operations on notes in background isolate + Future> processBatchOperation( + String operationType, + List notes, + Map parameters, + ) async { + final requestId = _generateRequestId(); + final stopwatch = Stopwatch()..start(); + + try { + // Create completer for this request + final completer = Completer>(); + _activeBatchRequests[requestId] = completer; + + // Prepare data for isolate + final isolateData = IsolateBatchData( + requestId: requestId, + operationType: operationType, + notes: notes, + parameters: parameters, + ); + + // Spawn isolate for heavy computation + final receivePort = ReceivePort(); + await Isolate.spawn(_batchOperationIsolate, [receivePort.sendPort, isolateData]); + + // Listen for results + receivePort.listen((message) { + if (message is IsolateBatchResult) { + final activeCompleter = _activeBatchRequests.remove(message.requestId); + if (activeCompleter != null && !activeCompleter.isCompleted) { + if (message.error != null) { + activeCompleter.completeError(message.error!); + } else { + activeCompleter.complete(message.result); + } + } + } + receivePort.close(); + }); + + // Wait for completion with timeout (longer for batch operations) + final result = await completer.future.timeout( + Duration(seconds: 30 + (notes.length ~/ 10)), // Scale with number of notes + onTimeout: () { + _activeBatchRequests.remove(requestId); + throw TimeoutException('Batch operation timed out', Duration(seconds: 30 + (notes.length ~/ 10))); + }, + ); + + // Track batch performance + NotesTelemetry.trackPerformanceMetric( + 'batch_$operationType', + stopwatch.elapsed, + ); + + return result; + } catch (e) { + _activeBatchRequests.remove(requestId); + rethrow; + } + } + + /// Process multiple operations in parallel isolates + Future> processParallelOperations( + List items, + String operationType, + Map parameters, { + int? maxConcurrency, + }) async { + final requestId = _generateRequestId(); + final stopwatch = Stopwatch()..start(); + + // Determine optimal concurrency based on system and data size + final concurrency = maxConcurrency ?? _calculateOptimalConcurrency(items.length); + + try { + // Create completer for this request + final completer = Completer>(); + _activeParallelRequests[requestId] = completer; + + // Split items into chunks for parallel processing + final chunks = _splitIntoChunks(items, concurrency); + + // Process chunks in parallel isolates + final futures = >>[]; + + for (int i = 0; i < chunks.length; i++) { + final chunkRequestId = '${requestId}_chunk_$i'; + final isolateData = IsolateParallelData( + requestId: chunkRequestId, + operationType: operationType, + items: chunks[i], + parameters: parameters, + ); + + futures.add(_processChunkInIsolate(isolateData)); + } + + // Wait for all chunks to complete + final results = await Future.wait(futures); + + // Flatten results + final flatResults = []; + for (final chunkResult in results) { + flatResults.addAll(chunkResult); + } + + // Track parallel performance + NotesTelemetry.trackPerformanceMetric( + 'parallel_$operationType', + stopwatch.elapsed, + ); + + _activeParallelRequests.remove(requestId); + return flatResults.cast(); + } catch (e) { + _activeParallelRequests.remove(requestId); + rethrow; + } + } + + /// Calculate optimal concurrency based on data size and system capabilities + int _calculateOptimalConcurrency(int itemCount) { + // Base concurrency on available processors (simulate with reasonable defaults) + const maxConcurrency = 4; // Reasonable default for most systems + + if (itemCount < 10) return 1; + if (itemCount < 50) return 2; + if (itemCount < 200) return 3; + return maxConcurrency; + } + + /// Split items into chunks for parallel processing + List> _splitIntoChunks(List items, int chunkCount) { + if (items.isEmpty || chunkCount <= 0) return []; + if (chunkCount >= items.length) return items.map((item) => [item]).toList(); + + final chunks = >[]; + final chunkSize = (items.length / chunkCount).ceil(); + + for (int i = 0; i < items.length; i += chunkSize) { + final end = (i + chunkSize < items.length) ? i + chunkSize : items.length; + chunks.add(items.sublist(i, end)); + } + + return chunks; + } + + /// Process a chunk of items in an isolate + Future> _processChunkInIsolate(IsolateParallelData data) async { + final completer = Completer>(); + + try { + // Spawn isolate for chunk processing + final receivePort = ReceivePort(); + await Isolate.spawn(_parallelChunkIsolate, [receivePort.sendPort, data]); + + // Listen for results + receivePort.listen((message) { + if (message is IsolateParallelResult) { + if (message.error != null) { + completer.completeError(message.error!); + } else { + completer.complete(message.results ?? []); + } + } + receivePort.close(); + }); + + // Wait for completion with timeout + return await completer.future.timeout( + Duration(seconds: 10 + (data.items.length ~/ 5)), // Scale with chunk size + onTimeout: () { + throw TimeoutException('Parallel chunk processing timed out', + Duration(seconds: 10 + (data.items.length ~/ 5))); + }, + ); + } catch (e) { + rethrow; + } + } + /// Process re-anchoring for multiple notes in background isolate Future> processReanchoring( List notes, @@ -102,6 +513,41 @@ class BackgroundProcessor { } } _activeRequests.clear(); + + for (final entry in _activeSearchRequests.entries) { + if (!entry.value.isCompleted) { + entry.value.completeError('All requests cancelled'); + } + } + _activeSearchRequests.clear(); + + for (final entry in _activeNormalizationRequests.entries) { + if (!entry.value.isCompleted) { + entry.value.completeError('All requests cancelled'); + } + } + _activeNormalizationRequests.clear(); + + for (final entry in _activeHashRequests.entries) { + if (!entry.value.isCompleted) { + entry.value.completeError('All requests cancelled'); + } + } + _activeHashRequests.clear(); + + for (final entry in _activeBatchRequests.entries) { + if (!entry.value.isCompleted) { + entry.value.completeError('All requests cancelled'); + } + } + _activeBatchRequests.clear(); + + for (final entry in _activeParallelRequests.entries) { + if (!entry.value.isCompleted) { + entry.value.completeError('All requests cancelled'); + } + } + _activeParallelRequests.clear(); } /// Generate unique request ID with epoch for stale work detection @@ -122,9 +568,18 @@ class BackgroundProcessor { /// Get statistics about active requests Map getProcessingStats() { return { - 'active_requests': _activeRequests.length, + 'active_reanchoring_requests': _activeRequests.length, + 'active_search_requests': _activeSearchRequests.length, + 'active_normalization_requests': _activeNormalizationRequests.length, + 'active_hash_requests': _activeHashRequests.length, + 'active_batch_requests': _activeBatchRequests.length, + 'active_parallel_requests': _activeParallelRequests.length, + 'total_active_requests': _activeRequests.length + _activeSearchRequests.length + _activeNormalizationRequests.length + _activeHashRequests.length + _activeBatchRequests.length + _activeParallelRequests.length, 'request_counter': _requestCounter, 'oldest_request_age': _getOldestRequestAge(), + 'cache_stats': getCacheStats(), + 'performance_stats': getPerformanceStats(), + 'performance_recommendations': getPerformanceRecommendations(), }; } @@ -147,18 +602,215 @@ class BackgroundProcessor { return oldestEpoch != null ? now - oldestEpoch : null; } -} -/// Static method to run in isolate for re-anchoring notes -void _reanchorNotesIsolate(List args) async { - final sendPort = args[0] as SendPort; - final data = args[1] as IsolateReanchoringData; - - try { - final results = []; - final anchoringService = AnchoringService.instance; + /// Check if result is cached and still valid + T? _getCachedResult(String cacheKey) { + final timestamp = _cacheTimestamps[cacheKey]; + if (timestamp == null) { + _cacheMisses++; + return null; + } - // Process each note with timeout + // Check if cache is expired + if (DateTime.now().difference(timestamp) > _cacheExpiration) { + _resultCache.remove(cacheKey); + _cacheTimestamps.remove(cacheKey); + _cacheMisses++; + return null; + } + + _cacheHits++; + return _resultCache[cacheKey] as T?; + } + + /// Cache a result with timestamp + void _cacheResult(String cacheKey, T result) { + // Clean old cache entries if we're at capacity + if (_resultCache.length >= _maxCacheSize) { + _cleanOldCacheEntries(); + } + + _resultCache[cacheKey] = result; + _cacheTimestamps[cacheKey] = DateTime.now(); + } + + /// Clean old cache entries to make room for new ones + void _cleanOldCacheEntries() { + final now = DateTime.now(); + final expiredKeys = []; + + // Find expired entries + for (final entry in _cacheTimestamps.entries) { + if (now.difference(entry.value) > _cacheExpiration) { + expiredKeys.add(entry.key); + } + } + + // Remove expired entries + for (final key in expiredKeys) { + _resultCache.remove(key); + _cacheTimestamps.remove(key); + } + + // If still at capacity, remove oldest entries + if (_resultCache.length >= _maxCacheSize) { + final sortedEntries = _cacheTimestamps.entries.toList() + ..sort((a, b) => a.value.compareTo(b.value)); + + final toRemove = sortedEntries.take(_maxCacheSize ~/ 4); // Remove 25% + for (final entry in toRemove) { + _resultCache.remove(entry.key); + _cacheTimestamps.remove(entry.key); + } + } + } + + /// Generate cache key for search operations + String _generateSearchCacheKey(String query, String? bookId, int notesCount) { + return 'search_${query.hashCode}_${bookId ?? 'all'}_$notesCount'; + } + + /// Generate cache key for normalization operations + String _generateNormalizationCacheKey(String text, Map config) { + return 'normalize_${text.hashCode}_${config.hashCode}'; + } + + /// Generate cache key for hash operations + String _generateHashCacheKey(String text) { + return 'hash_${text.hashCode}'; + } + + /// Clear all cached results + void clearCache() { + _resultCache.clear(); + _cacheTimestamps.clear(); + } + + /// Get cache statistics + Map getCacheStats() { + final now = DateTime.now(); + int expiredCount = 0; + + for (final timestamp in _cacheTimestamps.values) { + if (now.difference(timestamp) > _cacheExpiration) { + expiredCount++; + } + } + + return { + 'total_cached_items': _resultCache.length, + 'expired_items': expiredCount, + 'cache_hit_ratio': _calculateCacheHitRatio(), + 'cache_size_bytes': _estimateCacheSize(), + }; + } + + /// Calculate cache hit ratio + double _calculateCacheHitRatio() { + final totalRequests = _cacheHits + _cacheMisses; + return totalRequests > 0 ? _cacheHits / totalRequests : 0.0; + } + + /// Estimate cache size in bytes (simplified) + int _estimateCacheSize() { + // Rough estimation - in real implementation you'd want more accurate measurement + return _resultCache.length * 1024; // Assume 1KB per entry on average + } + + /// Track performance metric for an operation + void _trackPerformance(String operationType, Duration duration) { + _performanceMetrics.putIfAbsent(operationType, () => []); + _performanceMetrics[operationType]!.add(duration); + + // Keep only last 100 measurements per operation + if (_performanceMetrics[operationType]!.length > 100) { + _performanceMetrics[operationType]!.removeAt(0); + } + + _operationCounts[operationType] = (_operationCounts[operationType] ?? 0) + 1; + } + + /// Get performance statistics for all operations + Map getPerformanceStats() { + final stats = {}; + + for (final entry in _performanceMetrics.entries) { + final durations = entry.value; + if (durations.isNotEmpty) { + final totalMs = durations.fold(0, (sum, d) => sum + d.inMilliseconds); + final avgMs = totalMs / durations.length; + final minMs = durations.map((d) => d.inMilliseconds).reduce((a, b) => a < b ? a : b); + final maxMs = durations.map((d) => d.inMilliseconds).reduce((a, b) => a > b ? a : b); + + stats[entry.key] = { + 'count': _operationCounts[entry.key] ?? 0, + 'average_ms': avgMs.round(), + 'min_ms': minMs, + 'max_ms': maxMs, + 'total_ms': totalMs, + }; + } + } + + return { + 'operations': stats, + 'cache_hits': _cacheHits, + 'cache_misses': _cacheMisses, + 'cache_hit_ratio': _calculateCacheHitRatio(), + 'total_operations': _operationCounts.values.fold(0, (sum, count) => sum + count), + }; + } + + /// Reset performance statistics + void resetPerformanceStats() { + _performanceMetrics.clear(); + _operationCounts.clear(); + _cacheHits = 0; + _cacheMisses = 0; + } + + /// Get recommendations for performance optimization + List getPerformanceRecommendations() { + final recommendations = []; + final stats = getPerformanceStats(); + + // Check cache hit ratio + final hitRatio = stats['cache_hit_ratio'] as double; + if (hitRatio < 0.5) { + recommendations.add('Consider increasing cache size or expiration time - current hit ratio: ${(hitRatio * 100).toStringAsFixed(1)}%'); + } + + // Check for slow operations + final operations = stats['operations'] as Map; + for (final entry in operations.entries) { + final opStats = entry.value as Map; + final avgMs = opStats['average_ms'] as int; + + if (avgMs > 1000) { + recommendations.add('${entry.key} operations are slow (avg: ${avgMs}ms) - consider optimization'); + } + } + + // Check for high operation counts + final totalOps = stats['total_operations'] as int; + if (totalOps > 1000) { + recommendations.add('High operation count ($totalOps) - consider batching or caching strategies'); + } + + return recommendations; + } +} + +/// Static method to run in isolate for re-anchoring notes +void _reanchorNotesIsolate(List args) async { + final sendPort = args[0] as SendPort; + final data = args[1] as IsolateReanchoringData; + + try { + final results = []; + final anchoringService = AnchoringService.instance; + + // Process each note with timeout for (final note in data.notes) { try { final stopwatch = Stopwatch()..start(); @@ -245,4 +897,623 @@ class TimeoutException implements Exception { @override String toString() => 'TimeoutException: $message (timeout: $timeout)'; +} + +/// Static method to run in isolate for searching notes +void _searchNotesIsolate(List args) async { + final sendPort = args[0] as SendPort; + final data = args[1] as IsolateSearchData; + + try { + final results = []; + final queryLower = data.query.toLowerCase(); + + // Simple text search in content and tags + for (final note in data.notes) { + // Filter by book if specified + if (data.bookId != null && note.bookId != data.bookId) { + continue; + } + + // Search in content + if (note.contentMarkdown.toLowerCase().contains(queryLower)) { + results.add(note); + continue; + } + + // Search in tags + if (note.tags.any((tag) => tag.toLowerCase().contains(queryLower))) { + results.add(note); + continue; + } + + // Search in selected text + if (note.selectedTextNormalized.toLowerCase().contains(queryLower)) { + results.add(note); + continue; + } + } + + // Sort by relevance (simple scoring) + results.sort((a, b) { + int scoreA = _calculateRelevanceScore(a, queryLower); + int scoreB = _calculateRelevanceScore(b, queryLower); + return scoreB.compareTo(scoreA); // Higher score first + }); + + // Send results back + sendPort.send(IsolateSearchResult( + requestId: data.requestId, + results: results, + )); + } catch (e) { + // Send error back + sendPort.send(IsolateSearchResult( + requestId: data.requestId, + error: e.toString(), + )); + } +} + +/// Calculate relevance score for search results +int _calculateRelevanceScore(Note note, String queryLower) { + int score = 0; + + // Content matches (highest priority) + final contentMatches = queryLower.allMatches(note.contentMarkdown.toLowerCase()).length; + score += contentMatches * 10; + + // Tag matches (medium priority) + for (final tag in note.tags) { + if (tag.toLowerCase().contains(queryLower)) { + score += 5; + } + } + + // Selected text matches (lower priority) + final selectedMatches = queryLower.allMatches(note.selectedTextNormalized.toLowerCase()).length; + score += selectedMatches * 3; + + // Boost recent notes + final daysSinceUpdate = DateTime.now().difference(note.updatedAt).inDays; + if (daysSinceUpdate < 7) { + score += 2; + } else if (daysSinceUpdate < 30) { + score += 1; + } + + return score; +} + +/// Static method to run in isolate for text normalization +void _normalizeTextIsolate(List args) async { + final sendPort = args[0] as SendPort; + final data = args[1] as IsolateNormalizationData; + + try { + // Reconstruct normalization config from data + final config = NormalizationConfig.fromMap(data.configData); + + // Perform normalization + final result = TextNormalizer.normalize(data.text, config); + + // Send result back + sendPort.send(IsolateNormalizationResult( + requestId: data.requestId, + result: result, + )); + } catch (e) { + // Send error back + sendPort.send(IsolateNormalizationResult( + requestId: data.requestId, + error: e.toString(), + )); + } +} + +/// Data structure for search isolate communication +class IsolateSearchData { + final String requestId; + final String query; + final List notes; + final String? bookId; + + const IsolateSearchData({ + required this.requestId, + required this.query, + required this.notes, + this.bookId, + }); +} + +/// Result structure for search isolate communication +class IsolateSearchResult { + final String requestId; + final List? results; + final String? error; + + const IsolateSearchResult({ + required this.requestId, + this.results, + this.error, + }); +} + +/// Data structure for normalization isolate communication +class IsolateNormalizationData { + final String requestId; + final String text; + final Map configData; + + const IsolateNormalizationData({ + required this.requestId, + required this.text, + required this.configData, + }); +} + +/// Result structure for normalization isolate communication +class IsolateNormalizationResult { + final String requestId; + final String? result; + final String? error; + + const IsolateNormalizationResult({ + required this.requestId, + this.result, + this.error, + }); +} + +/// Static method to run in isolate for hash generation +void _generateHashIsolate(List args) async { + final sendPort = args[0] as SendPort; + final data = args[1] as IsolateHashData; + + try { + // Import hash generator in isolate + final result = _generateTextHashInIsolate(data.text); + + // Send result back + sendPort.send(IsolateHashResult( + requestId: data.requestId, + result: result, + )); + } catch (e) { + // Send error back + sendPort.send(IsolateHashResult( + requestId: data.requestId, + error: e.toString(), + )); + } +} + +/// Generate hash in isolate (simple implementation for isolate) +String _generateTextHashInIsolate(String text) { + // Simple hash implementation for isolate + // Using a basic hash algorithm that doesn't require external dependencies + int hash = 0; + for (int i = 0; i < text.length; i++) { + hash = ((hash << 5) - hash + text.codeUnitAt(i)) & 0xffffffff; + } + return hash.abs().toString(); +} + +/// Data structure for hash isolate communication +class IsolateHashData { + final String requestId; + final String text; + + const IsolateHashData({ + required this.requestId, + required this.text, + }); +} + +/// Result structure for hash isolate communication +class IsolateHashResult { + final String requestId; + final String? result; + final String? error; + + const IsolateHashResult({ + required this.requestId, + this.result, + this.error, + }); +} + +/// Static method to run in isolate for batch operations +void _batchOperationIsolate(List args) async { + final sendPort = args[0] as SendPort; + final data = args[1] as IsolateBatchData; + + try { + Map result = {}; + + switch (data.operationType) { + case 'export': + result = await _exportNotesInIsolate(data.notes, data.parameters); + break; + case 'validate': + result = await _validateNotesInIsolate(data.notes, data.parameters); + break; + case 'statistics': + result = await _calculateStatisticsInIsolate(data.notes, data.parameters); + break; + case 'cleanup': + result = await _cleanupNotesInIsolate(data.notes, data.parameters); + break; + default: + throw ArgumentError('Unknown operation type: ${data.operationType}'); + } + + // Send result back + sendPort.send(IsolateBatchResult( + requestId: data.requestId, + result: result, + )); + } catch (e) { + // Send error back + sendPort.send(IsolateBatchResult( + requestId: data.requestId, + error: e.toString(), + )); + } +} + +/// Export notes to various formats in isolate +Future> _exportNotesInIsolate( + List notes, + Map parameters, +) async { + final format = parameters['format'] as String? ?? 'json'; + final includeMetadata = parameters['includeMetadata'] as bool? ?? true; + + final exportedNotes = >[]; + + for (final note in notes) { + final noteData = { + 'id': note.id, + 'content': note.contentMarkdown, + 'selectedText': note.selectedTextNormalized, + 'tags': note.tags, + 'bookId': note.bookId, + }; + + if (includeMetadata) { + noteData.addAll({ + 'createdAt': note.createdAt.toIso8601String(), + 'updatedAt': note.updatedAt.toIso8601String(), + 'anchorData': { + 'charStart': note.charStart, + 'charEnd': note.charEnd, + 'contextBefore': note.contextBefore, + 'contextAfter': note.contextAfter, + }, + }); + } + + exportedNotes.add(noteData); + } + + return { + 'format': format, + 'count': notes.length, + 'data': exportedNotes, + 'exportedAt': DateTime.now().toIso8601String(), + }; +} + +/// Validate notes integrity in isolate +Future> _validateNotesInIsolate( + List notes, + Map parameters, +) async { + final issues = >[]; + int validNotes = 0; + + for (final note in notes) { + final noteIssues = []; + + // Check for empty content + if (note.contentMarkdown.trim().isEmpty) { + noteIssues.add('Empty content'); + } + + // Check for invalid anchor data + if (note.charStart < 0 || note.charEnd <= note.charStart) { + noteIssues.add('Invalid anchor positions'); + } + + // Check for missing selected text + if (note.selectedTextNormalized.trim().isEmpty) { + noteIssues.add('Missing selected text'); + } + + // Check for future dates + if (note.createdAt.isAfter(DateTime.now()) || note.updatedAt.isAfter(DateTime.now())) { + noteIssues.add('Future timestamp'); + } + + if (noteIssues.isNotEmpty) { + issues.add({ + 'noteId': note.id, + 'issues': noteIssues, + }); + } else { + validNotes++; + } + } + + return { + 'totalNotes': notes.length, + 'validNotes': validNotes, + 'invalidNotes': issues.length, + 'issues': issues, + 'validationDate': DateTime.now().toIso8601String(), + }; +} + +/// Calculate statistics about notes in isolate +Future> _calculateStatisticsInIsolate( + List notes, + Map parameters, +) async { + final bookStats = {}; + final tagStats = {}; + final monthlyStats = {}; + + int totalCharacters = 0; + int totalWords = 0; + DateTime? oldestNote; + DateTime? newestNote; + + for (final note in notes) { + // Book statistics + bookStats[note.bookId] = (bookStats[note.bookId] ?? 0) + 1; + + // Tag statistics + for (final tag in note.tags) { + tagStats[tag] = (tagStats[tag] ?? 0) + 1; + } + + // Monthly statistics + final monthKey = '${note.createdAt.year}-${note.createdAt.month.toString().padLeft(2, '0')}'; + monthlyStats[monthKey] = (monthlyStats[monthKey] ?? 0) + 1; + + // Content statistics + totalCharacters += note.contentMarkdown.length; + totalWords += note.contentMarkdown.split(RegExp(r'\s+')).length; + + // Date range + if (oldestNote == null || note.createdAt.isBefore(oldestNote)) { + oldestNote = note.createdAt; + } + if (newestNote == null || note.createdAt.isAfter(newestNote)) { + newestNote = note.createdAt; + } + } + + return { + 'totalNotes': notes.length, + 'totalCharacters': totalCharacters, + 'totalWords': totalWords, + 'averageCharactersPerNote': notes.isNotEmpty ? totalCharacters / notes.length : 0, + 'averageWordsPerNote': notes.isNotEmpty ? totalWords / notes.length : 0, + 'oldestNote': oldestNote?.toIso8601String(), + 'newestNote': newestNote?.toIso8601String(), + 'bookStats': bookStats, + 'tagStats': tagStats, + 'monthlyStats': monthlyStats, + 'calculatedAt': DateTime.now().toIso8601String(), + }; +} + +/// Cleanup and optimize notes data in isolate +Future> _cleanupNotesInIsolate( + List notes, + Map parameters, +) async { + final duplicates = []; + final emptyNotes = []; + final orphanedNotes = []; + final suggestions = >[]; + + final contentHashes = {}; + + for (final note in notes) { + // Check for duplicates by content hash + final contentHash = _generateTextHashInIsolate(note.contentMarkdown); + if (contentHashes.containsKey(contentHash)) { + duplicates.add(note.id); + } else { + contentHashes[contentHash] = note.id; + } + + // Check for empty notes + if (note.contentMarkdown.trim().isEmpty) { + emptyNotes.add(note.id); + } + + // Check for potentially orphaned notes (invalid anchor positions) + if (note.charStart < 0 || note.charEnd <= note.charStart) { + orphanedNotes.add(note.id); + } + + // Generate cleanup suggestions + if (note.tags.isEmpty && note.contentMarkdown.length > 100) { + suggestions.add({ + 'noteId': note.id, + 'type': 'add_tags', + 'message': 'Consider adding tags to this note for better organization', + }); + } + + if (note.contentMarkdown.length < 10) { + suggestions.add({ + 'noteId': note.id, + 'type': 'expand_content', + 'message': 'This note has very short content, consider expanding it', + }); + } + } + + return { + 'totalNotes': notes.length, + 'duplicates': duplicates, + 'emptyNotes': emptyNotes, + 'orphanedNotes': orphanedNotes, + 'suggestions': suggestions, + 'cleanupDate': DateTime.now().toIso8601String(), + }; +} + +/// Data structure for batch isolate communication +class IsolateBatchData { + final String requestId; + final String operationType; + final List notes; + final Map parameters; + + const IsolateBatchData({ + required this.requestId, + required this.operationType, + required this.notes, + required this.parameters, + }); +} + +/// Result structure for batch isolate communication +class IsolateBatchResult { + final String requestId; + final Map? result; + final String? error; + + const IsolateBatchResult({ + required this.requestId, + this.result, + this.error, + }); +} + +/// Static method to run in isolate for parallel chunk processing +void _parallelChunkIsolate(List args) async { + final sendPort = args[0] as SendPort; + final data = args[1] as IsolateParallelData; + + try { + final results = []; + + switch (data.operationType) { + case 'normalize_texts': + for (final item in data.items) { + if (item is String) { + // Simple normalization in isolate + final normalized = item.trim().toLowerCase(); + results.add(normalized); + } + } + break; + + case 'generate_hashes': + for (final item in data.items) { + if (item is String) { + final hash = _generateTextHashInIsolate(item); + results.add(hash); + } + } + break; + + case 'validate_notes': + for (final item in data.items) { + if (item is Note) { + final isValid = _validateNoteInIsolate(item); + results.add(isValid); + } + } + break; + + case 'extract_keywords': + for (final item in data.items) { + if (item is String) { + final keywords = _extractKeywordsInIsolate(item); + results.add(keywords); + } + } + break; + + default: + throw ArgumentError('Unknown parallel operation: ${data.operationType}'); + } + + // Send results back + sendPort.send(IsolateParallelResult( + requestId: data.requestId, + results: results, + )); + } catch (e) { + // Send error back + sendPort.send(IsolateParallelResult( + requestId: data.requestId, + error: e.toString(), + )); + } +} + +/// Validate a single note in isolate +bool _validateNoteInIsolate(Note note) { + // Basic validation checks + if (note.contentMarkdown.trim().isEmpty) return false; + if (note.charStart < 0 || note.charEnd <= note.charStart) return false; + if (note.selectedTextNormalized.trim().isEmpty) return false; + if (note.createdAt.isAfter(DateTime.now())) return false; + if (note.updatedAt.isAfter(DateTime.now())) return false; + + return true; +} + +/// Extract keywords from text in isolate +List _extractKeywordsInIsolate(String text) { + // Simple keyword extraction + final words = text.toLowerCase() + .replaceAll(RegExp(r'[^\w\s\u0590-\u05FF]'), ' ') // Keep Hebrew and Latin + .split(RegExp(r'\s+')) + .where((word) => word.length > 2) + .toSet() + .toList(); + + // Sort by length (longer words first) + words.sort((a, b) => b.length.compareTo(a.length)); + + // Return top keywords + return words.take(10).toList(); +} + +/// Data structure for parallel isolate communication +class IsolateParallelData { + final String requestId; + final String operationType; + final List items; + final Map parameters; + + const IsolateParallelData({ + required this.requestId, + required this.operationType, + required this.items, + required this.parameters, + }); +} + +/// Result structure for parallel isolate communication +class IsolateParallelResult { + final String requestId; + final List? results; + final String? error; + + const IsolateParallelResult({ + required this.requestId, + this.results, + this.error, + }); } \ No newline at end of file diff --git a/lib/notes/services/text_normalizer.dart b/lib/notes/services/text_normalizer.dart index fca20947f..0decda52f 100644 --- a/lib/notes/services/text_normalizer.dart +++ b/lib/notes/services/text_normalizer.dart @@ -1,5 +1,6 @@ import '../config/notes_config.dart'; import '../utils/text_utils.dart'; +import 'background_processor.dart'; /// Service for normalizing text to ensure consistent hashing and matching. /// @@ -120,6 +121,26 @@ class TextNormalizer { ); } + /// Normalize text asynchronously using background processor for large texts + static Future normalizeAsync(String text, NormalizationConfig config) async { + // For small texts, use synchronous normalization + if (text.length < 10000) { + return normalize(text, config); + } + + // For large texts, use background processor + try { + final backgroundProcessor = BackgroundProcessor.instance; + return await backgroundProcessor.processTextNormalization( + text, + config.toMap(), + ); + } catch (e) { + // Fallback to synchronous normalization + return normalize(text, config); + } + } + /// Validate that text normalization is stable static bool validateNormalization(String text, NormalizationConfig config) { final normalized1 = normalize(text, config); diff --git a/lib/notes/widgets/notes_sidebar.dart b/lib/notes/widgets/notes_sidebar.dart index 085672110..17448af1c 100644 --- a/lib/notes/widgets/notes_sidebar.dart +++ b/lib/notes/widgets/notes_sidebar.dart @@ -32,17 +32,27 @@ class _NotesSidebarState extends State { String _searchQuery = ''; NoteSortOption _sortOption = NoteSortOption.dateDesc; NoteStatusFilter _statusFilter = NoteStatusFilter.all; + Timer? _refreshTimer; + DateTime? _lastRefresh; @override void initState() { super.initState(); _loadNotes(); - - // רענון ההערות כל 2 שניות כדי לתפוס הערות חדשות - Timer.periodic(const Duration(seconds: 2), (timer) { + + // רענון ההערות כל 10 שניות + _refreshTimer = Timer.periodic(const Duration(seconds: 10), (timer) { if (mounted && widget.bookId != null) { + // בדיקה אם עבר מספיק זמן מהרענון האחרון + final now = DateTime.now(); + if (_lastRefresh != null && + now.difference(_lastRefresh!).inSeconds < 8) { + return; // דלג על הרענון אם עבר פחות מ-8 שניות + } + try { context.read().add(LoadNotesEvent(widget.bookId!)); + _lastRefresh = now; } catch (e) { // אם ה-BLoC לא זמין, נעצור את הטיימר timer.cancel(); @@ -65,9 +75,10 @@ class _NotesSidebarState extends State { if (widget.bookId != null) { try { context.read().add(LoadNotesEvent(widget.bookId!)); + _lastRefresh = DateTime.now(); } catch (e) { // BLoC not available yet - will be handled in build method - print('NotesBloc not available: $e'); + debugPrint('NotesBloc not available: $e'); } } } @@ -76,11 +87,11 @@ class _NotesSidebarState extends State { setState(() { _searchQuery = query.trim(); }); - + if (_searchQuery.isNotEmpty) { final stopwatch = Stopwatch()..start(); context.read().add(SearchNotesEvent(_searchQuery)); - + // Track search performance NotesTelemetry.trackSearchPerformance( _searchQuery, @@ -162,7 +173,8 @@ class _NotesSidebarState extends State { }); // Navigate to note position if possible - if (note.status != NoteStatus.orphan && widget.onNavigateToPosition != null) { + if (note.status != NoteStatus.orphan && + widget.onNavigateToPosition != null) { widget.onNavigateToPosition!(note.charStart, note.charEnd); } @@ -190,7 +202,7 @@ class _NotesSidebarState extends State { onPressed: () { Navigator.of(context).pop(); context.read().add(DeleteNoteEvent(note.id)); - + NotesTelemetry.trackUserAction('note_deleted', { 'note_count': 1, 'status': note.status.name, @@ -216,8 +228,8 @@ class _NotesSidebarState extends State { child: Text( 'הערות אישיות', style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + fontWeight: FontWeight.bold, + ), ), ), if (widget.onClose != null) @@ -239,7 +251,7 @@ class _NotesSidebarState extends State { // Search field TextField( controller: _searchController, - onChanged: _onSearchChanged, + onChanged: _onSearchChanged, decoration: InputDecoration( hintText: 'חפש הערות...', prefixIcon: const Icon(Icons.search), @@ -257,10 +269,10 @@ class _NotesSidebarState extends State { borderRadius: BorderRadius.circular(8.0), ), ), - ), - + ), + const SizedBox(height: 8), - + Row( children: [ Flexible( @@ -272,7 +284,8 @@ class _NotesSidebarState extends State { labelText: 'מיון', isDense: true, border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + contentPadding: + EdgeInsets.symmetric(horizontal: 8, vertical: 4), ), items: NoteSortOption.values.map((option) { return DropdownMenuItem( @@ -295,7 +308,8 @@ class _NotesSidebarState extends State { labelText: 'סטטוס', isDense: true, border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + contentPadding: + EdgeInsets.symmetric(horizontal: 8, vertical: 4), ), items: NoteStatusFilter.values.map((filter) { return DropdownMenuItem( @@ -316,133 +330,145 @@ class _NotesSidebarState extends State { // Notes list Expanded( - child: Builder( - builder: (context) { - try { - return BlocBuilder( - builder: (context, state) { - if (state is NotesLoading) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - if (state is NotesError) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline, - size: 48, - color: Theme.of(context).colorScheme.error, - ), - const SizedBox(height: 16), - Text( - 'שגיאה בטעינת הערות', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - state.message, - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadNotes, - child: const Text('נסה שוב'), - ), - ], - ), - ); - } - - List notes = []; - if (state is NotesLoaded) { - notes = state.notes; - } else if (state is NotesSearchResults) { - notes = state.results; - } - - final filteredNotes = _filterAndSortNotes(notes); - - if (filteredNotes.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - _searchQuery.isNotEmpty - ? Icons.search_off - : Icons.note_add_outlined, - size: 48, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 16), - Text( - _searchQuery.isNotEmpty - ? 'לא נמצאו תוצאות' - : 'אין הערות אישיות עדיין', - style: Theme.of(context).textTheme.titleMedium, + child: Builder( + builder: (context) { + try { + return BlocBuilder( + buildWhen: (previous, current) { + // רק rebuild אם יש שינוי אמיתי במצב + return previous.runtimeType != current.runtimeType || + (current is NotesLoaded && + previous is NotesLoaded && + current.notes.length != previous.notes.length) || + (current is NotesSearchResults && + previous is NotesSearchResults && + current.results.length != previous.results.length); + }, + builder: (context, state) { + if (state is NotesLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (state is NotesError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + 'שגיאה בטעינת הערות', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + state.message, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadNotes, + child: const Text('נסה שוב'), + ), + ], ), - const SizedBox(height: 8), - Text( - _searchQuery.isNotEmpty - ? 'נסה מילות חיפוש אחרות' - : 'בחר טקסט והוסף הערה אישית ראשונה', - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, + ); + } + + List notes = []; + if (state is NotesLoaded) { + notes = state.notes; + } else if (state is NotesSearchResults) { + notes = state.results; + } + + final filteredNotes = _filterAndSortNotes(notes); + + if (filteredNotes.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _searchQuery.isNotEmpty + ? Icons.search_off + : Icons.note_add_outlined, + size: 48, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + _searchQuery.isNotEmpty + ? 'לא נמצאו תוצאות' + : 'אין הערות אישיות עדיין', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + _searchQuery.isNotEmpty + ? 'נסה מילות חיפוש אחרות' + : 'בחר טקסט והוסף הערה אישית ראשונה', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], ), - ], - ), - ); - } - - return ListView.builder( - itemCount: filteredNotes.length, - itemBuilder: (context, index) { - final note = filteredNotes[index]; - return _NoteListItem( - note: note, - onPressed: () => _onNotePressed(note), - onEdit: () => _onEditNote(note), - onDelete: () => _onDeleteNote(note), + ); + } + + return ListView.builder( + itemCount: filteredNotes.length, + itemBuilder: (context, index) { + final note = filteredNotes[index]; + return _NoteListItem( + note: note, + onPressed: () => _onNotePressed(note), + onEdit: () => _onEditNote(note), + onDelete: () => _onDeleteNote(note), + ); + }, ); }, ); - }, - ); - } catch (e) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline, - size: 48, - color: Theme.of(context).colorScheme.error, - ), - const SizedBox(height: 16), - Text( - 'שגיאה בטעינת הערות', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - 'NotesBloc לא זמין. נסה לעשות restart לאפליקציה.', - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - ], - ), - ); - } - }, - ), + } catch (e) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + 'שגיאה בטעינת הערות', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'NotesBloc לא זמין. נסה לעשות restart לאפליקציה.', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ); + } + }, ), - ], - ); + ), + ], + ); } String _getSortOptionLabel(NoteSortOption option) { @@ -473,6 +499,7 @@ class _NotesSidebarState extends State { @override void dispose() { + _refreshTimer?.cancel(); _searchController.dispose(); super.dispose(); } @@ -514,8 +541,9 @@ class _NoteListItem extends StatelessWidget { child: Text( _formatDate(note.updatedAt), style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ), PopupMenuButton( @@ -554,9 +582,9 @@ class _NoteListItem extends StatelessWidget { ), ], ), - + const SizedBox(height: 8), - + // Note content preview Text( note.contentMarkdown, @@ -564,7 +592,7 @@ class _NoteListItem extends StatelessWidget { maxLines: 3, overflow: TextOverflow.ellipsis, ), - + // Tags if any if (note.tags.isNotEmpty) ...[ const SizedBox(height: 8), @@ -593,7 +621,7 @@ class _NoteListItem extends StatelessWidget { String _formatDate(DateTime date) { final now = DateTime.now(); final difference = now.difference(date); - + if (difference.inDays == 0) { return 'היום ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; } else if (difference.inDays == 1) { @@ -660,4 +688,4 @@ enum NoteStatusFilter { anchored, shifted, orphan, -} \ No newline at end of file +} diff --git a/lib/text_book/bloc/text_book_bloc.dart b/lib/text_book/bloc/text_book_bloc.dart index bf1a723f8..11fdbda67 100644 --- a/lib/text_book/bloc/text_book_bloc.dart +++ b/lib/text_book/bloc/text_book_bloc.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:otzaria/text_book/bloc/text_book_event.dart'; import 'package:otzaria/text_book/text_book_repository.dart'; @@ -74,13 +75,20 @@ class TextBookBloc extends Bloc { final ItemPositionsListener positionsListener = ItemPositionsListener.create(); - // Set up position listener + // Set up position listener with debouncing to prevent excessive updates + Timer? debounceTimer; positionsListener.itemPositions.addListener(() { - final visibleInecies = - positionsListener.itemPositions.value.map((e) => e.index).toList(); - if (visibleInecies.isNotEmpty) { - add(UpdateVisibleIndecies(visibleInecies)); - } + // Cancel previous timer if exists + debounceTimer?.cancel(); + + // Set new timer with 100ms delay + debounceTimer = Timer(const Duration(milliseconds: 100), () { + final visibleInecies = + positionsListener.itemPositions.value.map((e) => e.index).toList(); + if (visibleInecies.isNotEmpty) { + add(UpdateVisibleIndecies(visibleInecies)); + } + }); }); emit(TextBookLoaded( @@ -188,9 +196,18 @@ class TextBookBloc extends Bloc { ) async { if (state is TextBookLoaded) { final currentState = state as TextBookLoaded; - String? newTitle; + + // בדיקה אם האינדקסים באמת השתנו + if (_listsEqual(currentState.visibleIndices, event.visibleIndecies)) { + return; // אין שינוי, לא צריך לעדכן + } + + String? newTitle = currentState.currentTitle; - if (event.visibleIndecies.isNotEmpty) { + // עדכון הכותרת רק אם האינדקס הראשון השתנה + if (event.visibleIndecies.isNotEmpty && + (currentState.visibleIndices.isEmpty || + currentState.visibleIndices.first != event.visibleIndecies.first)) { newTitle = await refFromIndex(event.visibleIndecies.first, Future.value(currentState.tableOfContents)); } @@ -206,6 +223,15 @@ class TextBookBloc extends Bloc { selectedIndex: index)); } } + + /// בדיקה אם שתי רשימות שוות + bool _listsEqual(List list1, List list2) { + if (list1.length != list2.length) return false; + for (int i = 0; i < list1.length; i++) { + if (list1[i] != list2[i]) return false; + } + return true; + } void _onUpdateSelectedIndex( UpdateSelectedIndex event, diff --git a/lib/text_book/view/combined_view/combined_book_screen.dart b/lib/text_book/view/combined_view/combined_book_screen.dart index 9bb8146be..25c0b5445 100644 --- a/lib/text_book/view/combined_view/combined_book_screen.dart +++ b/lib/text_book/view/combined_view/combined_book_screen.dart @@ -662,19 +662,11 @@ $htmlContentToUse ), Expanded( flex: 1, - child: NotesSidebar( + child: _NotesSection( bookId: widget.tab.book.title, onClose: () => context .read() .add(const ToggleNotesSidebar()), - onNavigateToPosition: (start, end) { - // ניווט למיקום ההערה בטקסט - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('ניווט למיקום $start-$end'), - ), - ); - }, ), ), ], @@ -686,3 +678,29 @@ $htmlContentToUse ); } } +/// Widget נפרד לסרגל ההערות כדי למנוע rebuilds מיותרים של הטקסט +class _NotesSection extends StatelessWidget { + final String bookId; + final VoidCallback onClose; + + const _NotesSection({ + required this.bookId, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + return NotesSidebar( + bookId: bookId, + onClose: onClose, + onNavigateToPosition: (start, end) { + // ניווט למיקום ההערה בטקסט + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('ניווט למיקום $start-$end'), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/text_book/view/splited_view/simple_book_view.dart b/lib/text_book/view/splited_view/simple_book_view.dart index ac7704935..90f7ca764 100644 --- a/lib/text_book/view/splited_view/simple_book_view.dart +++ b/lib/text_book/view/splited_view/simple_book_view.dart @@ -648,21 +648,11 @@ $htmlContentToUse ), Expanded( flex: 1, - child: NotesSidebar( + child: _NotesSection( bookId: widget.tab.book.title, onClose: () => context .read() .add(const ToggleNotesSidebar()), - onNavigateToPosition: (start, end) { - // ניווט למיקום ההערה בטקסט - // זה יצריך חישוב של האינדקס המתאים - // לעת עתה נציג הודעה - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('ניווט למיקום $start-$end'), - ), - ); - }, ), ), ], @@ -674,3 +664,32 @@ $htmlContentToUse ); } } + +/// Widget נפרד לסרגל ההערות כדי למנוע rebuilds מיותרים של הטקסט +class _NotesSection extends StatelessWidget { + final String bookId; + final VoidCallback onClose; + + const _NotesSection({ + required this.bookId, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + return NotesSidebar( + bookId: bookId, + onClose: onClose, + onNavigateToPosition: (start, end) { + // ניווט למיקום ההערה בטקסט + // זה יצריך חישוב של האינדקס המתאים + // לעת עתה נציג הודעה + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('ניווט למיקום $start-$end'), + ), + ); + }, + ); + } +} \ No newline at end of file From 466d699b89223c8c6cdafcf154c2bc2afa4b2f62 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 24 Aug 2025 21:40:31 +0300 Subject: [PATCH 139/197] =?UTF-8?q?=D7=94=D7=A7=D7=98=D7=A0=D7=AA=20X=20?= =?UTF-8?q?=D7=91=D7=9B=D7=A8=D7=98=D7=99=D7=A1=D7=99=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/tabs/reading_screen.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/tabs/reading_screen.dart b/lib/tabs/reading_screen.dart index d2da36b57..cffb41ce3 100644 --- a/lib/tabs/reading_screen.dart +++ b/lib/tabs/reading_screen.dart @@ -340,6 +340,12 @@ class _ReadingScreenState extends State 'ctrl+w') .toUpperCase(), child: IconButton( + constraints: const BoxConstraints( + minWidth: 25, + minHeight: 25, + maxWidth: 25, + maxHeight: 25, + ), onPressed: () => closeTab(tab, context), icon: const Icon(Icons.close, size: 10), ), From 925987acbdeff61f6e520322fd6bae4ac084e9b7 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 25 Aug 2025 15:49:13 +0300 Subject: [PATCH 140/197] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20=D7=9B?= =?UTF-8?q?=D7=AA=D7=95=D7=91=D7=AA=20=D7=A9=D7=9C=D7=99=D7=97=D7=AA=20?= =?UTF-8?q?=D7=A9=D7=92=D7=99=D7=90=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/services/phone_report_service.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/services/phone_report_service.dart b/lib/services/phone_report_service.dart index 7e8b94a71..8f40e567c 100644 --- a/lib/services/phone_report_service.dart +++ b/lib/services/phone_report_service.dart @@ -7,7 +7,7 @@ import '../models/phone_report_data.dart'; /// Service for submitting phone error reports to Google Apps Script class PhoneReportService { static const String _endpoint = - 'https://script.google.com/macros/s/AKfycbwlEoUMQf-QwTvnLqk3jD8eIgptRAKR5Rzwx67CxD0xYu6SpWupeE4SI3o9BS3eE5fs/exec'; + 'https://script.google.com/macros/s/AKfycbyhLP5nbbRN33TFb7kR625BNZzzmhEljT8vX9bgckd6Vx6KPSz9Fgh9aDEk4rZe36Bf/exec'; static const Duration _timeout = Duration(seconds: 10); static const int _maxRetries = 2; From 761c015421e386c65738c42e9758700164fdd654 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 29 Aug 2025 00:54:05 +0300 Subject: [PATCH 141/197] =?UTF-8?q?=D7=A9=D7=99=D7=A4=D7=95=D7=A8=D7=99=20?= =?UTF-8?q?UI=20=D7=A7=D7=9C=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/library/view/library_browser.dart | 38 ++++----- lib/navigation/main_window_screen.dart | 1 + lib/navigation/more_screen.dart | 11 ++- lib/tabs/reading_screen.dart | 78 ++++++++++--------- .../combined_view/combined_book_screen.dart | 15 ++-- .../view/splited_view/simple_book_view.dart | 16 ++-- 6 files changed, 87 insertions(+), 72 deletions(-) diff --git a/lib/library/view/library_browser.dart b/lib/library/view/library_browser.dart index f978248ae..3794d6515 100644 --- a/lib/library/view/library_browser.dart +++ b/lib/library/view/library_browser.dart @@ -86,12 +86,29 @@ class _LibraryBrowserState extends State return Scaffold( appBar: AppBar( - title: Row( - mainAxisSize: MainAxisSize.min, + title: Stack( + alignment: Alignment.center, children: [ + Align( + alignment: Alignment.centerLeft, + child: DafYomi( + onDafYomiTap: (tractate, daf) { + openDafYomiBook(context, tractate, ' $daf.'); + }, + ), + ), + Text( + state.currentCategory?.title ?? '', + style: TextStyle( + color: Theme.of(context).colorScheme.secondary, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), Align( alignment: Alignment.centerRight, child: Row( + mainAxisSize: MainAxisSize.min, children: [ // קבוצת חזור ובית IconButton( @@ -177,23 +194,6 @@ class _LibraryBrowserState extends State ], ), ), - Expanded( - child: Center( - child: Text( - state.currentCategory?.title ?? '', - style: TextStyle( - color: Theme.of(context).colorScheme.secondary, - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - DafYomi( - onDafYomiTap: (tractate, daf) { - openDafYomiBook(context, tractate, ' $daf.'); - }, - ), ], ), ), diff --git a/lib/navigation/main_window_screen.dart b/lib/navigation/main_window_screen.dart index 823834969..d34d79440 100644 --- a/lib/navigation/main_window_screen.dart +++ b/lib/navigation/main_window_screen.dart @@ -254,6 +254,7 @@ class MainWindowScreenState extends State ), ), ), + const VerticalDivider(thickness: 1, width: 1), Expanded(child: pageView), ], ); diff --git a/lib/navigation/more_screen.dart b/lib/navigation/more_screen.dart index 5d7f3f29b..c2529ce27 100644 --- a/lib/navigation/more_screen.dart +++ b/lib/navigation/more_screen.dart @@ -34,7 +34,16 @@ class _MoreScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text(_getTitle(_selectedIndex)), + backgroundColor: + Theme.of(context).colorScheme.primary.withOpacity(0.15), + title: Text( + _getTitle(_selectedIndex), + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), centerTitle: true, actions: _getActions(context, _selectedIndex), ), diff --git a/lib/tabs/reading_screen.dart b/lib/tabs/reading_screen.dart index cffb41ce3..524b464b5 100644 --- a/lib/tabs/reading_screen.dart +++ b/lib/tabs/reading_screen.dart @@ -156,44 +156,50 @@ class _ReadingScreenState extends State return Scaffold( appBar: AppBar( - title: Container( - constraints: const BoxConstraints(maxHeight: 50), - child: TabBar( - controller: controller, - isScrollable: true, - tabAlignment: TabAlignment.center, - tabs: state.tabs - .map((tab) => _buildTab(context, tab, state)) - .toList(), - ), - ), - leadingWidth: 280, - leading: Row( - mainAxisSize: MainAxisSize.min, + title: Stack( children: [ - // קבוצת היסטוריה וסימניות - IconButton( - icon: const Icon(Icons.history), - tooltip: 'הצג היסטוריה', - onPressed: () => _showHistoryDialog(context), - ), - IconButton( - icon: const Icon(Icons.bookmark), - tooltip: 'הצג סימניות', - onPressed: () => _showBookmarksDialog(context), - ), - // קו מפריד - Container( - height: 24, - width: 1, - color: Colors.grey.shade400, - margin: const EdgeInsets.symmetric(horizontal: 2), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // קבוצת היסטוריה וסימניות + IconButton( + icon: const Icon(Icons.history), + tooltip: 'הצג היסטוריה', + onPressed: () => _showHistoryDialog(context), + ), + IconButton( + icon: const Icon(Icons.bookmark), + tooltip: 'הצג סימניות', + onPressed: () => _showBookmarksDialog(context), + ), + // קו מפריד + Container( + height: 24, + width: 1, + color: Colors.grey.shade400, + margin: const EdgeInsets.symmetric(horizontal: 2), + ), + // קבוצת שולחן עבודה עם אנימציה + SizedBox( + width: 180, // רוחב קבוע למניעת הזזת הטאבים + child: WorkspaceIconButton( + onPressed: () => + _showSaveWorkspaceDialog(context), + ), + ), + ], ), - // קבוצת שולחן עבודה עם אנימציה - SizedBox( - width: 180, // רוחב קבוע למניעת הזזת הטאבים - child: WorkspaceIconButton( - onPressed: () => _showSaveWorkspaceDialog(context), + Center( + child: Container( + constraints: const BoxConstraints(maxHeight: 50), + child: TabBar( + controller: controller, + isScrollable: true, + tabAlignment: TabAlignment.center, + tabs: state.tabs + .map((tab) => _buildTab(context, tab, state)) + .toList(), + ), ), ), ], diff --git a/lib/text_book/view/combined_view/combined_book_screen.dart b/lib/text_book/view/combined_view/combined_book_screen.dart index 25c0b5445..0f77e9a0a 100644 --- a/lib/text_book/view/combined_view/combined_book_screen.dart +++ b/lib/text_book/view/combined_view/combined_book_screen.dart @@ -616,15 +616,15 @@ $htmlContentToUse data = utils.replaceHolyNames(data); } return HtmlWidget( - state.removeNikud - ? utils.highLight( - utils.removeVolwels('$data\n'), - state.searchText, - ) - : utils.highLight('$data\n', state.searchText), + ''' +
+ ${state.removeNikud ? utils.highLight(utils.removeVolwels('$data\n'), state.searchText) : utils.highLight('$data\n', state.searchText)} +
+ ''', textStyle: TextStyle( fontSize: widget.textSize, fontFamily: settingsState.fontFamily, + height: 1.5, ), ); }, @@ -678,6 +678,7 @@ $htmlContentToUse ); } } + /// Widget נפרד לסרגל ההערות כדי למנוע rebuilds מיותרים של הטקסט class _NotesSection extends StatelessWidget { final String bookId; @@ -703,4 +704,4 @@ class _NotesSection extends StatelessWidget { }, ); } -} \ No newline at end of file +} diff --git a/lib/text_book/view/splited_view/simple_book_view.dart b/lib/text_book/view/splited_view/simple_book_view.dart index 90f7ca764..535500844 100644 --- a/lib/text_book/view/splited_view/simple_book_view.dart +++ b/lib/text_book/view/splited_view/simple_book_view.dart @@ -615,16 +615,14 @@ $htmlContentToUse style: TextStyle( fontSize: widget.textSize, fontFamily: settingsState.fontFamily, + height: 1.5, ), child: HtmlWidget( - // remove nikud if needed - state.removeNikud - ? utils.highLight( - utils.removeVolwels('$data\n'), - state.searchText, - ) - : utils.highLight( - '$data\n', state.searchText), + ''' +
+ ${state.removeNikud ? utils.highLight(utils.removeVolwels('$data\n'), state.searchText) : utils.highLight('$data\n', state.searchText)} +
+ ''', ), ), ); @@ -692,4 +690,4 @@ class _NotesSection extends StatelessWidget { }, ); } -} \ No newline at end of file +} From 41a5816fc708c32688d0461b18b47977839bcf4f Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 29 Aug 2025 01:08:45 +0300 Subject: [PATCH 142/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=91?= =?UTF-8?q?=D7=A2=D7=99=D7=99=D7=AA=20=D7=91=D7=97=D7=99=D7=A8=D7=AA=20?= =?UTF-8?q?=D7=94=D7=98=D7=A7=D7=A1=D7=98,=20=D7=95=D7=94=D7=92=D7=9C?= =?UTF-8?q?=D7=99=D7=9C=D7=94=20=D7=94=D7=90=D7=99=D7=A0=D7=A1=D7=95=D7=A4?= =?UTF-8?q?=D7=99=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/text_book/view/text_book_screen.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index c9220ba24..614595eae 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -77,6 +77,7 @@ class _TextBookViewerBlocState extends State static const String _reportSeparator = '=============================='; static const String _reportSeparator2 = '------------------------------'; static const String _fallbackMail = 'otzaria.200@gmail.com'; + bool _isInitialFocusDone = false; String? encodeQueryParameters(Map params) { return params.entries @@ -1170,12 +1171,13 @@ $detailsSection Widget _buildTabBar(TextBookLoaded state) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (state.showLeftPane && !Platform.isAndroid) { + if (state.showLeftPane && !Platform.isAndroid && !_isInitialFocusDone) { if (tabController.index == 1) { textSearchFocusNode.requestFocus(); } else if (tabController.index == 0) { navigationSearchFocusNode.requestFocus(); } + _isInitialFocusDone = true; } }); return ValueListenableBuilder( From 77eb0e88a0be9a57b457bf11524c69cd74966559 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 29 Aug 2025 14:05:21 +0300 Subject: [PATCH 143/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=A9?= =?UTF-8?q?=D7=92=D7=99=D7=90=D7=AA=20=D7=99=D7=A6=D7=99=D7=A8=D7=AA=20?= =?UTF-8?q?=D7=94=D7=A2=D7=A8=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app.dart | 2 + lib/core/app_paths.dart | 20 +++ lib/core/scaffold_messenger.dart | 4 + lib/main.dart | 10 +- lib/notes/data/notes_data_provider.dart | 161 +++++++++++++++--------- 5 files changed, 130 insertions(+), 67 deletions(-) create mode 100644 lib/core/app_paths.dart create mode 100644 lib/core/scaffold_messenger.dart diff --git a/lib/app.dart b/lib/app.dart index b9f8d7e58..4e4cbbf67 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:otzaria/core/scaffold_messenger.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:otzaria/settings/settings_bloc.dart'; import 'package:otzaria/settings/settings_state.dart'; @@ -14,6 +15,7 @@ class App extends StatelessWidget { builder: (context, settingsState) { final state = settingsState; return MaterialApp( + scaffoldMessengerKey: scaffoldMessengerKey, localizationsDelegates: const [ GlobalCupertinoLocalizations.delegate, GlobalMaterialLocalizations.delegate, diff --git a/lib/core/app_paths.dart b/lib/core/app_paths.dart new file mode 100644 index 000000000..2b91e5b22 --- /dev/null +++ b/lib/core/app_paths.dart @@ -0,0 +1,20 @@ +import 'dart:io'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart'; + +Future resolveNotesDbPath(String fileName) async { + if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { + // Windows: this will go into %APPDATA% (Roaming) - exactly what was requested + final support = await getApplicationSupportDirectory(); + final dbDir = Directory(p.join(support.path, 'databases')); + if (!await dbDir.exists()) await dbDir.create(recursive: true); + return p.join(dbDir.path, fileName); + } else { + // Mobile: the standard path for sqflite + final dbs = await getDatabasesPath(); + final dbDir = Directory(dbs); + if (!await dbDir.exists()) await dbDir.create(recursive: true); + return p.join(dbs, fileName); + } +} diff --git a/lib/core/scaffold_messenger.dart b/lib/core/scaffold_messenger.dart new file mode 100644 index 000000000..522575d6d --- /dev/null +++ b/lib/core/scaffold_messenger.dart @@ -0,0 +1,4 @@ +import 'package:flutter/material.dart'; + +final GlobalKey scaffoldMessengerKey = + GlobalKey(); diff --git a/lib/main.dart b/lib/main.dart index e8cd7a73f..9e3faab0d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -41,7 +41,6 @@ import 'package:otzaria/data/data_providers/hive_data_provider.dart'; import 'package:otzaria/notes/data/database_schema.dart'; import 'package:otzaria/notes/bloc/notes_bloc.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; - import 'package:search_engine/search_engine.dart'; /// Application entry point that initializes necessary components and launches the app. @@ -153,14 +152,14 @@ Future initialize() async { sqfliteFfiInit(); databaseFactory = databaseFactoryFfi; } - + await RustLib.init(); await Settings.init(cacheProvider: HiveCache()); await initLibraryPath(); await initHive(); await createDirs(); await loadCerts(); - + // Initialize notes database try { await DatabaseSchema.initializeDatabase(); @@ -211,10 +210,9 @@ Future initLibraryPath() async { // Check existing library path setting String? libraryPath = Settings.getValue('key-library-path'); - if (libraryPath == null && (Platform.isLinux || Platform.isMacOS)) { + if (libraryPath == null && (Platform.isLinux || Platform.isMacOS)) { // Use the working directory for Linux and macOS - await Settings.setValue( - 'key-library-path', '.'); + await Settings.setValue('key-library-path', '.'); } // Set default Windows path if not configured diff --git a/lib/notes/data/notes_data_provider.dart b/lib/notes/data/notes_data_provider.dart index f53678440..6ec15a0af 100644 --- a/lib/notes/data/notes_data_provider.dart +++ b/lib/notes/data/notes_data_provider.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:sqflite/sqflite.dart'; -import 'package:path/path.dart'; +import 'package:path/path.dart' as p; +import 'package:otzaria/core/app_paths.dart'; import '../models/note.dart'; import '../config/notes_config.dart'; @@ -27,19 +28,43 @@ class NotesDataProvider { /// Initialize the database with schema and optimizations Future _initDatabase() async { - // Always use persistent database - even in debug mode - // In-memory database would lose data when app closes - - final databasesPath = await getDatabasesPath(); - final path = join(databasesPath, NotesEnvironment.databasePath); - - return await openDatabase( - path, - version: DatabaseConfig.databaseVersion, - onCreate: _onCreate, - onUpgrade: _onUpgrade, - onOpen: _onOpen, - ); + final newPath = await resolveNotesDbPath(NotesEnvironment.databasePath); + + // Migrate old database file (if it exists) + final oldBase = await getDatabasesPath(); + final oldPath = p.join(oldBase, NotesEnvironment.databasePath); + + try { + final parent = Directory(p.dirname(newPath)); + if (!await parent.exists()) await parent.create(recursive: true); + + if (!await File(newPath).exists() && await File(oldPath).exists()) { + // move instead of copy to preserve permissions and filename + await File(oldPath).rename(newPath); + } + + return await openDatabase( + newPath, + version: DatabaseConfig.databaseVersion, + onCreate: _onCreate, + onUpgrade: _onUpgrade, + onOpen: _onOpen, + ); + } catch (e, st) { + // Write detailed log next to the DB (what the user can send) + final logPath = p.join(p.dirname(newPath), 'notes_db_error.log'); + final log = [ + 'When: ${DateTime.now().toIso8601String()}', + 'OS: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}', + 'Tried path: $newPath', + 'Error: $e', + 'Stack:\n$st', + ].join('\n') + + '\n\n'; + await File(logPath).writeAsString(log, mode: FileMode.append); + + rethrow; // rethrow to the upper layers - the UI will show a neat message + } } /// Create database schema on first run @@ -93,10 +118,10 @@ class NotesDataProvider { Future validateSchema() async { try { final db = await database; - + for (final entry in DatabaseSchema.validationQueries.entries) { final result = await db.rawQuery(entry.value); - + switch (entry.key) { case 'notes_table_exists': case 'canonical_docs_table_exists': @@ -113,7 +138,7 @@ class NotesDataProvider { break; } } - + return true; } catch (e) { return false; @@ -123,7 +148,7 @@ class NotesDataProvider { /// Create a new note Future createNote(Note note) async { final db = await database; - + await db.transaction((txn) async { await txn.insert( DatabaseConfig.notesTable, @@ -131,21 +156,21 @@ class NotesDataProvider { conflictAlgorithm: ConflictAlgorithm.replace, ); }); - + return note; } /// Get a note by ID Future getNoteById(String noteId) async { final db = await database; - + final result = await db.query( DatabaseConfig.notesTable, where: 'note_id = ?', whereArgs: [noteId], limit: 1, ); - + if (result.isEmpty) return null; return Note.fromJson(result.first); } @@ -153,26 +178,26 @@ class NotesDataProvider { /// Get all notes for a book Future> getNotesForBook(String bookId) async { final db = await database; - + final result = await db.query( DatabaseConfig.notesTable, where: 'book_id = ?', whereArgs: [bookId], orderBy: 'char_start ASC', ); - + return result.map((json) => Note.fromJson(json)).toList(); } /// Get all notes across all books Future> getAllNotes() async { final db = await database; - + final result = await db.query( DatabaseConfig.notesTable, orderBy: 'updated_at DESC', ); - + return result.map((json) => Note.fromJson(json)).toList(); } @@ -183,7 +208,7 @@ class NotesDataProvider { int endChar, ) async { final db = await database; - + final result = await db.query( DatabaseConfig.notesTable, where: ''' @@ -192,19 +217,27 @@ class NotesDataProvider { (char_end >= ? AND char_end <= ?) OR (char_start <= ? AND char_end >= ?)) ''', - whereArgs: [bookId, startChar, endChar, startChar, endChar, startChar, endChar], + whereArgs: [ + bookId, + startChar, + endChar, + startChar, + endChar, + startChar, + endChar + ], orderBy: 'char_start ASC', ); - + return result.map((json) => Note.fromJson(json)).toList(); } /// Update an existing note Future updateNote(Note note) async { final db = await database; - + final updatedNote = note.copyWith(updatedAt: DateTime.now()); - + await db.transaction((txn) async { await txn.update( DatabaseConfig.notesTable, @@ -213,14 +246,14 @@ class NotesDataProvider { whereArgs: [note.id], ); }); - + return updatedNote; } /// Delete a note Future deleteNote(String noteId) async { final db = await database; - + await db.transaction((txn) async { await txn.delete( DatabaseConfig.notesTable, @@ -233,15 +266,15 @@ class NotesDataProvider { /// Search notes using FTS Future> searchNotes(String query, {String? bookId}) async { final db = await database; - + String whereClause = 'notes_fts MATCH ?'; List whereArgs = [query]; - + if (bookId != null) { whereClause += ' AND notes.book_id = ?'; whereArgs.add(bookId); } - + final result = await db.rawQuery(''' SELECT notes.* FROM notes_fts JOIN notes ON notes.rowid = notes_fts.rowid @@ -249,29 +282,30 @@ class NotesDataProvider { ORDER BY bm25(notes_fts) ASC LIMIT 100 ''', whereArgs); - + return result.map((json) => Note.fromJson(json)).toList(); } /// Get notes by status - Future> getNotesByStatus(NoteStatus status, {String? bookId}) async { + Future> getNotesByStatus(NoteStatus status, + {String? bookId}) async { final db = await database; - + String whereClause = 'status = ?'; List whereArgs = [status.name]; - + if (bookId != null) { whereClause += ' AND book_id = ?'; whereArgs.add(bookId); } - + final result = await db.query( DatabaseConfig.notesTable, where: whereClause, whereArgs: whereArgs, orderBy: 'updated_at DESC', ); - + return result.map((json) => Note.fromJson(json)).toList(); } @@ -281,20 +315,22 @@ class NotesDataProvider { } /// Update note status (for re-anchoring) - Future updateNoteStatus(String noteId, NoteStatus status, { + Future updateNoteStatus( + String noteId, + NoteStatus status, { int? newStart, int? newEnd, }) async { final db = await database; - + final updateData = { 'status': status.name, 'updated_at': DateTime.now().toIso8601String(), }; - + if (newStart != null) updateData['char_start'] = newStart; if (newEnd != null) updateData['char_end'] = newEnd; - + await db.transaction((txn) async { await txn.update( DatabaseConfig.notesTable, @@ -308,7 +344,7 @@ class NotesDataProvider { /// Batch update multiple notes (for re-anchoring) Future batchUpdateNotes(List notes) async { final db = await database; - + await db.transaction((txn) async { for (final note in notes) { await txn.update( @@ -324,19 +360,23 @@ class NotesDataProvider { /// Get database statistics Future> getDatabaseStats() async { final db = await database; - + final notesCount = Sqflite.firstIntValue( - await db.rawQuery('SELECT COUNT(*) FROM notes'), - ) ?? 0; - + await db.rawQuery('SELECT COUNT(*) FROM notes'), + ) ?? + 0; + final canonicalDocsCount = Sqflite.firstIntValue( - await db.rawQuery('SELECT COUNT(*) FROM canonical_documents'), - ) ?? 0; - + await db.rawQuery('SELECT COUNT(*) FROM canonical_documents'), + ) ?? + 0; + final orphanNotesCount = Sqflite.firstIntValue( - await db.rawQuery("SELECT COUNT(*) FROM notes WHERE status = 'orphan'"), - ) ?? 0; - + await db + .rawQuery("SELECT COUNT(*) FROM notes WHERE status = 'orphan'"), + ) ?? + 0; + return { 'total_notes': notesCount, 'canonical_documents': canonicalDocsCount, @@ -355,13 +395,12 @@ class NotesDataProvider { /// Reset the database (for testing) Future reset() async { await close(); - final databasesPath = await getDatabasesPath(); - final path = join(databasesPath, NotesEnvironment.databasePath); - + final path = await resolveNotesDbPath(NotesEnvironment.databasePath); + if (await File(path).exists()) { await File(path).delete(); } - + _database = null; } -} \ No newline at end of file +} From 8001abef58451f2d21768fa17a466d6c6eeb1b4b Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 29 Aug 2025 19:03:19 +0300 Subject: [PATCH 144/197] =?UTF-8?q?=D7=9E=D7=A0=D7=99=D7=A2=D7=AA=20=D7=9B?= =?UTF-8?q?=D7=A4=D7=99=D7=9C=D7=95=D7=99=D7=95=D7=AA=20=D7=91=D7=90=D7=99?= =?UTF-8?q?=D7=AA=D7=95=D7=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/find_ref/find_ref_repository.dart | 54 ++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/lib/find_ref/find_ref_repository.dart b/lib/find_ref/find_ref_repository.dart index 5150d0cc6..8e1e09e84 100644 --- a/lib/find_ref/find_ref_repository.dart +++ b/lib/find_ref/find_ref_repository.dart @@ -9,7 +9,57 @@ class FindRefRepository { FindRefRepository({required this.dataRepository}); Future> findRefs(String ref) async { - return await TantivyDataProvider.instance - .searchRefs(replaceParaphrases(removeSectionNames(ref)), 100, false); + // שלב 1: שלוף יותר תוצאות מהרגיל כדי לפצות על אלו שיסוננו + final rawResults = await TantivyDataProvider.instance + .searchRefs(replaceParaphrases(removeSectionNames(ref)), 300, false); + + // שלב 2: בצע סינון כפילויות (דה-דופליקציה) חכם + final unique = _dedupeRefs(rawResults); + + // שלב 3: החזר עד 100 תוצאות ייחודיות + return unique.length > 100 + ? unique.take(100).toList(growable: false) + : unique; + } + + /// מסננת רשימת תוצאות ומשאירה רק את הייחודיות על בסיס מפתח מורכב. + List _dedupeRefs(List results) { + final seen = {}; // סט לשמירת מפתחות שכבר נראו + final out = []; + + for (final r in results) { + // יצירת מפתח ייחודי חכם שמורכב מ-3 חלקים: + + // 1. טקסט ההפניה לאחר נרמול + final refKey = _normalize(r.reference); + + // 2. יעד ההפניה (קובץ ספציפי או שם ספר וסוג) + final file = (r.filePath ?? '').trim().toLowerCase(); + final title = (r.title ?? '').trim().toLowerCase(); + final typ = r.isPdf ? 'pdf' : 'txt'; + final dest = file.isNotEmpty ? file : '$title|$typ'; + + // 3. המיקום המדויק בתוך היעד + final seg = _segNum(r.segment); + + // הרכבת המפתח הסופי + final key = '$refKey|$dest|$seg'; + + // הוסף לרשימת הפלט רק אם המפתח לא נראה בעבר + if (seen.add(key)) { + out.add(r); + } + } + return out; + } + + /// פונקציית עזר לנרמול טקסט: מורידה רווחים, הופכת לאותיות קטנות ומאחדת רווחים. + String _normalize(String? s) => + (s ?? '').trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '); + + /// פונקציית עזר להמרת 'segment' למספר שלם (int) בצורה בטוחה. + int _segNum(dynamic s) { + if (s is num) return s.round(); + return int.tryParse(s?.toString() ?? '') ?? 0; } } From 478acd86c6325777f8b972d63af5891a1c35b483 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sat, 30 Aug 2025 21:26:16 +0300 Subject: [PATCH 145/197] =?UTF-8?q?=D7=94=D7=A7=D7=98=D7=A0=D7=AA=20=D7=94?= =?UTF-8?q?=D7=9B=D7=AA=D7=95=D7=91=20=D7=91=D7=AA=D7=95=D7=9A=20=D7=A1?= =?UTF-8?q?=D7=95=D7=92=D7=A8=D7=99=D7=99=D7=9D=20=D7=A2=D7=92=D7=95=D7=9C?= =?UTF-8?q?=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../combined_view/combined_book_screen.dart | 9 +- .../combined_view/commentary_content.dart | 4 + .../view/splited_view/simple_book_view.dart | 14 ++- .../view/text_book_search_screen.dart | 3 + lib/utils/text_manipulation.dart | 89 ++++++++++++++++--- 5 files changed, 102 insertions(+), 17 deletions(-) diff --git a/lib/text_book/view/combined_view/combined_book_screen.dart b/lib/text_book/view/combined_view/combined_book_screen.dart index 0f77e9a0a..cc2273238 100644 --- a/lib/text_book/view/combined_view/combined_book_screen.dart +++ b/lib/text_book/view/combined_view/combined_book_screen.dart @@ -618,7 +618,14 @@ $htmlContentToUse return HtmlWidget( '''
- ${state.removeNikud ? utils.highLight(utils.removeVolwels('$data\n'), state.searchText) : utils.highLight('$data\n', state.searchText)} + ${() { + String processedData = state.removeNikud + ? utils.highLight( + utils.removeVolwels('$data\n'), state.searchText) + : utils.highLight('$data\n', state.searchText); + // החלת עיצוב הסוגריים העגולים + return utils.formatTextWithParentheses(processedData); + }()}
''', textStyle: TextStyle( diff --git a/lib/text_book/view/combined_view/commentary_content.dart b/lib/text_book/view/combined_view/commentary_content.dart index 21a6db39a..9497088fa 100644 --- a/lib/text_book/view/combined_view/commentary_content.dart +++ b/lib/text_book/view/combined_view/commentary_content.dart @@ -83,6 +83,10 @@ class _CommentaryContentState extends State { text = utils.highLight(text, widget.searchQuery, currentIndex: widget.currentSearchIndex); + + // החלת עיצוב הסוגריים העגולים + text = utils.formatTextWithParentheses(text); + return BlocBuilder( builder: (context, settingsState) { return DefaultTextStyle.merge( diff --git a/lib/text_book/view/splited_view/simple_book_view.dart b/lib/text_book/view/splited_view/simple_book_view.dart index 535500844..ea804791c 100644 --- a/lib/text_book/view/splited_view/simple_book_view.dart +++ b/lib/text_book/view/splited_view/simple_book_view.dart @@ -201,7 +201,7 @@ class _SimpleBookViewState extends State { ), ctx.MenuItem.submenu( label: 'קישורים', - enabled: LinksViewer.getLinks(state).isNotEmpty, // <--- חדש + enabled: LinksViewer.getLinks(state).isNotEmpty, items: LinksViewer.getLinks(state) .map( (link) => ctx.MenuItem( @@ -620,7 +620,17 @@ $htmlContentToUse child: HtmlWidget( '''
- ${state.removeNikud ? utils.highLight(utils.removeVolwels('$data\n'), state.searchText) : utils.highLight('$data\n', state.searchText)} + ${() { + String processedData = state.removeNikud + ? utils.highLight( + utils.removeVolwels('$data\n'), + state.searchText) + : utils.highLight( + '$data\n', state.searchText); + // החלת עיצוב הסוגריים העגולים + return utils + .formatTextWithParentheses(processedData); + }()}
''', ), diff --git a/lib/text_book/view/text_book_search_screen.dart b/lib/text_book/view/text_book_search_screen.dart index 513e61a1c..e95798f0f 100644 --- a/lib/text_book/view/text_book_search_screen.dart +++ b/lib/text_book/view/text_book_search_screen.dart @@ -179,6 +179,9 @@ class TextBookSearchViewState extends State if (settingsState.replaceHolyNames) { snippet = utils.replaceHolyNames(snippet); } + // החלת עיצוב הסוגריים העגולים + snippet = utils.formatTextWithParentheses(snippet); + return ListTile( subtitle: SearchHighlightText(snippet, searchText: result.query), diff --git a/lib/utils/text_manipulation.dart b/lib/utils/text_manipulation.dart index a6b5e8fbf..2257aa5c1 100644 --- a/lib/utils/text_manipulation.dart +++ b/lib/utils/text_manipulation.dart @@ -19,34 +19,36 @@ String removeVolwels(String s) { String highLight(String data, String searchQuery, {int currentIndex = -1}) { if (searchQuery.isEmpty) return data; - + final regex = RegExp(RegExp.escape(searchQuery), caseSensitive: false); final matches = regex.allMatches(data).toList(); - + if (matches.isEmpty) return data; - + // אם לא צוין אינדקס נוכחי, נדגיש את כל התוצאות באדום if (currentIndex == -1) { return data.replaceAll(regex, '$searchQuery'); } - + // נדגיש את התוצאה הנוכחית בכחול ואת השאר באדום String result = data; int offset = 0; - + for (int i = 0; i < matches.length; i++) { final match = matches[i]; final color = i == currentIndex ? 'blue' : 'red'; - final backgroundColor = i == currentIndex ? ' style="background-color: yellow;"' : ''; - final replacement = '${match.group(0)}'; - + final backgroundColor = + i == currentIndex ? ' style="background-color: yellow;"' : ''; + final replacement = + '${match.group(0)}'; + final start = match.start + offset; final end = match.end + offset; - + result = result.substring(0, start) + replacement + result.substring(end); offset += replacement.length - match.group(0)!.length; } - + return result; } @@ -55,13 +57,13 @@ String getTitleFromPath(String path) { .replaceAll('/', Platform.pathSeparator) .replaceAll('\\', Platform.pathSeparator); final fileName = path.split(Platform.pathSeparator).last; - + // אם אין נקודה בשם הקובץ, נחזיר את השם כמו שהוא final lastDotIndex = fileName.lastIndexOf('.'); if (lastDotIndex == -1) { return fileName; } - + // נסיר רק את הסיומת (החלק האחרון אחרי הנקודה האחרונה) return fileName.substring(0, lastDotIndex); } @@ -92,10 +94,9 @@ Future hasTopic(String title, String topic) async { return titleToPath[title]?.contains(topic) ?? false; } - Future _loadCsvCache() async { _csvCache = {}; - + try { final libraryPath = Settings.getValue('key-library-path') ?? '.'; final csvPath = @@ -186,6 +187,66 @@ final RegExp _holyNameRegex = RegExp( unicode: true, ); +/// מקטין טקסט בתוך סוגריים עגולים +/// תנאים: +/// 1. אם יש סוגר פותח נוסף בפנים - מתעלם מהסוגר החיצוני ומקטין רק את הפנימיים +/// 2. אם אין סוגר סוגר עד סוף המקטע - לא מקטין כלום +String formatTextWithParentheses(String text) { + if (text.isEmpty) return text; + + final StringBuffer result = StringBuffer(); + int i = 0; + + while (i < text.length) { + if (text[i] == '(') { + // מחפשים את הסוגר הסוגר המתאים + int openCount = 1; + int j = i + 1; + int innerOpenIndex = -1; + + // בודקים אם יש סוגר פותח נוסף בפנים + while (j < text.length && openCount > 0) { + if (text[j] == '(') { + if (innerOpenIndex == -1) { + innerOpenIndex = j; // שומרים את המיקום של הסוגר הפנימי הראשון + } + openCount++; + } else if (text[j] == ')') { + openCount--; + } + j++; + } + + // אם לא מצאנו סוגר סוגר - מוסיפים הכל כמו שהוא + if (openCount > 0) { + result.write(text[i]); + i++; + continue; + } + + // אם יש סוגר פנימי - מתעלמים מהחיצוני ומעבדים רק את הפנימי + if (innerOpenIndex != -1) { + // מוסיפים את החלק עד הסוגר הפנימי + result.write(text.substring(i, innerOpenIndex)); + // ממשיכים מהסוגר הפנימי + i = innerOpenIndex; + continue; + } + + // אם אין סוגר פנימי - מקטינים את כל התוכן + final content = text.substring(i + 1, j - 1); + result.write('('); + result.write(content); + result.write(')'); + i = j; + } else { + result.write(text[i]); + i++; + } + } + + return result.toString(); +} String replaceHolyNames(String s) { return s.replaceAllMapped( From 22fec57e653c8944acffbef0ef178cca789daa80 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sat, 30 Aug 2025 23:22:40 +0300 Subject: [PATCH 146/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=9E?= =?UTF-8?q?=D7=99=D7=93=D7=95=D7=AA=20=D7=96=D7=9E=D7=A0=D7=A0=D7=95=20?= =?UTF-8?q?=D7=9C=D7=9E=D7=9E=D7=99=D7=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../measurement_converter_screen.dart | 356 ++++++++++++++---- .../measurement_data.dart | 10 +- 2 files changed, 292 insertions(+), 74 deletions(-) diff --git a/lib/tools/measurement_converter/measurement_converter_screen.dart b/lib/tools/measurement_converter/measurement_converter_screen.dart index ec3d2258d..6b528d3bd 100644 --- a/lib/tools/measurement_converter/measurement_converter_screen.dart +++ b/lib/tools/measurement_converter/measurement_converter_screen.dart @@ -2,6 +2,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'measurement_data.dart'; +// START OF ADDITIONS - MODERN UNITS +const List modernLengthUnits = ['ס"מ', 'מטר', 'ק"מ']; +const List modernAreaUnits = ['מ"ר', 'דונם']; +const List modernVolumeUnits = ['סמ"ק', 'ליטר']; +const List modernWeightUnits = ['גרם', 'ק"ג']; +// END OF ADDITIONS + class MeasurementConverterScreen extends StatefulWidget { const MeasurementConverterScreen({super.key}); @@ -19,11 +26,12 @@ class _MeasurementConverterScreenState final TextEditingController _inputController = TextEditingController(); final TextEditingController _resultController = TextEditingController(); + // Updated to include modern units final Map> _units = { - 'אורך': lengthConversionFactors.keys.toList(), - 'שטח': areaConversionFactors.keys.toList(), - 'נפח': volumeConversionFactors.keys.toList(), - 'משקל': weightConversionFactors.keys.toList(), + 'אורך': lengthConversionFactors.keys.toList()..addAll(modernLengthUnits), + 'שטח': areaConversionFactors.keys.toList()..addAll(modernAreaUnits), + 'נפח': volumeConversionFactors.keys.toList()..addAll(modernVolumeUnits), + 'משקל': weightConversionFactors.keys.toList()..addAll(modernWeightUnits), 'זמן': timeConversionFactors.keys.first.isNotEmpty ? timeConversionFactors[timeConversionFactors.keys.first]!.keys.toList() : [], @@ -53,6 +61,112 @@ class _MeasurementConverterScreenState }); } + // Helper function to handle small inconsistencies in unit names + // e.g., 'אצבעות' vs 'אצבע', 'רביעיות' vs 'רביעית' + String _normalizeUnitName(String unit) { + const Map normalizationMap = { + 'אצבעות': 'אצבע', + 'טפחים': 'טפח', + 'זרתות': 'זרת', + 'אמות': 'אמה', + 'קנים': 'קנה', + 'מילים': 'מיל', + 'פרסאות': 'פרסה', + 'בית רובע': 'בית רובע', + 'בית קב': 'בית קב', + 'בית סאה': 'בית סאה', + 'בית סאתיים': 'בית סאתיים', + 'בית לתך': 'בית לתך', + 'בית כור': 'בית כור', + 'רביעיות': 'רביעית', + 'לוגים': 'לוג', + 'קבים': 'קב', + 'עשרונות': 'עשרון', + 'הינים': 'הין', + 'סאים': 'סאה', + 'איפות': 'איפה', + 'לתכים': 'לתך', + 'כורים': 'כור', + 'דינרים': 'דינר', + 'שקלים': 'שקל', + 'סלעים': 'סלע', + 'טרטימרים': 'טרטימר', + 'מנים': 'מנה', + 'ככרות': 'כיכר', + 'קנטרים': 'קנטר', + }; + return normalizationMap[unit] ?? unit; + } + + // Core logic to get the conversion factor from any unit to a base modern unit + double? _getFactorToBaseUnit(String category, String unit, String opinion) { + final normalizedUnit = _normalizeUnitName(unit); + + switch (category) { + case 'אורך': // Base unit: cm + if (modernLengthUnits.contains(unit)) { + if (unit == 'ס"מ') return 1.0; + if (unit == 'מטר') return 100.0; + if (unit == 'ק"מ') return 100000.0; + } else { + final value = modernLengthFactors[opinion]![normalizedUnit]; + if (value == null) return null; + // Units in data are cm, m, km. Convert all to cm. + if (['קנה', 'מיל'].contains(normalizedUnit)) + return value * 100; // m to cm + if (['פרסה'].contains(normalizedUnit)) + return value * 100000; // km to cm + return value; // Already in cm + } + break; + case 'שטח': // Base unit: m^2 + if (modernAreaUnits.contains(unit)) { + if (unit == 'מ"ר') return 1.0; + if (unit == 'דונם') return 1000.0; + } else { + final value = modernAreaFactors[opinion]![normalizedUnit]; + if (value == null) return null; + // Units in data are m^2, dunam. Convert all to m^2 + if (['בית סאתיים', 'בית לתך', 'בית כור'].contains(normalizedUnit) || + (opinion == 'חתם סופר' && normalizedUnit == 'בית סאה')) { + return value * 1000; // dunam to m^2 + } + return value; // Already in m^2 + } + break; + case 'נפח': // Base unit: cm^3 + if (modernVolumeUnits.contains(unit)) { + if (unit == 'סמ"ק') return 1.0; + if (unit == 'ליטר') return 1000.0; + } else { + final value = modernVolumeFactors[opinion]![normalizedUnit]; + if (value == null) return null; + // Units in data are cm^3, L. Convert all to cm^3 + if (['קב', 'עשרון', 'הין', 'סאה', 'איפה', 'לתך', 'כור'] + .contains(normalizedUnit)) { + return value * 1000; // L to cm^3 + } + return value; // Already in cm^3 + } + break; + case 'משקל': // Base unit: g + if (modernWeightUnits.contains(unit)) { + if (unit == 'גרם') return 1.0; + if (unit == 'ק"ג') return 1000.0; + } else { + final value = modernWeightFactors[opinion]![_normalizeUnitName(unit)]; + if (value == null) return null; + // Units in data are g, kg. Convert all to g + if (['כיכר', 'קנטר'].contains(normalizedUnit)) { + return value * 1000; // kg to g + } + return value; // Already in g + } + break; + } + return null; + } + void _convert() { final double? input = double.tryParse(_inputController.text); if (input == null || @@ -65,39 +179,82 @@ class _MeasurementConverterScreenState return; } - double conversionFactor = 1.0; + // Check if both units are ancient + bool fromIsAncient = !(_units[_selectedCategory]! + .sublist(_units[_selectedCategory]!.length - modernLengthUnits.length) + .contains(_selectedFromUnit)); + bool toIsAncient = !(_units[_selectedCategory]! + .sublist(_units[_selectedCategory]!.length - modernLengthUnits.length) + .contains(_selectedToUnit)); - switch (_selectedCategory) { - case 'אורך': - conversionFactor = - lengthConversionFactors[_selectedFromUnit]![_selectedToUnit]!; - break; - case 'שטח': - conversionFactor = - areaConversionFactors[_selectedFromUnit]![_selectedToUnit]!; - break; - case 'נפח': - conversionFactor = - volumeConversionFactors[_selectedFromUnit]![_selectedToUnit]!; - break; - case 'משקל': - conversionFactor = - weightConversionFactors[_selectedFromUnit]![_selectedToUnit]!; - break; - case 'זמן': - if (_selectedOpinion != null) { - final fromFactor = - timeConversionFactors[_selectedOpinion]![_selectedFromUnit]!; - final toFactor = - timeConversionFactors[_selectedOpinion]![_selectedToUnit]!; - conversionFactor = fromFactor / toFactor; - } - break; + double result = 0.0; + + // ----- CONVERSION LOGIC ----- + if (_selectedCategory == 'זמן') { + if (_selectedOpinion != null) { + final fromFactor = + timeConversionFactors[_selectedOpinion]![_selectedFromUnit]!; + final toFactor = + timeConversionFactors[_selectedOpinion]![_selectedToUnit]!; + final conversionFactor = fromFactor / toFactor; + result = input * conversionFactor; + } + } else if (fromIsAncient && toIsAncient) { + // Case 1: Ancient to Ancient conversion (doesn't need opinion) + double conversionFactor = 1.0; + switch (_selectedCategory) { + case 'אורך': + conversionFactor = + lengthConversionFactors[_selectedFromUnit]![_selectedToUnit]!; + break; + case 'שטח': + conversionFactor = + areaConversionFactors[_selectedFromUnit]![_selectedToUnit]!; + break; + case 'נפח': + conversionFactor = + volumeConversionFactors[_selectedFromUnit]![_selectedToUnit]!; + break; + case 'משקל': + conversionFactor = + weightConversionFactors[_selectedFromUnit]![_selectedToUnit]!; + break; + } + result = input * conversionFactor; + } else { + // Case 2: Conversion involving any modern unit (requires an opinion) + if (_selectedOpinion == null) { + _resultController.text = "נא לבחור שיטה"; + return; + } + + // Step 1: Convert input from 'FromUnit' to the base unit (e.g., cm for length) + final factorFrom = _getFactorToBaseUnit( + _selectedCategory, _selectedFromUnit!, _selectedOpinion!); + if (factorFrom == null) { + _resultController.clear(); + return; + } + final valueInBaseUnit = input * factorFrom; + + // Step 2: Convert the value from the base unit to the 'ToUnit' + final factorTo = _getFactorToBaseUnit( + _selectedCategory, _selectedToUnit!, _selectedOpinion!); + if (factorTo == null) { + _resultController.clear(); + return; + } + result = valueInBaseUnit / factorTo; } setState(() { - final result = input * conversionFactor; - _resultController.text = result.toStringAsFixed(4); + if (result.isNaN || result.isInfinite) { + _resultController.clear(); + } else { + _resultController.text = result + .toStringAsFixed(4) + .replaceAll(RegExp(r'([.]*0+)(?!.*\d)'), ''); + } }); } @@ -105,25 +262,27 @@ class _MeasurementConverterScreenState Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('ממיר מידות'), + title: const Text('ממיר מידות תורני'), ), body: Padding( padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildCategorySelector(), - const SizedBox(height: 20), - _buildUnitSelectors(), - const SizedBox(height: 20), - if (_opinions.containsKey(_selectedCategory) && - _opinions[_selectedCategory]!.isNotEmpty) - _buildOpinionSelector(), - const SizedBox(height: 20), - _buildInputField(), - const SizedBox(height: 20), - _buildResultDisplay(), - ], + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildCategorySelector(), + const SizedBox(height: 20), + _buildUnitSelectors(), + const SizedBox(height: 20), + if (_opinions.containsKey(_selectedCategory) && + _opinions[_selectedCategory]!.isNotEmpty) + _buildOpinionSelector(), + const SizedBox(height: 20), + _buildInputField(), + const SizedBox(height: 20), + _buildResultDisplay(), + ], + ), ), ), ); @@ -162,7 +321,17 @@ class _MeasurementConverterScreenState _convert(); })), const SizedBox(width: 10), - const Icon(Icons.arrow_forward), + IconButton( + icon: const Icon(Icons.swap_horiz), + onPressed: () { + setState(() { + final temp = _selectedFromUnit; + _selectedFromUnit = _selectedToUnit; + _selectedToUnit = temp; + _convert(); + }); + }, + ), const SizedBox(width: 10), Expanded( child: _buildDropdown('אל', _selectedToUnit, (val) { @@ -177,6 +346,7 @@ class _MeasurementConverterScreenState String label, String? value, ValueChanged onChanged) { return DropdownButtonFormField( value: value, + isExpanded: true, decoration: InputDecoration( labelText: label, border: const OutlineInputBorder(), @@ -184,32 +354,76 @@ class _MeasurementConverterScreenState items: _units[_selectedCategory]!.map((String unit) { return DropdownMenuItem( value: unit, - child: Text(unit), + child: Text(unit, overflow: TextOverflow.ellipsis), ); }).toList(), onChanged: onChanged, ); } + // A map to easily check if a unit is modern + final Map> _modernUnits = { + 'אורך': modernLengthUnits, + 'שטח': modernAreaUnits, + 'נפח': modernVolumeUnits, + 'משקל': modernWeightUnits, + }; + Widget _buildOpinionSelector() { - return DropdownButtonFormField( - value: _selectedOpinion, - decoration: const InputDecoration( - labelText: 'שיטה', - border: OutlineInputBorder(), - ), - items: _opinions[_selectedCategory]!.map((String opinion) { - return DropdownMenuItem( - value: opinion, - child: Text(opinion), - ); - }).toList(), - onChanged: (String? newValue) { - setState(() { - _selectedOpinion = newValue; - _convert(); - }); - }, + // Default to enabled + bool isOpinionEnabled = true; + + // For categories other than 'זמן' + if (_selectedCategory != 'זמן') { + final moderns = _modernUnits[_selectedCategory] ?? []; + final bool isFromModern = moderns.contains(_selectedFromUnit); + final bool isToModern = moderns.contains(_selectedToUnit); + + // Disable ONLY if converting from ancient to ancient + if (!isFromModern && !isToModern) { + isOpinionEnabled = false; + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + value: _selectedOpinion, + decoration: InputDecoration( + labelText: 'שיטה', + border: const OutlineInputBorder(), + // Make the field gray if it's disabled + filled: !isOpinionEnabled, + fillColor: Colors.grey[200], + ), + items: _opinions[_selectedCategory]!.map((String opinion) { + return DropdownMenuItem( + value: opinion, + child: Text(opinion, overflow: TextOverflow.ellipsis), + ); + }).toList(), + // Set onChanged to null to disable the dropdown + onChanged: isOpinionEnabled + ? (String? newValue) { + setState(() { + _selectedOpinion = newValue; + _convert(); + }); + } + : null, + ), + // Show a helper text only when the dropdown is disabled + if (!isOpinionEnabled) + Padding( + padding: const EdgeInsets.only(top: 4.0, right: 8.0), + child: Text( + 'השיטה אינה רלוונטית להמרה בין מידות חז"ל.', + style: TextStyle(fontSize: 12, color: Colors.grey[700]), + textAlign: TextAlign.right, + ), + ), + ], ); } @@ -225,6 +439,8 @@ class _MeasurementConverterScreenState FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')), ], onChanged: (value) => _convert(), + textDirection: TextDirection.ltr, + textAlign: TextAlign.right, ); } @@ -237,6 +453,8 @@ class _MeasurementConverterScreenState border: OutlineInputBorder(), ), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + textDirection: TextDirection.ltr, + textAlign: TextAlign.right, ); } } diff --git a/lib/tools/measurement_converter/measurement_data.dart b/lib/tools/measurement_converter/measurement_data.dart index 38ef56dc4..c5fec29a4 100644 --- a/lib/tools/measurement_converter/measurement_data.dart +++ b/lib/tools/measurement_converter/measurement_data.dart @@ -67,7 +67,7 @@ const Map> lengthConversionFactors = { }; const Map> modernLengthFactors = { - 'מדות ושיעורי תורה, בדעת הרמב"ם': { + 'רמב"ם': { 'אצבע': 1.9, // cm 'טפח': 7.6, // cm 'זרת': 22.8, // cm @@ -184,7 +184,7 @@ const Map areaInSquareAmot = { }; const Map> modernAreaFactors = { - 'מדות ושיעורי תורה, בדעת הרמב"ם': { + 'רמב"ם': { 'בית רובע': 21 + 2 / 3, // m^2 'בית קב': 86 + 2 / 3, // m^2 'בית סאה': 520, // m^2 @@ -337,7 +337,7 @@ const Map> volumeConversionFactors = { }; const Map> modernVolumeFactors = { - 'מדות ושיעורי תורה, בדעת הרמב"ם': { + 'רמב"ם': { 'רביעית': 76.4, // cm^3 'לוג': 305.6, // cm^3 'קב': 1.22, // L @@ -496,7 +496,7 @@ const Map> weightConversionFactors = { }; const Map> modernWeightFactors = { - 'מדות ושיעורי תורה, בדעת רש"י': { + 'רש"י': { 'דינר': 3.54, // g 'שקל': 7.08, // g 'סלע': 14.16, // g @@ -523,7 +523,7 @@ const Map> modernWeightFactors = { 'כיכר': 23.7, // kg 'קנטר': 39.5, // kg }, - 'מדות ושיעורי תורה, בדעת הגאונים והרמב"ם': { + 'גאונים ורמב"ם': { 'דינר': 4.25, // g 'שקל': 8.5, // g 'סלע': 17, // g From 9796953f4d39bb0bbdf0f29241a7a180a4cc188f Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 31 Aug 2025 14:00:47 +0300 Subject: [PATCH 147/197] =?UTF-8?q?=D7=A9=D7=99=D7=A0=D7=95=D7=99=20=D7=A2?= =?UTF-8?q?=D7=99=D7=A6=D7=95=D7=91=20=D7=91=D7=9E=D7=9E=D7=99=D7=A8=20?= =?UTF-8?q?=D7=9E=D7=99=D7=93=D7=95=D7=AA,=20=D7=95=D7=A9=D7=9E=D7=99?= =?UTF-8?q?=D7=A8=D7=AA=20=D7=91=D7=97=D7=99=D7=A8=D7=94=20=D7=90=D7=97?= =?UTF-8?q?=D7=A8=D7=95=D7=A0=D7=94=20=D7=A9=D7=9C=20=D7=94=D7=9E=D7=A9?= =?UTF-8?q?=D7=AA=D7=9E=D7=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../measurement_converter_screen.dart | 289 +++++++++++++++--- 1 file changed, 241 insertions(+), 48 deletions(-) diff --git a/lib/tools/measurement_converter/measurement_converter_screen.dart b/lib/tools/measurement_converter/measurement_converter_screen.dart index 6b528d3bd..e342cf5b4 100644 --- a/lib/tools/measurement_converter/measurement_converter_screen.dart +++ b/lib/tools/measurement_converter/measurement_converter_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'dart:math' as math; import 'measurement_data.dart'; // START OF ADDITIONS - MODERN UNITS @@ -26,6 +27,11 @@ class _MeasurementConverterScreenState final TextEditingController _inputController = TextEditingController(); final TextEditingController _resultController = TextEditingController(); + // Maps to remember user selections for each category + final Map _rememberedFromUnits = {}; + final Map _rememberedToUnits = {}; + final Map _rememberedOpinions = {}; + // Updated to include modern units final Map> _units = { 'אורך': lengthConversionFactors.keys.toList()..addAll(modernLengthUnits), @@ -53,14 +59,39 @@ class _MeasurementConverterScreenState void _resetDropdowns() { setState(() { - _selectedFromUnit = _units[_selectedCategory]!.first; - _selectedToUnit = _units[_selectedCategory]!.first; - _selectedOpinion = _opinions[_selectedCategory]?.first; + // Restore remembered selections or use defaults + _selectedFromUnit = _rememberedFromUnits[_selectedCategory] ?? _units[_selectedCategory]!.first; + _selectedToUnit = _rememberedToUnits[_selectedCategory] ?? _units[_selectedCategory]!.first; + _selectedOpinion = _rememberedOpinions[_selectedCategory] ?? _opinions[_selectedCategory]?.first; + + // Validate that remembered selections are still valid for current category + if (!_units[_selectedCategory]!.contains(_selectedFromUnit)) { + _selectedFromUnit = _units[_selectedCategory]!.first; + } + if (!_units[_selectedCategory]!.contains(_selectedToUnit)) { + _selectedToUnit = _units[_selectedCategory]!.first; + } + if (_opinions[_selectedCategory] != null && !_opinions[_selectedCategory]!.contains(_selectedOpinion)) { + _selectedOpinion = _opinions[_selectedCategory]?.first; + } + _inputController.clear(); _resultController.clear(); }); } + void _saveCurrentSelections() { + if (_selectedFromUnit != null) { + _rememberedFromUnits[_selectedCategory] = _selectedFromUnit!; + } + if (_selectedToUnit != null) { + _rememberedToUnits[_selectedCategory] = _selectedToUnit!; + } + if (_selectedOpinion != null) { + _rememberedOpinions[_selectedCategory] = _selectedOpinion!; + } + } + // Helper function to handle small inconsistencies in unit names // e.g., 'אצבעות' vs 'אצבע', 'רביעיות' vs 'רביעית' String _normalizeUnitName(String unit) { @@ -112,10 +143,12 @@ class _MeasurementConverterScreenState final value = modernLengthFactors[opinion]![normalizedUnit]; if (value == null) return null; // Units in data are cm, m, km. Convert all to cm. - if (['קנה', 'מיל'].contains(normalizedUnit)) + if (['קנה', 'מיל'].contains(normalizedUnit)) { return value * 100; // m to cm - if (['פרסה'].contains(normalizedUnit)) + } + if (['פרסה'].contains(normalizedUnit)) { return value * 100000; // km to cm + } return value; // Already in cm } break; @@ -289,75 +322,232 @@ class _MeasurementConverterScreenState } Widget _buildCategorySelector() { - return DropdownButtonFormField( - value: _selectedCategory, - decoration: const InputDecoration( - labelText: 'קטגוריה', - border: OutlineInputBorder(), + final categories = ['אורך', 'שטח', 'נפח', 'משקל', 'זמן']; + + return Container( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: LayoutBuilder( + builder: (context, constraints) { + // Calculate if all buttons can fit in one row + const double minButtonWidth = 80.0; // Minimum width to ensure text fits in one line + const double spacing = 12.0; + final double totalSpacing = spacing * (categories.length - 1); + final double availableWidth = constraints.maxWidth - totalSpacing; + final double buttonWidth = availableWidth / categories.length; + + // If buttons would be too small, use Wrap for multiple rows + if (buttonWidth < minButtonWidth) { + return Wrap( + spacing: spacing, + runSpacing: 12.0, + children: categories.map((category) => _buildCategoryButton(category, minButtonWidth)).toList(), + ); + } + + // Otherwise, use Row with equal-width buttons + return Row( + children: categories.asMap().entries.map((entry) { + final index = entry.key; + final category = entry.value; + return Expanded( + child: Container( + margin: EdgeInsets.only( + left: index < categories.length - 1 ? spacing / 2 : 0, + right: index > 0 ? spacing / 2 : 0, + ), + child: _buildCategoryButton(category, null), + ), + ); + }).toList(), + ); + }, ), - items: _units.keys.map((String category) { - return DropdownMenuItem( - value: category, - child: Text(category), - ); - }).toList(), - onChanged: (String? newValue) { - if (newValue != null) { + ); + } + + Widget _buildCategoryButton(String category, double? minWidth) { + final isSelected = _selectedCategory == category; + + return GestureDetector( + onTap: () { + if (category != _selectedCategory) { + _saveCurrentSelections(); // Save current selections before changing category setState(() { - _selectedCategory = newValue; + _selectedCategory = category; _resetDropdowns(); }); } }, + child: Container( + width: minWidth, + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(8.0), + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: isSelected ? 2.0 : 1.0, + ), + ), + child: Text( + category, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: isSelected + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.primary, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + fontSize: 16.0, + ), + ), + ), ); } Widget _buildUnitSelectors() { return Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: _buildDropdown('מ', _selectedFromUnit, (val) { - setState(() => _selectedFromUnit = val); - _convert(); - })), + child: _buildUnitGrid('מ', _selectedFromUnit, (val) { + setState(() => _selectedFromUnit = val); + _rememberedFromUnits[_selectedCategory] = val!; + _convert(); + }), + ), const SizedBox(width: 10), - IconButton( - icon: const Icon(Icons.swap_horiz), - onPressed: () { - setState(() { - final temp = _selectedFromUnit; - _selectedFromUnit = _selectedToUnit; - _selectedToUnit = temp; - _convert(); - }); - }, + Column( + children: [ + const SizedBox(height: 20), // Align with the grid content + IconButton( + icon: const Icon(Icons.swap_horiz), + onPressed: () { + setState(() { + final temp = _selectedFromUnit; + _selectedFromUnit = _selectedToUnit; + _selectedToUnit = temp; + _convert(); + }); + }, + ), + ], ), const SizedBox(width: 10), Expanded( - child: _buildDropdown('אל', _selectedToUnit, (val) { - setState(() => _selectedToUnit = val); - _convert(); - })), + child: _buildUnitGrid('אל', _selectedToUnit, (val) { + setState(() => _selectedToUnit = val); + _rememberedToUnits[_selectedCategory] = val!; + _convert(); + }), + ), ], ); } - Widget _buildDropdown( - String label, String? value, ValueChanged onChanged) { - return DropdownButtonFormField( - value: value, - isExpanded: true, + Widget _buildUnitGrid(String label, String? selectedValue, ValueChanged onChanged) { + final units = _units[_selectedCategory]!; + + // Separate modern and ancient units + final modernUnits = _getModernUnitsForCategory(_selectedCategory); + final ancientUnits = units.where((unit) => !modernUnits.contains(unit)).toList(); + + return InputDecorator( decoration: InputDecoration( labelText: label, + labelStyle: const TextStyle(fontSize: 19.0, fontWeight: FontWeight.w500), border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.fromLTRB(12.0, 26.0, 12.0, 12.0), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Ancient units rows + if (ancientUnits.isNotEmpty) ...[ + _buildUnitsWrap(ancientUnits, selectedValue, onChanged), + if (modernUnits.isNotEmpty) const SizedBox(height: 12.0), + ], + // Modern units rows (if any) + if (modernUnits.isNotEmpty) + _buildUnitsWrap(modernUnits, selectedValue, onChanged), + ], ), - items: _units[_selectedCategory]!.map((String unit) { - return DropdownMenuItem( - value: unit, - child: Text(unit, overflow: TextOverflow.ellipsis), - ); + ); + } + + List _getModernUnitsForCategory(String category) { + switch (category) { + case 'אורך': + return modernLengthUnits; + case 'שטח': + return modernAreaUnits; + case 'נפח': + return modernVolumeUnits; + case 'משקל': + return modernWeightUnits; + default: + return []; + } + } + + + Widget _buildUnitsWrap(List units, String? selectedValue, ValueChanged onChanged) { + // Calculate the maximum width needed for any unit in this category + double maxWidth = 0; + for (String unit in units) { + final textPainter = TextPainter( + text: TextSpan( + text: unit, + style: const TextStyle(fontSize: 14.0, fontWeight: FontWeight.bold), + ), + textDirection: TextDirection.rtl, + ); + textPainter.layout(); + maxWidth = math.max(maxWidth, textPainter.width + 32.0); // Add padding + } + + return Wrap( + spacing: 8.0, + runSpacing: 8.0, + children: units.map((unit) { + return _buildUnitButton(unit, selectedValue == unit, onChanged, maxWidth); }).toList(), - onChanged: onChanged, + ); + } + + Widget _buildUnitButton(String unit, bool isSelected, ValueChanged onChanged, double? fixedWidth) { + return GestureDetector( + onTap: () => onChanged(unit), + child: Container( + width: fixedWidth, + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(6.0), + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: isSelected ? 1.5 : 0.5, + ), + ), + child: Text( + unit, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: isSelected + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.primary, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + fontSize: 14.0, + ), + ), + ), ); } @@ -408,6 +598,9 @@ class _MeasurementConverterScreenState ? (String? newValue) { setState(() { _selectedOpinion = newValue; + if (newValue != null) { + _rememberedOpinions[_selectedCategory] = newValue; + } _convert(); }); } From 18238257fb4014827d63cc322a8b2158bc4b7cae Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 31 Aug 2025 14:03:21 +0300 Subject: [PATCH 148/197] =?UTF-8?q?=D7=A9=D7=99=D7=A0=D7=95=D7=99=20=D7=A9?= =?UTF-8?q?=D7=9D=20=D7=A9=D7=95=D7=9C=D7=97=D7=9F=20=D7=A2=D7=91=D7=95?= =?UTF-8?q?=D7=93=D7=94=20=D7=91=D7=A8=D7=99=D7=A8=D7=AA=20=D7=9E=D7=97?= =?UTF-8?q?=D7=93=D7=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/workspaces/bloc/workspace_bloc.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/workspaces/bloc/workspace_bloc.dart b/lib/workspaces/bloc/workspace_bloc.dart index 9f1a1054e..35d9cb950 100644 --- a/lib/workspaces/bloc/workspace_bloc.dart +++ b/lib/workspaces/bloc/workspace_bloc.dart @@ -30,7 +30,7 @@ class WorkspaceBloc extends Bloc { final workspaces = _repository.loadWorkspaces(); if (workspaces.$1.isEmpty) { workspaces.$1 - .add(Workspace(name: "ברירת מחדל", tabs: [], currentTab: 0)); + .add(Workspace(name: "שולחן עבודה 1", tabs: [], currentTab: 0)); } final currentWorkSpace = workspaces.$2; From e6bf55a53ba799342a4264cbf614b83f99674479 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 1 Sep 2025 10:31:38 +0300 Subject: [PATCH 149/197] =?UTF-8?q?=D7=A9=D7=99=D7=A0=D7=95=D7=99=20UI=20?= =?UTF-8?q?=D7=A7=D7=9C=20=D7=91=D7=9E=D7=9E=D7=99=D7=A8=20=D7=9E=D7=99?= =?UTF-8?q?=D7=93=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/navigation/more_screen.dart | 2 +- .../measurement_converter/measurement_converter_screen.dart | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/navigation/more_screen.dart b/lib/navigation/more_screen.dart index c2529ce27..02eb66d69 100644 --- a/lib/navigation/more_screen.dart +++ b/lib/navigation/more_screen.dart @@ -92,7 +92,7 @@ class _MoreScreenState extends State { case 1: return 'זכור ושמור'; case 2: - return 'ממיר מידות'; + return 'ממיר מידות תורני'; case 3: return 'גימטריות'; default: diff --git a/lib/tools/measurement_converter/measurement_converter_screen.dart b/lib/tools/measurement_converter/measurement_converter_screen.dart index e342cf5b4..f0ffb6074 100644 --- a/lib/tools/measurement_converter/measurement_converter_screen.dart +++ b/lib/tools/measurement_converter/measurement_converter_screen.dart @@ -294,9 +294,6 @@ class _MeasurementConverterScreenState @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('ממיר מידות תורני'), - ), body: Padding( padding: const EdgeInsets.all(16.0), child: SingleChildScrollView( From f46177ff8674fce4e494bc7769a729ead9adf8a5 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 1 Sep 2025 14:54:21 +0300 Subject: [PATCH 150/197] =?UTF-8?q?=D7=94=D7=95=D7=A8=D7=93=D7=AA=20=D7=90?= =?UTF-8?q?=D7=96=D7=94=D7=A8=D7=AA=20=D7=94=D7=96=D7=9E=D7=A0=D7=99=D7=9D?= =?UTF-8?q?=20=D7=9C=D7=9C=D7=9E=D7=98=D7=94,=20=D7=95=D7=94=D7=A4=D7=99?= =?UTF-8?q?=D7=9B=D7=AA=D7=94=20=D7=9C=D7=A6=D7=91=D7=A2=20=D7=94=D7=9E?= =?UTF-8?q?=D7=A2=D7=A8=D7=9B=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/navigation/calendar_widget.dart | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/navigation/calendar_widget.dart b/lib/navigation/calendar_widget.dart index 78234db4a..142fb3e86 100644 --- a/lib/navigation/calendar_widget.dart +++ b/lib/navigation/calendar_widget.dart @@ -639,27 +639,27 @@ class CalendarWidget extends StatelessWidget { ], ), const SizedBox(height: 16), + _buildTimesGrid(context, state), + const SizedBox(height: 16), + _buildDafYomiButtons(context, state), + const SizedBox(height: 16), Container( width: double.infinity, padding: const EdgeInsets.all(12), - margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - color: Colors.orange.withAlpha(51), + color: Theme.of(context).primaryColor.withAlpha(76), borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.orange, width: 1), + border: Border.all(color: Theme.of(context).primaryColor, width: 1), ), - child: const Text( + child: Text( 'אין לסמוך על הזמנים!', textAlign: TextAlign.center, style: TextStyle( fontWeight: FontWeight.bold, - color: Colors.orange, + color: Theme.of(context).primaryColor, ), ), ), - _buildTimesGrid(context, state), - const SizedBox(height: 16), - _buildDafYomiButtons(context, state), ], ), ), From 0b9988f4a51ab072abd37421231e9a06f5ac320f Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 1 Sep 2025 15:17:37 +0300 Subject: [PATCH 151/197] =?UTF-8?q?=D7=94=D7=93=D7=92=D7=A9=D7=94=20=D7=A7?= =?UTF-8?q?=D7=9C=D7=94=20=D7=A1=D7=91=D7=99=D7=91=20=D7=94=D7=99=D7=9E?= =?UTF-8?q?=D7=99=D7=9D=20=D7=91=D7=9C=D7=95=D7=97=20=D7=94=D7=A9=D7=A0?= =?UTF-8?q?=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/navigation/calendar_widget.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/navigation/calendar_widget.dart b/lib/navigation/calendar_widget.dart index 142fb3e86..31ac8ca60 100644 --- a/lib/navigation/calendar_widget.dart +++ b/lib/navigation/calendar_widget.dart @@ -402,12 +402,12 @@ class CalendarWidget extends StatelessWidget { ? Theme.of(context).primaryColor : isToday ? Theme.of(context).primaryColor.withAlpha(76) - : Colors.transparent, + : Theme.of(context).primaryColor.withAlpha(10), borderRadius: BorderRadius.circular(8), border: Border.all( color: isToday ? Theme.of(context).primaryColor - : Colors.grey.shade300, + : Theme.of(context).primaryColor.withAlpha(102), width: isToday ? 2 : 1, ), ), @@ -475,11 +475,11 @@ class CalendarWidget extends StatelessWidget { ? Theme.of(context).primaryColor : isToday ? Theme.of(context).primaryColor.withAlpha(76) - : Colors.transparent, + : Theme.of(context).primaryColor.withAlpha(10), borderRadius: BorderRadius.circular(8), border: Border.all( color: - isToday ? Theme.of(context).primaryColor : Colors.grey.shade300, + isToday ? Theme.of(context).primaryColor : Theme.of(context).primaryColor.withAlpha(102), width: isToday ? 2 : 1, ), ), @@ -536,11 +536,11 @@ class CalendarWidget extends StatelessWidget { ? Theme.of(context).primaryColor : isToday ? Theme.of(context).primaryColor.withAlpha(76) - : Colors.transparent, + : Theme.of(context).primaryColor.withAlpha(10), borderRadius: BorderRadius.circular(8), border: Border.all( color: - isToday ? Theme.of(context).primaryColor : Colors.grey.shade300, + isToday ? Theme.of(context).primaryColor : Theme.of(context).primaryColor.withAlpha(102), width: isToday ? 2 : 1, ), ), From bd139b68749cc01d176cc8ddbe892b5cf26266e9 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 1 Sep 2025 16:54:14 +0300 Subject: [PATCH 152/197] =?UTF-8?q?=D7=A2=D7=99=D7=A6=D7=95=D7=91=20=D7=A9?= =?UTF-8?q?=D7=95=D7=A8=D7=AA=20=D7=94=D7=A9=D7=99=D7=98=D7=95=D7=AA=20?= =?UTF-8?q?=D7=91=D7=9E=D7=9E=D7=99=D7=A8,=20=D7=95=D7=A9=D7=9E=D7=99?= =?UTF-8?q?=D7=A8=D7=AA=20=D7=94=D7=A2=D7=A8=D7=9A=20=D7=9C=D7=94=D7=9E?= =?UTF-8?q?=D7=A8=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../measurement_converter_screen.dart | 268 +++++++++++++----- 1 file changed, 204 insertions(+), 64 deletions(-) diff --git a/lib/tools/measurement_converter/measurement_converter_screen.dart b/lib/tools/measurement_converter/measurement_converter_screen.dart index f0ffb6074..54e7746e2 100644 --- a/lib/tools/measurement_converter/measurement_converter_screen.dart +++ b/lib/tools/measurement_converter/measurement_converter_screen.dart @@ -31,6 +31,7 @@ class _MeasurementConverterScreenState final Map _rememberedFromUnits = {}; final Map _rememberedToUnits = {}; final Map _rememberedOpinions = {}; + final Map _rememberedInputValues = {}; // Updated to include modern units final Map> _units = { @@ -60,10 +61,13 @@ class _MeasurementConverterScreenState void _resetDropdowns() { setState(() { // Restore remembered selections or use defaults - _selectedFromUnit = _rememberedFromUnits[_selectedCategory] ?? _units[_selectedCategory]!.first; - _selectedToUnit = _rememberedToUnits[_selectedCategory] ?? _units[_selectedCategory]!.first; - _selectedOpinion = _rememberedOpinions[_selectedCategory] ?? _opinions[_selectedCategory]?.first; - + _selectedFromUnit = _rememberedFromUnits[_selectedCategory] ?? + _units[_selectedCategory]!.first; + _selectedToUnit = _rememberedToUnits[_selectedCategory] ?? + _units[_selectedCategory]!.first; + _selectedOpinion = _rememberedOpinions[_selectedCategory] ?? + _opinions[_selectedCategory]?.first; + // Validate that remembered selections are still valid for current category if (!_units[_selectedCategory]!.contains(_selectedFromUnit)) { _selectedFromUnit = _units[_selectedCategory]!.first; @@ -71,12 +75,20 @@ class _MeasurementConverterScreenState if (!_units[_selectedCategory]!.contains(_selectedToUnit)) { _selectedToUnit = _units[_selectedCategory]!.first; } - if (_opinions[_selectedCategory] != null && !_opinions[_selectedCategory]!.contains(_selectedOpinion)) { + if (_opinions[_selectedCategory] != null && + !_opinions[_selectedCategory]!.contains(_selectedOpinion)) { _selectedOpinion = _opinions[_selectedCategory]?.first; } - - _inputController.clear(); + + // Restore remembered input value or clear + _inputController.text = _rememberedInputValues[_selectedCategory] ?? ''; _resultController.clear(); + + // Convert if there's a remembered input value + if (_rememberedInputValues[_selectedCategory] != null && + _rememberedInputValues[_selectedCategory]!.isNotEmpty) { + _convert(); + } }); } @@ -90,6 +102,10 @@ class _MeasurementConverterScreenState if (_selectedOpinion != null) { _rememberedOpinions[_selectedCategory] = _selectedOpinion!; } + // Save the current input value + if (_inputController.text.isNotEmpty) { + _rememberedInputValues[_selectedCategory] = _inputController.text; + } } // Helper function to handle small inconsistencies in unit names @@ -305,9 +321,10 @@ class _MeasurementConverterScreenState _buildUnitSelectors(), const SizedBox(height: 20), if (_opinions.containsKey(_selectedCategory) && - _opinions[_selectedCategory]!.isNotEmpty) + _opinions[_selectedCategory]!.isNotEmpty) ...[ _buildOpinionSelector(), - const SizedBox(height: 20), + const SizedBox(height: 20), + ], _buildInputField(), const SizedBox(height: 20), _buildResultDisplay(), @@ -326,21 +343,25 @@ class _MeasurementConverterScreenState child: LayoutBuilder( builder: (context, constraints) { // Calculate if all buttons can fit in one row - const double minButtonWidth = 80.0; // Minimum width to ensure text fits in one line + const double minButtonWidth = + 80.0; // Minimum width to ensure text fits in one line const double spacing = 12.0; final double totalSpacing = spacing * (categories.length - 1); final double availableWidth = constraints.maxWidth - totalSpacing; final double buttonWidth = availableWidth / categories.length; - + // If buttons would be too small, use Wrap for multiple rows if (buttonWidth < minButtonWidth) { return Wrap( spacing: spacing, runSpacing: 12.0, - children: categories.map((category) => _buildCategoryButton(category, minButtonWidth)).toList(), + children: categories + .map((category) => + _buildCategoryButton(category, minButtonWidth)) + .toList(), ); } - + // Otherwise, use Row with equal-width buttons return Row( children: categories.asMap().entries.map((entry) { @@ -364,7 +385,7 @@ class _MeasurementConverterScreenState Widget _buildCategoryButton(String category, double? minWidth) { final isSelected = _selectedCategory == category; - + return GestureDetector( onTap: () { if (category != _selectedCategory) { @@ -381,7 +402,7 @@ class _MeasurementConverterScreenState decoration: BoxDecoration( color: isSelected ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.primary.withOpacity(0.1), + : Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8.0), border: Border.all( color: Theme.of(context).colorScheme.primary, @@ -445,17 +466,20 @@ class _MeasurementConverterScreenState ); } - Widget _buildUnitGrid(String label, String? selectedValue, ValueChanged onChanged) { + Widget _buildUnitGrid( + String label, String? selectedValue, ValueChanged onChanged) { final units = _units[_selectedCategory]!; - + // Separate modern and ancient units final modernUnits = _getModernUnitsForCategory(_selectedCategory); - final ancientUnits = units.where((unit) => !modernUnits.contains(unit)).toList(); - + final ancientUnits = + units.where((unit) => !modernUnits.contains(unit)).toList(); + return InputDecorator( decoration: InputDecoration( labelText: label, - labelStyle: const TextStyle(fontSize: 19.0, fontWeight: FontWeight.w500), + labelStyle: + const TextStyle(fontSize: 19.0, fontWeight: FontWeight.w500), border: const OutlineInputBorder(), contentPadding: const EdgeInsets.fromLTRB(12.0, 26.0, 12.0, 12.0), ), @@ -490,8 +514,8 @@ class _MeasurementConverterScreenState } } - - Widget _buildUnitsWrap(List units, String? selectedValue, ValueChanged onChanged) { + Widget _buildUnitsWrap(List units, String? selectedValue, + ValueChanged onChanged) { // Calculate the maximum width needed for any unit in this category double maxWidth = 0; for (String unit in units) { @@ -510,12 +534,14 @@ class _MeasurementConverterScreenState spacing: 8.0, runSpacing: 8.0, children: units.map((unit) { - return _buildUnitButton(unit, selectedValue == unit, onChanged, maxWidth); + return _buildUnitButton( + unit, selectedValue == unit, onChanged, maxWidth); }).toList(), ); } - Widget _buildUnitButton(String unit, bool isSelected, ValueChanged onChanged, double? fixedWidth) { + Widget _buildUnitButton(String unit, bool isSelected, + ValueChanged onChanged, double? fixedWidth) { return GestureDetector( onTap: () => onChanged(unit), child: Container( @@ -524,7 +550,7 @@ class _MeasurementConverterScreenState decoration: BoxDecoration( color: isSelected ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.primary.withOpacity(0.1), + : Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(6.0), border: Border.all( color: Theme.of(context).colorScheme.primary, @@ -572,48 +598,154 @@ class _MeasurementConverterScreenState } } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DropdownButtonFormField( - value: _selectedOpinion, - decoration: InputDecoration( - labelText: 'שיטה', - border: const OutlineInputBorder(), - // Make the field gray if it's disabled - filled: !isOpinionEnabled, - fillColor: Colors.grey[200], - ), - items: _opinions[_selectedCategory]!.map((String opinion) { - return DropdownMenuItem( - value: opinion, - child: Text(opinion, overflow: TextOverflow.ellipsis), - ); - }).toList(), - // Set onChanged to null to disable the dropdown - onChanged: isOpinionEnabled - ? (String? newValue) { - setState(() { - _selectedOpinion = newValue; - if (newValue != null) { - _rememberedOpinions[_selectedCategory] = newValue; - } - _convert(); - }); - } - : null, - ), - // Show a helper text only when the dropdown is disabled - if (!isOpinionEnabled) + // If not enabled, don't show the selector at all + if (!isOpinionEnabled) { + return const SizedBox.shrink(); + } + + final opinions = _opinions[_selectedCategory]!; + + return Container( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Padding( - padding: const EdgeInsets.only(top: 4.0, right: 8.0), + padding: const EdgeInsets.only(bottom: 8.0, right: 4.0), child: Text( - 'השיטה אינה רלוונטית להמרה בין מידות חז"ל.', - style: TextStyle(fontSize: 12, color: Colors.grey[700]), - textAlign: TextAlign.right, + 'שיטה', + style: TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface, + ), ), ), - ], + LayoutBuilder( + builder: (context, constraints) { + const double spacing = 12.0; + const double padding = 16.0; + + // Calculate the natural width needed for each opinion text + List textWidths = opinions.map((opinion) { + final textPainter = TextPainter( + text: TextSpan( + text: opinion, + style: const TextStyle( + fontSize: 14.0, fontWeight: FontWeight.bold), + ), + textDirection: + TextDirection.ltr, // Changed to LTR to fix Hebrew display + ); + textPainter.layout(); + return textPainter.width + + (padding * 2); // Add horizontal padding + }).toList(); + + final double maxTextWidth = + textWidths.reduce((a, b) => a > b ? a : b); + final double totalSpacing = spacing * (opinions.length - 1); + final double totalEqualWidth = + (maxTextWidth * opinions.length) + totalSpacing; + + // First preference: Try equal-width buttons if they fit + if (totalEqualWidth <= constraints.maxWidth) { + return Row( + children: opinions.asMap().entries.map((entry) { + final index = entry.key; + final opinion = entry.value; + + return Expanded( + child: Container( + margin: EdgeInsets.only( + left: index < opinions.length - 1 ? spacing / 2 : 0, + right: index > 0 ? spacing / 2 : 0, + ), + child: _buildOpinionButton(opinion, null), + ), + ); + }).toList(), + ); + } + + // Second preference: Try proportional widths if natural sizes fit + final double totalNaturalWidth = + textWidths.reduce((a, b) => a + b) + totalSpacing; + if (totalNaturalWidth <= constraints.maxWidth) { + final double totalFlex = textWidths.reduce((a, b) => a + b); + + return Row( + children: opinions.asMap().entries.map((entry) { + final index = entry.key; + final opinion = entry.value; + final flex = (textWidths[index] / totalFlex * 1000).round(); + + return Expanded( + flex: flex, + child: Container( + margin: EdgeInsets.only( + left: index < opinions.length - 1 ? spacing / 2 : 0, + right: index > 0 ? spacing / 2 : 0, + ), + child: _buildOpinionButton(opinion, null), + ), + ); + }).toList(), + ); + } + + // Last resort: Use Wrap for multiple rows + return Wrap( + spacing: spacing, + runSpacing: 12.0, + children: opinions + .map((opinion) => _buildOpinionButton(opinion, null)) + .toList(), + ); + }, + ), + ], + ), + ); + } + + Widget _buildOpinionButton(String opinion, double? minWidth) { + final isSelected = _selectedOpinion == opinion; + + return GestureDetector( + onTap: () { + setState(() { + _selectedOpinion = opinion; + _rememberedOpinions[_selectedCategory] = opinion; + _convert(); + }); + }, + child: Container( + width: minWidth, + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8.0), + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: isSelected ? 2.0 : 1.0, + ), + ), + child: Text( + opinion, + textAlign: TextAlign.center, + maxLines: 1, + style: TextStyle( + color: isSelected + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.primary, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + fontSize: 14.0, + ), + ), + ), ); } @@ -628,7 +760,15 @@ class _MeasurementConverterScreenState inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')), ], - onChanged: (value) => _convert(), + onChanged: (value) { + // Save the input value when it changes + if (value.isNotEmpty) { + _rememberedInputValues[_selectedCategory] = value; + } else { + _rememberedInputValues.remove(_selectedCategory); + } + _convert(); + }, textDirection: TextDirection.ltr, textAlign: TextAlign.right, ); From 44cdc8a8395646f3059d99091372704746eeeeaf Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 1 Sep 2025 21:20:23 +0300 Subject: [PATCH 153/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=9E?= =?UTF-8?q?=D7=A2=D7=91=D7=A8=20PDF=20<>=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pdf_book/pdf_book_screen.dart | 58 ++-- lib/search/view/tantivy_full_text_search.dart | 2 +- lib/tabs/bloc/tabs_bloc.dart | 3 + lib/tabs/models/text_tab.dart | 14 + lib/tabs/reading_screen.dart | 4 + lib/text_book/bloc/text_book_bloc.dart | 44 +-- lib/text_book/bloc/text_book_state.dart | 6 - .../combined_view/combined_book_screen.dart | 36 ++- .../view/splited_view/simple_book_view.dart | 36 ++- lib/text_book/view/text_book_screen.dart | 19 +- lib/utils/open_book.dart | 35 ++- lib/utils/page_converter.dart | 278 +++++++++--------- 12 files changed, 335 insertions(+), 200 deletions(-) diff --git a/lib/pdf_book/pdf_book_screen.dart b/lib/pdf_book/pdf_book_screen.dart index 1cbf3b1f7..6585e5c09 100644 --- a/lib/pdf_book/pdf_book_screen.dart +++ b/lib/pdf_book/pdf_book_screen.dart @@ -128,6 +128,9 @@ class _PdfBookScreenState extends State // 3. שמור את הבקר בטאב כדי ששאר חלקי האפליקציה יוכלו להשתמש בו. widget.tab.pdfViewerController = pdfController; + // וודא שהמיקום הנוכחי נשמר בטאב + print('DEBUG: אתחול PDF טאב - דף התחלתי: ${widget.tab.pageNumber}'); + _sidebarWidth = ValueNotifier( Settings.getValue('key-sidebar-width', defaultValue: 300)!); @@ -183,15 +186,17 @@ class _PdfBookScreenState extends State }); } - void _onPdfViewerControllerUpdate() { - if (widget.tab.pdfViewerController.isReady) { - widget.tab.pageNumber = widget.tab.pdfViewerController.pageNumber ?? 1; - () async { - widget.tab.currentTitle.value = await refFromPageNumber( - widget.tab.pageNumber, - widget.tab.outline.value, - widget.tab.book.title); - }(); + int _lastComputedForPage = -1; + void _onPdfViewerControllerUpdate() async { + if (!widget.tab.pdfViewerController.isReady) return; + final newPage = widget.tab.pdfViewerController.pageNumber ?? 1; + if (newPage == widget.tab.pageNumber) return; + widget.tab.pageNumber = newPage; + final token = _lastComputedForPage = newPage; + final title = await refFromPageNumber( + newPage, widget.tab.outline.value ?? [], widget.tab.book.title); + if (token == _lastComputedForPage) { + widget.tab.currentTitle.value = title; } } @@ -416,7 +421,9 @@ class _PdfBookScreenState extends State passwordProvider: () => passwordDialog(context), controller: widget.tab.pdfViewerController, params: PdfViewerParams( - backgroundColor: Theme.of(context).colorScheme.surface, // צבע רקע המסך, בתצוגת ספרי PDF + backgroundColor: Theme.of(context) + .colorScheme + .surface, // צבע רקע המסך, בתצוגת ספרי PDF maxScale: 10, horizontalCacheExtent: 5, verticalCacheExtent: 5, @@ -565,9 +572,19 @@ class _PdfBookScreenState extends State Row( children: [ Expanded(child: _buildCustomTab('ניווט', 0)), - Container(height: 24, width: 1, color: Colors.grey.shade400, margin: const EdgeInsets.symmetric(horizontal: 2)), + Container( + height: 24, + width: 1, + color: Colors.grey.shade400, + margin: const EdgeInsets.symmetric( + horizontal: 2)), Expanded(child: _buildCustomTab('חיפוש', 1)), - Container(height: 24, width: 1, color: Colors.grey.shade400, margin: const EdgeInsets.symmetric(horizontal: 2)), + Container( + height: 24, + width: 1, + color: Colors.grey.shade400, + margin: const EdgeInsets.symmetric( + horizontal: 2)), Expanded(child: _buildCustomTab('דפים', 2)), ], ), @@ -720,7 +737,7 @@ class _PdfBookScreenState extends State ), ); } - + return AnimatedBuilder( animation: controller, builder: (context, child) { @@ -777,12 +794,17 @@ class _PdfBookScreenState extends State icon: const Icon(Icons.article), tooltip: 'פתח טקסט', onPressed: () async { + final currentPage = controller.isReady + ? controller.pageNumber ?? 1 + : widget.tab.pageNumber; + widget.tab.pageNumber = currentPage; + final currentOutline = widget.tab.outline.value ?? []; + final index = await pdfToTextPage( - book, - widget.tab.outline.value ?? [], - controller.pageNumber ?? 1, - context); - openBook(context, snapshot.data!, index ?? 0, ''); + book, currentOutline, currentPage, context); + + openBook(context, snapshot.data!, index ?? 0, '', + ignoreHistory: true); }) : const SizedBox.shrink(), ); diff --git a/lib/search/view/tantivy_full_text_search.dart b/lib/search/view/tantivy_full_text_search.dart index c469b7822..87041a68f 100644 --- a/lib/search/view/tantivy_full_text_search.dart +++ b/lib/search/view/tantivy_full_text_search.dart @@ -266,7 +266,7 @@ class _TantivyFullTextSearchState extends State child: Center( child: Text( state.results.isEmpty && state.searchQuery.isEmpty - ? 'עוד לא בוצע חיפוש' + ? 'לא בוצע חיפוש' : '${state.results.length} מתוך ${state.totalResults}', style: const TextStyle(fontSize: 14), ), diff --git a/lib/tabs/bloc/tabs_bloc.dart b/lib/tabs/bloc/tabs_bloc.dart index 60058e8da..11e087268 100644 --- a/lib/tabs/bloc/tabs_bloc.dart +++ b/lib/tabs/bloc/tabs_bloc.dart @@ -41,6 +41,7 @@ class TabsBloc extends Bloc { } void _onAddTab(AddTab event, Emitter emit) { + print('DEBUG: הוספת טאב חדש - ${event.tab.title}'); final newTabs = List.from(state.tabs); final newIndex = min(state.currentTabIndex + 1, newTabs.length); newTabs.insert(newIndex, event.tab); @@ -67,6 +68,8 @@ class TabsBloc extends Bloc { void _onSetCurrentTab(SetCurrentTab event, Emitter emit) { if (event.index >= 0 && event.index < state.tabs.length) { + print( + 'DEBUG: מעבר לטאב ${event.index} - ${state.tabs[event.index].title}'); _repository.saveTabs(state.tabs, event.index); emit(state.copyWith(currentTabIndex: event.index)); } diff --git a/lib/tabs/models/text_tab.dart b/lib/tabs/models/text_tab.dart index 3fe6a1235..6f166d92f 100644 --- a/lib/tabs/models/text_tab.dart +++ b/lib/tabs/models/text_tab.dart @@ -1,5 +1,6 @@ import 'package:otzaria/text_book/bloc/text_book_bloc.dart'; import 'package:otzaria/text_book/text_book_repository.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:otzaria/text_book/bloc/text_book_state.dart'; import 'package:otzaria/data/data_providers/file_system_data_provider.dart'; import 'package:otzaria/models/books.dart'; @@ -23,6 +24,16 @@ class TextBookTab extends OpenedTab { /// The bloc that manages the text book state and logic. late final TextBookBloc bloc; + final ItemScrollController scrollController = ItemScrollController(); + final ItemPositionsListener positionsListener = + ItemPositionsListener.create(); + // בקרים נוספים עבור תצוגה מפוצלת או רשימות מקבילות + final ItemScrollController auxScrollController = ItemScrollController(); + final ItemPositionsListener auxPositionsListener = + ItemPositionsListener.create(); + final ScrollOffsetController mainOffsetController = ScrollOffsetController(); + final ScrollOffsetController auxOffsetController = ScrollOffsetController(); + List? commentators; /// Creates a new instance of [TextBookTab]. @@ -39,6 +50,7 @@ class TextBookTab extends OpenedTab { bool openLeftPane = false, bool splitedView = true, }) : super(book.title) { + print('DEBUG: TextBookTab נוצר עם אינדקס: $index לספר: ${book.title}'); // Initialize the bloc with initial state bloc = TextBookBloc( repository: TextBookRepository( @@ -51,6 +63,8 @@ class TextBookTab extends OpenedTab { commentators ?? [], searchText, ), + scrollController: scrollController, + positionsListener: positionsListener, ); } diff --git a/lib/tabs/reading_screen.dart b/lib/tabs/reading_screen.dart index 524b464b5..d38464218 100644 --- a/lib/tabs/reading_screen.dart +++ b/lib/tabs/reading_screen.dart @@ -146,10 +146,14 @@ class _ReadingScreenState extends State controller.addListener(() { if (controller.indexIsChanging && state.currentTabIndex < state.tabs.length) { + // שמירת המצב הנוכחי לפני המעבר לטאב אחר + print( + 'DEBUG: מעבר בין טאבים - שמירת מצב טאב ${state.currentTabIndex}'); context.read().add(CaptureStateForHistory( state.tabs[state.currentTabIndex])); } if (controller.index != state.currentTabIndex) { + print('DEBUG: עדכון טאב נוכחי ל-${controller.index}'); context.read().add(SetCurrentTab(controller.index)); } }); diff --git a/lib/text_book/bloc/text_book_bloc.dart b/lib/text_book/bloc/text_book_bloc.dart index 11fdbda67..d90d8b76f 100644 --- a/lib/text_book/bloc/text_book_bloc.dart +++ b/lib/text_book/bloc/text_book_bloc.dart @@ -11,10 +11,14 @@ import 'package:otzaria/data/data_providers/file_system_data_provider.dart'; class TextBookBloc extends Bloc { final TextBookRepository _repository; + final ItemScrollController scrollController; + final ItemPositionsListener positionsListener; TextBookBloc({ required TextBookRepository repository, required TextBookInitial initialState, + required this.scrollController, + required this.positionsListener, }) : _repository = repository, super(initialState) { on(_onLoadContent); @@ -49,6 +53,8 @@ class TextBookBloc extends Bloc { final book = initial.book; final searchText = initial.searchText; + print('DEBUG: TextBookBloc טוען תוכן עם אינדקס ראשוני: ${initial.index}'); + emit(TextBookLoading( book, initial.index, initial.showLeftPane, initial.commentators)); try { @@ -68,25 +74,29 @@ class TextBookBloc extends Bloc { final removeNikud = defaultRemoveNikud && (removeNikudFromTanach || !isTanach); - // Create controllers if this is the first load - final ItemScrollController scrollController = ItemScrollController(); - final ScrollOffsetController scrollOffsetController = - ScrollOffsetController(); - final ItemPositionsListener positionsListener = - ItemPositionsListener.create(); - // Set up position listener with debouncing to prevent excessive updates Timer? debounceTimer; positionsListener.itemPositions.addListener(() { // Cancel previous timer if exists debounceTimer?.cancel(); - + // Set new timer with 100ms delay debounceTimer = Timer(const Duration(milliseconds: 100), () { - final visibleInecies = - positionsListener.itemPositions.value.map((e) => e.index).toList(); + final visibleInecies = positionsListener.itemPositions.value + .map((e) => e.index) + .toList(); if (visibleInecies.isNotEmpty) { add(UpdateVisibleIndecies(visibleInecies)); + // עדכון המיקום בטאב כדי למנוע בלבול במעבר בין תצוגות + if (state is TextBookLoaded) { + final currentState = state as TextBookLoaded; + // מעדכנים את המיקום בטאב רק אם יש שינוי משמעותי + final newIndex = visibleInecies.first; + if ((currentState.visibleIndices.isEmpty || + (currentState.visibleIndices.first - newIndex).abs() > 5)) { + // כאן נצטרך לעדכן את הטאב - נעשה זאת בהמשך + } + } } }); }); @@ -112,7 +122,6 @@ class TextBookBloc extends Bloc { pinLeftPane: Settings.getValue('key-pin-sidebar') ?? false, searchText: searchText, scrollController: scrollController, - scrollOffsetController: scrollOffsetController, positionsListener: positionsListener, showNotesSidebar: false, selectedTextForNote: null, @@ -196,18 +205,19 @@ class TextBookBloc extends Bloc { ) async { if (state is TextBookLoaded) { final currentState = state as TextBookLoaded; - + // בדיקה אם האינדקסים באמת השתנו if (_listsEqual(currentState.visibleIndices, event.visibleIndecies)) { return; // אין שינוי, לא צריך לעדכן } - + String? newTitle = currentState.currentTitle; // עדכון הכותרת רק אם האינדקס הראשון השתנה - if (event.visibleIndecies.isNotEmpty && - (currentState.visibleIndices.isEmpty || - currentState.visibleIndices.first != event.visibleIndecies.first)) { + if (event.visibleIndecies.isNotEmpty && + (currentState.visibleIndices.isEmpty || + currentState.visibleIndices.first != + event.visibleIndecies.first)) { newTitle = await refFromIndex(event.visibleIndecies.first, Future.value(currentState.tableOfContents)); } @@ -223,7 +233,7 @@ class TextBookBloc extends Bloc { selectedIndex: index)); } } - + /// בדיקה אם שתי רשימות שוות bool _listsEqual(List list1, List list2) { if (list1.length != list2.length) return false; diff --git a/lib/text_book/bloc/text_book_state.dart b/lib/text_book/bloc/text_book_state.dart index 1a5de9fbc..87694bc6a 100644 --- a/lib/text_book/bloc/text_book_state.dart +++ b/lib/text_book/bloc/text_book_state.dart @@ -71,7 +71,6 @@ class TextBookLoaded extends TextBookState { // Controllers final ItemScrollController scrollController; - final ScrollOffsetController scrollOffsetController; final ItemPositionsListener positionsListener; const TextBookLoaded({ @@ -95,7 +94,6 @@ class TextBookLoaded extends TextBookState { required this.pinLeftPane, required this.searchText, required this.scrollController, - required this.scrollOffsetController, required this.positionsListener, this.currentTitle, required this.showNotesSidebar, @@ -130,7 +128,6 @@ class TextBookLoaded extends TextBookState { pinLeftPane: Settings.getValue('key-pin-sidebar') ?? false, searchText: '', scrollController: ItemScrollController(), - scrollOffsetController: ScrollOffsetController(), positionsListener: ItemPositionsListener.create(), visibleIndices: [index], showNotesSidebar: false, @@ -161,7 +158,6 @@ class TextBookLoaded extends TextBookState { bool? pinLeftPane, String? searchText, ItemScrollController? scrollController, - ScrollOffsetController? scrollOffsetController, ItemPositionsListener? positionsListener, String? currentTitle, bool? showNotesSidebar, @@ -191,8 +187,6 @@ class TextBookLoaded extends TextBookState { pinLeftPane: pinLeftPane ?? this.pinLeftPane, searchText: searchText ?? this.searchText, scrollController: scrollController ?? this.scrollController, - scrollOffsetController: - scrollOffsetController ?? this.scrollOffsetController, positionsListener: positionsListener ?? this.positionsListener, currentTitle: currentTitle ?? this.currentTitle, showNotesSidebar: showNotesSidebar ?? this.showNotesSidebar, diff --git a/lib/text_book/view/combined_view/combined_book_screen.dart b/lib/text_book/view/combined_view/combined_book_screen.dart index cc2273238..c6e0f0e50 100644 --- a/lib/text_book/view/combined_view/combined_book_screen.dart +++ b/lib/text_book/view/combined_view/combined_book_screen.dart @@ -46,6 +46,31 @@ class CombinedView extends StatefulWidget { class _CombinedViewState extends State { final GlobalKey _selectionKey = GlobalKey(); + bool _didInitialJump = false; + + void _jumpToInitialIndexWhenReady() { + int attempts = 0; + void tryJump(Duration _) { + if (!mounted) return; + final ctrl = widget.tab.scrollController; + if (ctrl.isAttached) { + ctrl.jumpTo(index: widget.tab.index); + } else if (attempts++ < 5) { + WidgetsBinding.instance.addPostFrameCallback(tryJump); + } + } + + WidgetsBinding.instance.addPostFrameCallback(tryJump); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_didInitialJump) { + _didInitialJump = true; + _jumpToInitialIndexWhenReady(); + } + } // הוסרנו את _showNotesSidebar המקומי - נשתמש ב-state מה-BLoC @@ -533,7 +558,7 @@ $htmlContentToUse maxSpeed: 10000.0, curve: 10.0, accelerationFactor: 5, - scrollController: state.scrollOffsetController, + scrollController: widget.tab.mainOffsetController, child: SelectionArea( key: _selectionKey, contextMenuBuilder: (_, __) => const SizedBox.shrink(), @@ -578,11 +603,10 @@ $htmlContentToUse Widget buildOuterList(TextBookLoaded state) { return ScrollablePositionedList.builder( - key: PageStorageKey(widget.tab), - initialScrollIndex: state.visibleIndices.first, - itemPositionsListener: state.positionsListener, - itemScrollController: state.scrollController, - scrollOffsetController: state.scrollOffsetController, + key: ValueKey('combined-${widget.tab.book.title}'), + itemPositionsListener: widget.tab.positionsListener, + itemScrollController: widget.tab.scrollController, + scrollOffsetController: widget.tab.mainOffsetController, itemCount: widget.data.length, itemBuilder: (context, index) { ExpansibleController controller = ExpansibleController(); diff --git a/lib/text_book/view/splited_view/simple_book_view.dart b/lib/text_book/view/splited_view/simple_book_view.dart index ea804791c..c7e1e565a 100644 --- a/lib/text_book/view/splited_view/simple_book_view.dart +++ b/lib/text_book/view/splited_view/simple_book_view.dart @@ -43,6 +43,31 @@ class SimpleBookView extends StatefulWidget { class _SimpleBookViewState extends State { final GlobalKey _selectionKey = GlobalKey(); + bool _didInitialJump = false; + + void _jumpToInitialIndexWhenReady() { + int attempts = 0; + void tryJump(Duration _) { + if (!mounted) return; + final ctrl = widget.tab.scrollController; + if (ctrl.isAttached) { + ctrl.jumpTo(index: widget.tab.index); + } else if (attempts++ < 5) { + WidgetsBinding.instance.addPostFrameCallback(tryJump); + } + } + + WidgetsBinding.instance.addPostFrameCallback(tryJump); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_didInitialJump) { + _didInitialJump = true; + _jumpToInitialIndexWhenReady(); + } + } // הוסרנו את _showNotesSidebar המקומי - נשתמש ב-state מה-BLoC @@ -539,7 +564,7 @@ $htmlContentToUse if (state is! TextBookLoaded) return const Center(); final bookView = ProgressiveScroll( - scrollController: state.scrollOffsetController, + scrollController: widget.tab.mainOffsetController, maxSpeed: 10000.0, curve: 10.0, accelerationFactor: 5, @@ -587,11 +612,10 @@ $htmlContentToUse child: ctx.ContextMenuRegion( contextMenu: _buildContextMenu(state), child: ScrollablePositionedList.builder( - key: PageStorageKey(widget.tab), - initialScrollIndex: state.visibleIndices.first, - itemPositionsListener: state.positionsListener, - itemScrollController: state.scrollController, - scrollOffsetController: state.scrollOffsetController, + key: ValueKey('simple-${widget.tab.book.title}'), + itemPositionsListener: widget.tab.positionsListener, + itemScrollController: widget.tab.scrollController, + scrollOffsetController: widget.tab.mainOffsetController, itemCount: widget.data.length, itemBuilder: (context, index) { return BlocBuilder( diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index 614595eae..7b78f1785 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -110,6 +110,9 @@ class _TextBookViewerBlocState extends State void initState() { super.initState(); + // וודא שהמיקום הנוכחי נשמר בטאב + print('DEBUG: אתחול טקסט טאב - אינדקס התחלתי: ${widget.tab.index}'); + // אם יש טקסט חיפוש (searchText), נתחיל בלשונית 'חיפוש' (שנמצאת במקום ה-1) // אחרת, נתחיל בלשונית 'ניווט' (שנמצאת במקום ה-0) final int initialIndex = widget.tab.searchText.isNotEmpty ? 1 : 0; @@ -290,18 +293,22 @@ class _TextBookViewerBlocState extends State icon: const Icon(Icons.picture_as_pdf), tooltip: 'פתח ספר במהדורה מודפסת ', onPressed: () async { - final library = DataRepository.instance.library; - final book = await library.then( + final currentIndex = state + .positionsListener.itemPositions.value.isNotEmpty + ? state.positionsListener.itemPositions.value.first.index + : 0; + widget.tab.index = currentIndex; + + final book = await DataRepository.instance.library.then( (library) => library.findBookByTitle(state.book.title, PdfBook), ); final index = await textToPdfPage( state.book, - state.positionsListener.itemPositions.value.isNotEmpty - ? state.positionsListener.itemPositions.value.first.index - : 0, + currentIndex, ); - openBook(context, book!, index ?? 0, ''); + + openBook(context, book!, index ?? 1, '', ignoreHistory: true); }, ) : const SizedBox.shrink(), diff --git a/lib/utils/open_book.dart b/lib/utils/open_book.dart index e693cd02a..1649eebb8 100644 --- a/lib/utils/open_book.dart +++ b/lib/utils/open_book.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:otzaria/history/bloc/history_bloc.dart'; +import 'package:otzaria/history/bloc/history_event.dart'; import 'package:otzaria/navigation/bloc/navigation_bloc.dart'; import 'package:otzaria/navigation/bloc/navigation_event.dart'; import 'package:otzaria/navigation/bloc/navigation_state.dart'; @@ -12,19 +13,40 @@ import 'package:otzaria/tabs/models/text_tab.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:collection/collection.dart'; -void openBook(BuildContext context, Book book, int index, String searchQuery) { +void openBook(BuildContext context, Book book, int index, String searchQuery, + {bool ignoreHistory = false}) { + print('DEBUG: פתיחת ספר - ${book.title}, אינדקס: $index'); + + // שמירת המצב הנוכחי לפני פתיחת ספר חדש כדי למנוע בלבול במיקום + final tabsState = context.read().state; + if (tabsState.hasOpenTabs) { + print('DEBUG: שמירת מצב הטאב הנוכחי לפני פתיחת ספר חדש'); + context + .read() + .add(CaptureStateForHistory(tabsState.currentTab!)); + } + final historyState = context.read().state; - final lastOpened = historyState.history - .firstWhereOrNull((b) => b.book.title == book.title); - - final int initialIndex = lastOpened?.index ?? index; + final lastOpened = ignoreHistory + ? null + : historyState.history + .firstWhereOrNull((b) => b.book.title == book.title); + + // אם ignoreHistory=true או האינדקס שהועבר הוא מחושב ממעבר בין תצוגות, השתמש בו תמיד + // רק אם האינדקס הוא 0 (ברירת מחדל) ולא ignoreHistory, השתמש בהיסטוריה + final int initialIndex = + (ignoreHistory || index != 0) ? index : (lastOpened?.index ?? 0); final List? initialCommentators = lastOpened?.commentatorsToShow; + print( + 'DEBUG: אינדקס סופי לטאב: $initialIndex (מועבר: $index, מהיסטוריה: ${lastOpened?.index})'); + final bool shouldOpenLeftPane = (Settings.getValue('key-pin-sidebar') ?? false) || - (Settings.getValue('key-default-sidebar-open') ?? false); + (Settings.getValue('key-default-sidebar-open') ?? false); if (book is TextBook) { + print('DEBUG: יצירת טאב טקסט עם אינדקס: $initialIndex'); context.read().add(AddTab(TextBookTab( book: book, index: initialIndex, @@ -33,6 +55,7 @@ void openBook(BuildContext context, Book book, int index, String searchQuery) { openLeftPane: shouldOpenLeftPane, ))); } else if (book is PdfBook) { + print('DEBUG: יצירת טאב PDF עם דף: $initialIndex'); context.read().add(AddTab(PdfBookTab( book: book, pageNumber: initialIndex, diff --git a/lib/utils/page_converter.dart b/lib/utils/page_converter.dart index 334df5874..7c23701b9 100644 --- a/lib/utils/page_converter.dart +++ b/lib/utils/page_converter.dart @@ -3,186 +3,196 @@ import 'package:otzaria/data/repository/data_repository.dart'; import 'package:otzaria/models/books.dart'; import 'package:pdfrx/pdfrx.dart'; -/// Represents a node in the hierarchy with its full path -class HierarchyNode { - final T node; - final List path; +// A cache for the generated page maps to avoid rebuilding them on every conversion. +final _pageMapCache = {}; - HierarchyNode(this.node, this.path); -} - -/// Converts a text book page index to the corresponding PDF page number +/// Converts a text book page index to the corresponding PDF page number. /// -/// [bookTitle] is the title of the book -/// [textIndex] is the index in the text version -/// Returns the corresponding page number in the PDF version, or null if not found +/// This function uses a cached, anchor-based map with local interpolation for accuracy and performance. Future textToPdfPage(TextBook textBook, int textIndex) async { - final library = await DataRepository.instance.library; - - // Get both text and PDF versions of the book - - final pdfBook = library.findBookByTitle(textBook.title, PdfBook) as PdfBook?; - + final pdfBook = (await DataRepository.instance.library) + .findBookByTitle(textBook.title, PdfBook) as PdfBook?; if (pdfBook == null) { return null; } - // Find the closest TOC entry with its full hierarchy - final toc = await textBook.tableOfContents; - final hierarchyNode = _findClosestEntryWithHierarchy(toc, textIndex); - if (hierarchyNode == null) { - return null; - } - // Find matching outline entry in PDF using the hierarchy - final outlines = + // It's better to get the outline from a provider/tab if available than to load it every time. + // For now, we load it directly as a fallback. + final outline = await PdfDocument.openFile(pdfBook.path).then((doc) => doc.loadOutline()); - final outlineEntry = - _findMatchingOutlineByHierarchy(outlines, hierarchyNode.path); + final key = '${pdfBook.path}::${textBook.title}'; + final map = + _pageMapCache[key] ??= await _buildPageMap(pdfBook, outline, textBook); - return outlineEntry?.dest?.pageNumber; + return map.textToPdf(textIndex); } -/// Converts a PDF page number to the corresponding text book index +/// Converts a PDF page number to the corresponding text book index. /// -/// [bookTitle] is the title of the book -/// [pdfPage] is the page number in the PDF version -/// Returns the corresponding index in the text version, or null if not found +/// This function uses a cached, anchor-based map with local interpolation for accuracy and performance. Future pdfToTextPage(PdfBook pdfBook, List outline, - int pdfPage, BuildContext context) async { - final library = await DataRepository.instance.library; - - // Get both text and PDF versions of the book - final textBook = - library.findBookByTitle(pdfBook.title, TextBook) as TextBook?; - + int pdfPage, BuildContext ctx) async { + final textBook = (await DataRepository.instance.library) + .findBookByTitle(pdfBook.title, TextBook) as TextBook?; if (textBook == null) { return null; } + final key = '${pdfBook.path}::${textBook.title}'; + final map = + _pageMapCache[key] ??= await _buildPageMap(pdfBook, outline, textBook); - // Find the outline entry with its full hierarchy + return map.pdfToText(pdfPage); +} - final hierarchyNode = _findOutlineByPageWithHierarchy(outline, pdfPage); - if (hierarchyNode == null) { - return null; - } +/// A class that holds a synchronized map of PDF pages and text indices +/// and performs interpolation between them. +class _PageMap { + // Sorted lists of corresponding anchor points. + final List pdfPages; // 1-based + final List textIndices; // 0-based - // Find matching TOC entry using the hierarchy - final toc = await textBook.tableOfContents; - final tocEntry = _findMatchingTocByHierarchy(toc, hierarchyNode.path); + _PageMap(this.pdfPages, this.textIndices); - return tocEntry?.index; -} + /// Converts a PDF page to a text index using binary search and linear interpolation. + int? pdfToText(int page) { + if (pdfPages.isEmpty) return null; -/// Finds the closest TOC entry before the target index and builds its hierarchy -HierarchyNode? _findClosestEntryWithHierarchy( - List entries, int targetIndex, - [List currentPath = const []]) { - HierarchyNode? closest; + final i = _lowerBound(pdfPages, page); + if (i == 0) return textIndices.first; + if (i >= pdfPages.length) return textIndices.last; - for (var entry in entries) { - final path = [...currentPath, entry.text.trim()]; + final pA = pdfPages[i - 1], pB = pdfPages[i]; + final tA = textIndices[i - 1], tB = textIndices[i]; - // Check if this entry is before target and later than current closest - if (entry.index <= targetIndex && - (closest == null || entry.index > closest.node.index)) { - closest = HierarchyNode(entry, path); - } + if (pB == pA) return tA; // Avoid division by zero - // Recursively search children with updated path - final childResult = - _findClosestEntryWithHierarchy(entry.children, targetIndex, path); - if (childResult != null && - (closest == null || childResult.node.index > closest.node.index)) { - closest = childResult; - } + // Linear interpolation + final t = tA + ((page - pA) * (tB - tA) / (pB - pA)).round(); + return t; } - return closest; -} + /// Converts a text index to a PDF page using binary search and linear interpolation. + int? textToPdf(int index) { + if (textIndices.isEmpty) return null; -/// Finds an outline entry by page number and builds its hierarchy -HierarchyNode? _findOutlineByPageWithHierarchy( - List outlines, int targetPage, - [List currentPath = const []]) { - HierarchyNode? closest; + final i = _lowerBound(textIndices, index); + if (i == 0) return pdfPages.first; + if (i >= textIndices.length) return pdfPages.last; - for (var outline in outlines) { - final path = [...currentPath, outline.title.trim()]; + final tA = textIndices[i - 1], tB = textIndices[i]; + final pA = pdfPages[i - 1], pB = pdfPages[i]; - final page = outline.dest?.pageNumber; - if (page != null && - page <= targetPage && - (closest == null || page > (closest.node.dest?.pageNumber ?? -1))) { - closest = HierarchyNode(outline, path); - } + if (tB == tA) return pA; // Avoid division by zero - // Recursively search children with updated path - final result = - _findOutlineByPageWithHierarchy(outline.children, targetPage, path); - if (result != null && - result.node.dest?.pageNumber != null && - (closest == null || - (result.node.dest!.pageNumber > (closest.node.dest?.pageNumber ?? -1)))) { - closest = result; + // Linear interpolation + final p = pA + ((index - tA) * (pB - pA) / (tB - tA)).round(); + return p; + } + + /// Custom implementation of lower_bound for binary search on a sorted list. + int _lowerBound(List a, int x) { + var lo = 0, hi = a.length; + while (lo < hi) { + final mid = (lo + hi) >> 1; + if (a[mid] < x) { + lo = mid + 1; + } else { + hi = mid; + } } + return lo; } - return closest; } -/// Finds a matching outline entry using a hierarchy path -PdfOutlineNode? _findMatchingOutlineByHierarchy( - List outlines, List targetPath, - [int level = 0]) { - if (level >= targetPath.length) { - return null; - } +/// Builds the synchronized anchor map from PDF outline and text Table of Contents. +Future<_PageMap> _buildPageMap( + PdfBook pdf, List outline, TextBook text) async { + // 1. Collect PDF anchors: (page, normalized_path) + final anchorsPdf = _collectPdfAnchors(outline); - final targetTitle = targetPath[level]; + // 2. Collect text anchors from TOC: (index, normalized_path) + final toc = await text.tableOfContents; + final anchorsText = _collectTextAnchors(toc); - for (var outline in outlines) { - if (outline.title.trim() == targetTitle) { - // If we've reached the last level, this is our match - if (level == targetPath.length - 1) { - return outline; - } + // 3. Match anchors by the normalized path. + final pdfPages = []; + final textIndices = []; + final mapTextByRef = {}; + + for (final a in anchorsText) { + mapTextByRef[a.ref] = a.index; + } - // Otherwise, search the next level in the children - final result = _findMatchingOutlineByHierarchy( - outline.children, targetPath, level + 1); - if (result != null) { - return result; + for (final p in anchorsPdf) { + final idx = mapTextByRef[p.ref]; + if (idx != null) { + // To avoid duplicates which can break interpolation logic + if (!pdfPages.contains(p.page) && !textIndices.contains(idx)) { + pdfPages.add(p.page); + textIndices.add(idx); } } } - return null; -} + // Ensure the lists are sorted, as matching might break order. + final zipped = + List.generate(pdfPages.length, (i) => Tuple(pdfPages[i], textIndices[i])); + zipped.sort((a, b) => a.item1.compareTo(b.item1)); -/// Finds a matching TOC entry using a hierarchy path -TocEntry? _findMatchingTocByHierarchy( - List entries, List targetPath, - [int level = 0]) { - if (level >= targetPath.length) { - return null; - } + final sortedPdfPages = zipped.map((e) => e.item1).toList(); + final sortedTextIndices = zipped.map((e) => e.item2).toList(); - final targetText = targetPath[level]; + // Fallback: if there are too few matches, add start/end points. + if (sortedPdfPages.length < 2) { + if (sortedPdfPages.isEmpty) { + sortedPdfPages.add(1); + sortedTextIndices.add(0); + } + // Potentially add last page and last index as another anchor. + } - for (var entry in entries) { - if (entry.text.trim() == targetText) { - // If we've reached the last level, this is our match - if (level == targetPath.length - 1) { - return entry; - } + return _PageMap(sortedPdfPages, sortedTextIndices); +} - // Otherwise, search the next level in the children - final result = - _findMatchingTocByHierarchy(entry.children, targetPath, level + 1); - if (result != null) { - return result; - } +List<({int page, String ref})> _collectPdfAnchors(List nodes, + [String prefix = '']) { + final List<({int page, String ref})> anchors = []; + for (final node in nodes) { + final page = node.dest?.pageNumber; + if (page != null && page > 0) { + final currentPath = + prefix.isEmpty ? node.title.trim() : '$prefix/${node.title.trim()}'; + anchors.add((page: page, ref: _normalize(currentPath))); + anchors.addAll(_collectPdfAnchors(node.children, currentPath)); } } + return anchors; +} + +List<({int index, String ref})> _collectTextAnchors(List entries, + [String prefix = '']) { + final List<({int index, String ref})> anchors = []; + for (final entry in entries) { + final currentPath = + prefix.isEmpty ? entry.text.trim() : '$prefix/${entry.text.trim()}'; + anchors.add((index: entry.index, ref: _normalize(currentPath))); + anchors.addAll(_collectTextAnchors(entry.children, currentPath)); + } + return anchors; +} + +/// Normalizes a string for comparison by removing extra whitespace, punctuation, etc. +String _normalize(String s) { + return s + .replaceAll(RegExp(r'\s+'), ' ') + .replaceAll(RegExp(r'[^\p{L}\p{N}\s/.-]', unicode: true), '') + .toLowerCase() + .trim(); +} - return null; +// A simple tuple class for sorting pairs. +class Tuple { + final T1 item1; + final T2 item2; + Tuple(this.item1, this.item2); } From 826df8093df47b0c09c80018441b174df59203c6 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 2 Sep 2025 15:21:27 +0300 Subject: [PATCH 154/197] =?UTF-8?q?=D7=94=D7=A2=D7=91=D7=A8=D7=AA=20=D7=A4?= =?UTF-8?q?=D7=A2=D7=95=D7=9C=D7=95=D7=AA=20=D7=9C=20Isolate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/indexing_repository.dart | 88 ++- .../services/indexing_isolate_service.dart | 437 +++++++++++++++ lib/navigation/main_window_screen.dart | 13 + .../services/pdf_isolate_service.dart | 438 +++++++++++++++ lib/search/search_repository.dart | 58 +- .../services/search_isolate_service.dart | 501 ++++++++++++++++++ lib/utils/file_processing_isolate.dart | 373 +++++++++++++ lib/utils/isolate_manager.dart | 182 +++++++ 8 files changed, 2032 insertions(+), 58 deletions(-) create mode 100644 lib/indexing/services/indexing_isolate_service.dart create mode 100644 lib/pdf_book/services/pdf_isolate_service.dart create mode 100644 lib/search/services/search_isolate_service.dart create mode 100644 lib/utils/file_processing_isolate.dart create mode 100644 lib/utils/isolate_manager.dart diff --git a/lib/indexing/repository/indexing_repository.dart b/lib/indexing/repository/indexing_repository.dart index abaa95f3f..66840c995 100644 --- a/lib/indexing/repository/indexing_repository.dart +++ b/lib/indexing/repository/indexing_repository.dart @@ -8,13 +8,14 @@ import 'package:otzaria/models/books.dart'; import 'package:otzaria/utils/text_manipulation.dart'; import 'package:pdfrx/pdfrx.dart'; import 'package:otzaria/utils/ref_helper.dart'; +import 'package:otzaria/indexing/services/indexing_isolate_service.dart'; class IndexingRepository { final TantivyDataProvider _tantivyDataProvider; IndexingRepository(this._tantivyDataProvider); - /// Indexes all books in the provided library. + /// Indexes all books in the provided library using an Isolate. /// /// [library] The library containing books to index /// [onProgress] Callback function to report progress @@ -23,63 +24,39 @@ class IndexingRepository { void Function(int processed, int total) onProgress, ) async { _tantivyDataProvider.isIndexing.value = true; - final allBooks = library.getAllBooks(); - final totalBooks = allBooks.length; - int processedBooks = 0; - - for (Book book in allBooks) { - // Check if indexing was cancelled - if (!_tantivyDataProvider.isIndexing.value) { - return; - } - - try { - // Check if this book has already been indexed - if (book is TextBook) { - if (!_tantivyDataProvider.booksDone - .contains("${book.title}textBook")) { - if (_tantivyDataProvider.booksDone.contains( - sha1.convert(utf8.encode((await book.text))).toString())) { - _tantivyDataProvider.booksDone.add("${book.title}textBook"); - } else { - await _indexTextBook(book); - _tantivyDataProvider.booksDone.add("${book.title}textBook"); - } - } - } else if (book is PdfBook) { - if (!_tantivyDataProvider.booksDone - .contains("${book.title}pdfBook")) { - if (_tantivyDataProvider.booksDone.contains( - sha1.convert(await File(book.path).readAsBytes()).toString())) { - _tantivyDataProvider.booksDone.add("${book.title}pdfBook"); - } else { - await _indexPdfBook(book); - _tantivyDataProvider.booksDone.add("${book.title}pdfBook"); - } - } + + try { + // הפעלת האינדוקס ב-Isolate + final progressStream = await IndexingIsolateService.startIndexing(library); + + // האזנה להתקדמות מה-Isolate + await for (final progress in progressStream) { + if (progress.error != null) { + debugPrint('Indexing error: ${progress.error}'); + _tantivyDataProvider.isIndexing.value = false; + throw Exception(progress.error); + } + + // דיווח התקדמות + onProgress(progress.processed, progress.total); + + // עדכון רשימת הספרים שהושלמו + if (progress.currentBook != null) { + _tantivyDataProvider.booksDone.add('${progress.currentBook}textBook'); + _tantivyDataProvider.booksDone.add('${progress.currentBook}pdfBook'); + saveIndexedBooks(); + } + + if (progress.isComplete) { + _tantivyDataProvider.isIndexing.value = false; + break; } - - processedBooks++; - // Report progress - onProgress(processedBooks, totalBooks); - } catch (e) { - // Use async error handling to prevent event loop blocking - await Future.microtask(() { - debugPrint('Error adding ${book.title} to index: $e'); - }); - processedBooks++; - // Still report progress even after error - onProgress(processedBooks, totalBooks); - // Yield control back to event loop after error - await Future.delayed(Duration.zero); } - - await Future.delayed(Duration.zero); - + } catch (e) { + _tantivyDataProvider.isIndexing.value = false; + debugPrint('Error in indexing: $e'); + rethrow; } - - // Reset indexing flag after completion - _tantivyDataProvider.isIndexing.value = false; } /// Indexes a text-based book by processing its content and adding it to the search index and reference index. @@ -198,6 +175,7 @@ class IndexingRepository { /// Cancels the ongoing indexing process. void cancelIndexing() { _tantivyDataProvider.isIndexing.value = false; + IndexingIsolateService.cancelIndexing(); } /// Persists the list of indexed books to disk. diff --git a/lib/indexing/services/indexing_isolate_service.dart b/lib/indexing/services/indexing_isolate_service.dart new file mode 100644 index 000000000..28d48b37f --- /dev/null +++ b/lib/indexing/services/indexing_isolate_service.dart @@ -0,0 +1,437 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:otzaria/library/models/library.dart'; +import 'package:otzaria/models/books.dart'; +import 'package:otzaria/utils/text_manipulation.dart'; +import 'package:otzaria/utils/isolate_manager.dart'; +import 'package:pdfrx/pdfrx.dart'; +import 'package:otzaria/utils/ref_helper.dart'; +import 'package:search_engine/search_engine.dart'; +import 'package:flutter_settings_screens/flutter_settings_screens.dart'; + +/// הודעות לתקשורת עם ה-Isolate +abstract class IndexingMessage {} + +class StartIndexingMessage extends IndexingMessage { + final List books; + final String indexPath; + final String refIndexPath; + + StartIndexingMessage({ + required this.books, + required this.indexPath, + required this.refIndexPath, + }); +} + +class CancelIndexingMessage extends IndexingMessage {} + +class IndexingProgressMessage { + final int processed; + final int total; + final String? currentBook; + final bool isComplete; + final String? error; + + IndexingProgressMessage({ + required this.processed, + required this.total, + this.currentBook, + this.isComplete = false, + this.error, + }); +} + +/// נתוני ספר לאינדוקס +class BookData { + final String title; + final String? path; + final String topics; + final BookType type; + final String? textContent; // לספרי טקסט + + BookData({ + required this.title, + this.path, + required this.topics, + required this.type, + this.textContent, + }); +} + +enum BookType { text, pdf } + +/// שירות אינדוקס שרץ ב-Isolate +class IndexingIsolateService { + static IsolateHandler? _isolateHandler; + static StreamController? _progressController; + static bool _isIndexing = false; + + /// התחלת תהליך האינדוקס ב-Isolate + static Future> startIndexing( + Library library, + ) async { + if (_isIndexing) { + throw Exception('Indexing already in progress'); + } + + _isIndexing = true; + _progressController = StreamController.broadcast(); + + // הכנת נתוני הספרים + final books = await _prepareBooksData(library); + + // קבלת נתיבי האינדקס + final indexPath = '${Settings.getValue('key-library-path') ?? 'C:/אוצריא'}${Platform.pathSeparator}index'; + final refIndexPath = '${Settings.getValue('key-library-path') ?? 'C:/אוצריא'}${Platform.pathSeparator}ref_index'; + + // יצירת ה-Isolate + _isolateHandler = await IsolateManager.getOrCreate( + 'indexing', + _indexingIsolateEntry, + ); + + // האזנה לתגובות מה-Isolate + _isolateHandler!.responses.listen((message) { + if (message is IndexingProgressMessage) { + _progressController?.add(message); + + if (message.isComplete || message.error != null) { + _isIndexing = false; + _progressController?.close(); + _progressController = null; + } + } + }); + + // שליחת הודעת התחלה + _isolateHandler!.send(StartIndexingMessage( + books: books, + indexPath: indexPath, + refIndexPath: refIndexPath, + )); + + return _progressController!.stream; + } + + /// ביטול תהליך האינדוקס + static Future cancelIndexing() async { + if (_isolateHandler != null) { + _isolateHandler!.send(CancelIndexingMessage()); + await IsolateManager.kill('indexing'); + _isolateHandler = null; + } + + _isIndexing = false; + _progressController?.close(); + _progressController = null; + } + + /// הכנת נתוני הספרים לאינדוקס + static Future> _prepareBooksData(Library library) async { + final books = []; + final allBooks = library.getAllBooks(); + + for (final book in allBooks) { + if (book is TextBook) { + // טעינת תוכן הטקסט מראש + final text = await book.text; + books.add(BookData( + title: book.title, + topics: book.topics, + type: BookType.text, + textContent: text, + )); + } else if (book is PdfBook) { + books.add(BookData( + title: book.title, + path: book.path, + topics: book.topics, + type: BookType.pdf, + )); + } + } + + return books; + } +} + +/// נקודת הכניסה ל-Isolate של האינדוקס +void _indexingIsolateEntry(IsolateContext context) { + SearchEngine? searchEngine; + ReferenceSearchEngine? refEngine; + bool shouldCancel = false; + Set booksDone = {}; + + // האזנה להודעות + context.messages.listen((message) async { + if (message is StartIndexingMessage) { + shouldCancel = false; + + try { + // יצירת מנועי החיפוש עם הרשאות כתיבה + searchEngine = SearchEngine(path: message.indexPath); + refEngine = ReferenceSearchEngine(path: message.refIndexPath); + + final totalBooks = message.books.length; + int processedBooks = 0; + + for (final book in message.books) { + if (shouldCancel) break; + + try { + // שליחת עדכון התקדמות + context.send(IndexingProgressMessage( + processed: processedBooks, + total: totalBooks, + currentBook: book.title, + )); + + // אינדוקס הספר + if (book.type == BookType.text) { + await _indexTextBookInIsolate( + book, + searchEngine!, + refEngine!, + booksDone, + ); + } else if (book.type == BookType.pdf) { + await _indexPdfBookInIsolate( + book, + searchEngine!, + booksDone, + ); + } + + processedBooks++; + + // ביצוע commit מדי פעם כדי לשחרר לוקים + if (processedBooks % 10 == 0) { + await searchEngine?.commit(); + await refEngine?.commit(); + } + } catch (e) { + debugPrint('Error indexing ${book.title}: $e'); + processedBooks++; + } + + // תן ל-Isolate לנשום + await Future.delayed(Duration.zero); + } + + // סיום מוצלח + await searchEngine?.commit(); + await refEngine?.commit(); + + context.send(IndexingProgressMessage( + processed: processedBooks, + total: totalBooks, + isComplete: true, + )); + } catch (e) { + context.send(IndexingProgressMessage( + processed: 0, + total: 0, + error: e.toString(), + )); + } + } else if (message is CancelIndexingMessage) { + shouldCancel = true; + } + }); +} + +/// אינדוקס ספר טקסט בתוך ה-Isolate +Future _indexTextBookInIsolate( + BookData book, + SearchEngine searchEngine, + ReferenceSearchEngine refEngine, + Set booksDone, +) async { + // בדיקה אם כבר אונדקס + final bookKey = "${book.title}textBook"; + if (booksDone.contains(bookKey)) return; + + final text = book.textContent ?? ''; + final title = book.title; + final topics = "/${book.topics.replaceAll(', ', '/')}"; + + final texts = text.split('\n'); + List reference = []; + + for (int i = 0; i < texts.length; i++) { + // תן לאירועים אחרים לרוץ + if (i % 100 == 0) { + await Future.delayed(Duration.zero); + } + + String line = texts[i]; + + if (line.startsWith(' + element.substring(0, 4) == line.substring(0, 4))) { + reference.removeRange( + reference.indexWhere((element) => + element.substring(0, 4) == line.substring(0, 4)), + reference.length); + } + reference.add(line); + + // אינדוקס כרפרנס + String refText = stripHtmlIfNeeded(reference.join(" ")); + final shortref = replaceParaphrases(removeSectionNames(refText)); + + refEngine.addDocument( + id: BigInt.from(DateTime.now().microsecondsSinceEpoch), + title: title, + reference: refText, + shortRef: shortref, + segment: BigInt.from(i), + isPdf: false, + filePath: ''); + } else { + line = stripHtmlIfNeeded(line); + line = removeVolwels(line); + + // הוספה לאינדקס + searchEngine.addDocument( + id: BigInt.from(DateTime.now().microsecondsSinceEpoch), + title: title, + reference: stripHtmlIfNeeded(reference.join(', ')), + topics: '$topics/$title', + text: line, + segment: BigInt.from(i), + isPdf: false, + filePath: ''); + } + } + + booksDone.add(bookKey); +} + +/// אינדוקס ספר PDF בתוך ה-Isolate +Future _indexPdfBookInIsolate( + BookData book, + SearchEngine searchEngine, + Set booksDone, +) async { + // בדיקה אם כבר אונדקס + final bookKey = "${book.title}pdfBook"; + if (booksDone.contains(bookKey)) return; + + final document = await PdfDocument.openFile(book.path!); + final pages = document.pages; + final outline = await document.loadOutline(); + final title = book.title; + final topics = "/${book.topics.replaceAll(', ', '/')}"; + + for (int i = 0; i < pages.length; i++) { + final texts = (await pages[i].loadText()).fullText.split('\n'); + + for (int j = 0; j < texts.length; j++) { + // תן לאירועים אחרים לרוץ + if (j % 50 == 0) { + await Future.delayed(Duration.zero); + } + + final bookmark = await refFromPageNumber(i + 1, outline, title); + final ref = bookmark.isNotEmpty + ? '$title, $bookmark, עמוד ${i + 1}' + : '$title, עמוד ${i + 1}'; + + searchEngine.addDocument( + id: BigInt.from(DateTime.now().microsecondsSinceEpoch), + title: title, + reference: ref, + topics: '$topics/$title', + text: texts[j], + segment: BigInt.from(i), + isPdf: true, + filePath: book.path!); + } + } + + booksDone.add(bookKey); +} + +/// מחלקת עזר לגישת קריאה בלבד לאינדקס (לחיפוש במקביל לאינדוקס) +class ReadOnlySearchEngine { + late SearchEngine _engine; + final String indexPath; + + ReadOnlySearchEngine({required this.indexPath}) { + // פתיחת האינדקס במצב קריאה בלבד + _initEngine(); + } + + void _initEngine() { + try { + // ניסיון לפתוח במצב קריאה + _engine = SearchEngine(path: indexPath); + } catch (e) { + debugPrint('Failed to open search engine in read-only mode: $e'); + rethrow; + } + } + + /// חיפוש באינדקס (קריאה בלבד) + Future> search({ + required List regexTerms, + required List facets, + required int limit, + int slop = 0, + int maxExpansions = 10, + ResultsOrder order = ResultsOrder.relevance, + }) async { + try { + return await _engine.search( + regexTerms: regexTerms, + facets: facets, + limit: limit, + slop: slop, + maxExpansions: maxExpansions, + order: order, + ); + } catch (e) { + // אם יש בעיה בגישה, ננסה לפתוח מחדש + _initEngine(); + return await _engine.search( + regexTerms: regexTerms, + facets: facets, + limit: limit, + slop: slop, + maxExpansions: maxExpansions, + order: order, + ); + } + } + + /// ספירת תוצאות (קריאה בלבד) + Future count({ + required List regexTerms, + required List facets, + int slop = 0, + int maxExpansions = 10, + }) async { + try { + return await _engine.count( + regexTerms: regexTerms, + facets: facets, + slop: slop, + maxExpansions: maxExpansions, + ); + } catch (e) { + // אם יש בעיה בגישה, ננסה לפתוח מחדש + _initEngine(); + return await _engine.count( + regexTerms: regexTerms, + facets: facets, + slop: slop, + maxExpansions: maxExpansions, + ); + } + } +} diff --git a/lib/navigation/main_window_screen.dart b/lib/navigation/main_window_screen.dart index d34d79440..70acee689 100644 --- a/lib/navigation/main_window_screen.dart +++ b/lib/navigation/main_window_screen.dart @@ -7,6 +7,7 @@ import 'package:otzaria/indexing/bloc/indexing_event.dart'; import 'package:otzaria/navigation/bloc/navigation_bloc.dart'; import 'package:otzaria/navigation/bloc/navigation_event.dart'; import 'package:otzaria/navigation/bloc/navigation_state.dart'; +import 'package:otzaria/utils/isolate_manager.dart'; import 'package:otzaria/settings/settings_bloc.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; @@ -59,9 +60,21 @@ class MainWindowScreenState extends State @override void dispose() { pageController.dispose(); + // סגירת כל ה-Isolates בעת יציאה מהאפליקציה + _cleanupIsolates(); super.dispose(); } + /// ניקוי כל ה-Isolates + void _cleanupIsolates() async { + try { + await IsolateManager.killAll(); + } catch (e) { + // אם יש שגיאה בניקוי, נמשיך בכל זאת + debugPrint('Error cleaning up isolates: $e'); + } + } + void _handleOrientationChange(BuildContext context, Orientation orientation) { if (_previousOrientation != orientation) { _previousOrientation = orientation; diff --git a/lib/pdf_book/services/pdf_isolate_service.dart b/lib/pdf_book/services/pdf_isolate_service.dart new file mode 100644 index 000000000..ef86db990 --- /dev/null +++ b/lib/pdf_book/services/pdf_isolate_service.dart @@ -0,0 +1,438 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:otzaria/utils/isolate_manager.dart'; +import 'package:pdfrx/pdfrx.dart'; +import 'dart:ui' as ui; + +/// סוגי הודעות לעיבוד PDF +abstract class PdfMessage {} + +class LoadPdfTextMessage extends PdfMessage { + final String filePath; + final int pageNumber; + + LoadPdfTextMessage({ + required this.filePath, + required this.pageNumber, + }); +} + +class SearchPdfMessage extends PdfMessage { + final String filePath; + final String query; + final int maxResults; + + SearchPdfMessage({ + required this.filePath, + required this.query, + this.maxResults = 100, + }); +} + +class GenerateThumbnailMessage extends PdfMessage { + final String filePath; + final int pageNumber; + final double scale; + + GenerateThumbnailMessage({ + required this.filePath, + required this.pageNumber, + this.scale = 0.3, + }); +} + +class LoadOutlineMessage extends PdfMessage { + final String filePath; + + LoadOutlineMessage({required this.filePath}); +} + +/// תוצאות עיבוד PDF +class PdfTextResult { + final String text; + final int pageNumber; + final String? error; + + PdfTextResult({ + required this.text, + required this.pageNumber, + this.error, + }); +} + +class PdfSearchResult { + final List matches; + final String? error; + + PdfSearchResult({ + required this.matches, + this.error, + }); +} + +class PdfSearchMatch { + final int pageNumber; + final String text; + final int startIndex; + final int endIndex; + + PdfSearchMatch({ + required this.pageNumber, + required this.text, + required this.startIndex, + required this.endIndex, + }); +} + +class PdfThumbnailResult { + final Uint8List? imageData; + final int pageNumber; + final String? error; + + PdfThumbnailResult({ + this.imageData, + required this.pageNumber, + this.error, + }); +} + +class PdfOutlineResult { + final List? outline; + final String? error; + + PdfOutlineResult({ + this.outline, + this.error, + }); +} + +/// שירות עיבוד PDF ב-Isolate +class PdfIsolateService { + static final Map _pdfIsolates = {}; + + /// קבלת או יצירת Isolate לקובץ PDF ספציפי + static Future _getOrCreateIsolate(String filePath) async { + final isolateName = 'pdf_${filePath.hashCode}'; + + if (_pdfIsolates.containsKey(isolateName)) { + return _pdfIsolates[isolateName]!; + } + + final handler = await IsolateManager.getOrCreate( + isolateName, + _pdfIsolateEntry, + initialData: {'filePath': filePath}, + ); + + _pdfIsolates[isolateName] = handler; + return handler; + } + + /// טעינת טקסט מעמוד PDF + static Future loadPageText(String filePath, int pageNumber) async { + final isolate = await _getOrCreateIsolate(filePath); + + return await isolate.compute( + LoadPdfTextMessage( + filePath: filePath, + pageNumber: pageNumber, + ), + ); + } + + /// חיפוש טקסט ב-PDF + static Future searchInPdf( + String filePath, + String query, { + int maxResults = 100, + }) async { + final isolate = await _getOrCreateIsolate(filePath); + + return await isolate.compute( + SearchPdfMessage( + filePath: filePath, + query: query, + maxResults: maxResults, + ), + ); + } + + /// יצירת תמונה ממוזערת של עמוד + static Future generateThumbnail( + String filePath, + int pageNumber, { + double scale = 0.3, + }) async { + final isolate = await _getOrCreateIsolate(filePath); + + return await isolate.compute( + GenerateThumbnailMessage( + filePath: filePath, + pageNumber: pageNumber, + scale: scale, + ), + ); + } + + /// טעינת תוכן העניינים של PDF + static Future loadOutline(String filePath) async { + final isolate = await _getOrCreateIsolate(filePath); + + return await isolate.compute( + LoadOutlineMessage(filePath: filePath), + ); + } + + /// שחרור Isolate של קובץ PDF ספציפי + static Future disposePdfIsolate(String filePath) async { + final isolateName = 'pdf_${filePath.hashCode}'; + + if (_pdfIsolates.containsKey(isolateName)) { + await _pdfIsolates[isolateName]!.dispose(); + _pdfIsolates.remove(isolateName); + } + } + + /// שחרור כל ה-Isolates של PDF + static Future disposeAll() async { + for (final isolate in _pdfIsolates.values) { + await isolate.dispose(); + } + _pdfIsolates.clear(); + } +} + +/// נקודת כניסה ל-Isolate של PDF +void _pdfIsolateEntry(IsolateContext context) { + PdfDocument? document; + final filePath = context.initialData?['filePath'] as String?; + + // טעינת המסמך פעם אחת + Future _getDocument() async { + document ??= await PdfDocument.openFile(filePath!); + return document!; + } + + // האזנה להודעות + context.messages.listen((message) async { + try { + if (message is LoadPdfTextMessage) { + final doc = await _getDocument(); + final pages = doc.pages; + + if (message.pageNumber < 0 || message.pageNumber >= pages.length) { + context.send(PdfTextResult( + text: '', + pageNumber: message.pageNumber, + error: 'Invalid page number', + )); + return; + } + + final page = pages[message.pageNumber]; + final textPage = await page.loadText(); + + context.send(PdfTextResult( + text: textPage.fullText, + pageNumber: message.pageNumber, + )); + + } else if (message is SearchPdfMessage) { + final doc = await _getDocument(); + final pages = doc.pages; + final matches = []; + + for (int i = 0; i < pages.length && matches.length < message.maxResults; i++) { + final textPage = await pages[i].loadText(); + final text = textPage.fullText.toLowerCase(); + final query = message.query.toLowerCase(); + + int index = 0; + while ((index = text.indexOf(query, index)) != -1 && + matches.length < message.maxResults) { + // קח קונטקסט סביב המילה שנמצאה + final start = (index - 50).clamp(0, text.length); + final end = (index + query.length + 50).clamp(0, text.length); + + matches.add(PdfSearchMatch( + pageNumber: i, + text: textPage.fullText.substring(start, end), + startIndex: index - start, + endIndex: (index + query.length) - start, + )); + + index += query.length; + } + + // תן לאירועים אחרים לרוץ + if (i % 10 == 0) { + await Future.delayed(Duration.zero); + } + } + + context.send(PdfSearchResult(matches: matches)); + + } else if (message is GenerateThumbnailMessage) { + final doc = await _getDocument(); + final pages = doc.pages; + + if (message.pageNumber < 0 || message.pageNumber >= pages.length) { + context.send(PdfThumbnailResult( + pageNumber: message.pageNumber, + error: 'Invalid page number', + )); + return; + } + + final page = pages[message.pageNumber]; + + // יצירת תמונה של העמוד + final pageImage = await page.render( + width: (page.width * message.scale).toInt(), + height: (page.height * message.scale).toInt(), + ); + + // בדיקה שהרינדור הצליח + if (pageImage == null) { + context.send(PdfThumbnailResult( + pageNumber: message.pageNumber, + error: 'Failed to render page image.', + )); + return; + } + + // המרה ל-PNG + final uiImage = await pageImage.createImage(); + final byteData = await uiImage.toByteData(format: ui.ImageByteFormat.png); + + if (byteData == null) { + context.send(PdfThumbnailResult( + pageNumber: message.pageNumber, + error: 'Failed to convert image to byte data.', + )); + return; + } + + // 3. המרת ה-ByteData לרשימת בתים (Uint8List) + final Uint8List pngData = byteData.buffer.asUint8List(); + + context.send(PdfThumbnailResult( + imageData: pngData, + pageNumber: message.pageNumber, + )); + + + } else if (message is LoadOutlineMessage) { + final doc = await _getDocument(); + final outline = await doc.loadOutline(); + + context.send(PdfOutlineResult( + outline: outline, + )); + } + } catch (e) { + // שליחת שגיאה חזרה + if (message is LoadPdfTextMessage) { + context.send(PdfTextResult( + text: '', + pageNumber: message.pageNumber, + error: e.toString(), + )); + } else if (message is SearchPdfMessage) { + context.send(PdfSearchResult( + matches: [], + error: e.toString(), + )); + } else if (message is GenerateThumbnailMessage) { + context.send(PdfThumbnailResult( + pageNumber: message.pageNumber, + error: e.toString(), + )); + } else if (message is LoadOutlineMessage) { + context.send(PdfOutlineResult( + error: e.toString(), + )); + } + } + }); +} + +/// מחלקת עזר לעבודה עם PDF text search +class PdfTextSearcher { + final String filePath; + final Map _textCache = {}; + + PdfTextSearcher({required this.filePath}); + + /// טעינת טקסט מעמוד עם cache + Future loadText({required int pageNumber}) async { + if (_textCache.containsKey(pageNumber)) { + return PdfPageText( + fullText: _textCache[pageNumber]!, + fragments: [], + ); + } + + final result = await PdfIsolateService.loadPageText(filePath, pageNumber); + + if (result.error == null) { + _textCache[pageNumber] = result.text; + return PdfPageText( + fullText: result.text, + fragments: [], + ); + } + + return null; + } + + /// חיפוש טקסט בכל ה-PDF + Future> search(String query, {int maxResults = 100}) async { + final result = await PdfIsolateService.searchInPdf( + filePath, + query, + maxResults: maxResults, + ); + + if (result.error == null) { + return result.matches; + } + + return []; + } + + /// ניקוי ה-cache + void clearCache() { + _textCache.clear(); + } + + /// שחרור משאבים + Future dispose() async { + clearCache(); + await PdfIsolateService.disposePdfIsolate(filePath); + } +} + +/// מחלקת עזר ליצירת PdfPageText (תאימות לקוד קיים) +class PdfPageText { + final String fullText; + final List fragments; + + PdfPageText({ + required this.fullText, + required this.fragments, + }); +} + +class PdfTextFragment { + final String text; + final int index; + final int end; + + PdfTextFragment({ + required this.text, + required this.index, + required this.end, + }); +} diff --git a/lib/search/search_repository.dart b/lib/search/search_repository.dart index 81844fd7e..36eb052f3 100644 --- a/lib/search/search_repository.dart +++ b/lib/search/search_repository.dart @@ -1,7 +1,7 @@ import 'dart:math' as math; import 'package:otzaria/data/data_providers/tantivy_data_provider.dart'; -import 'package:otzaria/search/utils/hebrew_morphology.dart'; import 'package:otzaria/search/utils/regex_patterns.dart'; +import 'package:otzaria/search/services/search_isolate_service.dart'; import 'package:search_engine/search_engine.dart'; /// Performs a search operation across indexed texts. @@ -45,8 +45,60 @@ class SearchRepository { final finalPattern = SearchRegexPatterns.createSpellingWithPrefixPattern('ראשית'); print('🔍 Final createSpellingWithPrefixPattern result: $finalPattern'); - final index = await TantivyDataProvider.instance.engine; + // בדיקה אם האינדקס רץ - אם כן, נשתמש ב-Isolate לחיפוש + final isIndexing = TantivyDataProvider.instance.isIndexing.value; + + if (isIndexing) { + print('🔄 Indexing in progress, using isolate search service'); + // שימוש ב-SearchIsolateService כשהאינדקס רץ + final isolateSearchOptions = SearchOptions( + fuzzy: fuzzy, + distance: distance, + customSpacing: customSpacing, + alternativeWords: alternativeWords, + searchOptions: searchOptions, + order: order, + ); + + final resultWrapper = await SearchIsolateService.searchTexts( + query, + facets, + limit, + isolateSearchOptions, + ); + + if (resultWrapper.error != null) { + print('❌ Search isolate error: ${resultWrapper.error}'); + // fallback לחיפוש רגיל + final index = await TantivyDataProvider.instance.engine; + return await _performDirectSearch(index, query, facets, limit, order, + fuzzy, distance, customSpacing, alternativeWords, searchOptions); + } + + print( + '✅ Isolate search completed, found ${resultWrapper.results.length} results'); + return resultWrapper.results; + } else { + // שימוש רגיל כשהאינדקס לא רץ + final index = await TantivyDataProvider.instance.engine; + return await _performDirectSearch(index, query, facets, limit, order, + fuzzy, distance, customSpacing, alternativeWords, searchOptions); + } + } + /// ביצוע חיפוש ישיר (ללא Isolate) + Future> _performDirectSearch( + SearchEngine index, + String query, + List facets, + int limit, + ResultsOrder order, + bool fuzzy, + int distance, + Map? customSpacing, + Map>? alternativeWords, + Map>? searchOptions, + ) async { // בדיקה אם יש מרווחים מותאמים אישית, מילים חילופיות או אפשרויות חיפוש final hasCustomSpacing = customSpacing != null && customSpacing.isNotEmpty; final hasAlternativeWords = @@ -128,7 +180,7 @@ class SearchRepository { maxExpansions: maxExpansions, order: order); - print('✅ Search completed, found ${results.length} results'); + print('✅ Direct search completed, found ${results.length} results'); return results; } diff --git a/lib/search/services/search_isolate_service.dart b/lib/search/services/search_isolate_service.dart new file mode 100644 index 000000000..98b375bed --- /dev/null +++ b/lib/search/services/search_isolate_service.dart @@ -0,0 +1,501 @@ +import 'dart:async'; +import 'package:otzaria/utils/isolate_manager.dart'; +import 'package:otzaria/indexing/services/indexing_isolate_service.dart'; +import 'package:search_engine/search_engine.dart'; +import 'package:flutter_settings_screens/flutter_settings_screens.dart'; +import 'dart:io'; + +/// הודעות חיפוש +abstract class SearchMessage {} + +class TextSearchMessage extends SearchMessage { + final String query; + final List facets; + final int limit; + final SearchOptions options; + final String indexPath; + + TextSearchMessage({ + required this.query, + required this.facets, + required this.limit, + required this.options, + required this.indexPath, + }); +} + +class CountSearchMessage extends SearchMessage { + final String query; + final List facets; + final SearchOptions options; + final String indexPath; + + CountSearchMessage({ + required this.query, + required this.facets, + required this.options, + required this.indexPath, + }); +} + +class BuildRegexMessage extends SearchMessage { + final List words; + final Map>? alternativeWords; + final Map>? searchOptions; + + BuildRegexMessage({ + required this.words, + this.alternativeWords, + this.searchOptions, + }); +} + +/// אפשרויות חיפוש +class SearchOptions { + final bool fuzzy; + final int distance; + final Map? customSpacing; + final Map>? alternativeWords; + final Map>? searchOptions; + final ResultsOrder order; + + SearchOptions({ + this.fuzzy = false, + this.distance = 2, + this.customSpacing, + this.alternativeWords, + this.searchOptions, + this.order = ResultsOrder.relevance, + }); +} + +/// תוצאות חיפוש +class SearchResultWrapper { + final List results; + final int totalCount; + final String? error; + + SearchResultWrapper({ + required this.results, + required this.totalCount, + this.error, + }); +} + +class SearchCountResult { + final int count; + final String? error; + + SearchCountResult({ + required this.count, + this.error, + }); +} + +class RegexBuildResult { + final List regexTerms; + final int slop; + final int maxExpansions; + + RegexBuildResult({ + required this.regexTerms, + required this.slop, + required this.maxExpansions, + }); +} + +/// שירות חיפוש ב-Isolate +class SearchIsolateService { + static IsolateHandler? _searchIsolate; + static ReadOnlySearchEngine? _readOnlyEngine; + + /// אתחול שירות החיפוש + static Future initialize() async { + if (_searchIsolate == null) { + _searchIsolate = await IsolateManager.getOrCreate( + 'search', + _searchIsolateEntry, + ); + + // אתחול מנוע חיפוש לקריאה בלבד + final indexPath = '${Settings.getValue('key-library-path') ?? 'C:/אוצריא'}${Platform.pathSeparator}index'; + _readOnlyEngine = ReadOnlySearchEngine(indexPath: indexPath); + } + } + + /// חיפוש טקסט + static Future searchTexts( + String query, + List facets, + int limit, + SearchOptions options, + ) async { + await initialize(); + + final indexPath = '${Settings.getValue('key-library-path') ?? 'C:/אוצריא'}${Platform.pathSeparator}index'; + + return await _searchIsolate!.compute( + TextSearchMessage( + query: query, + facets: facets, + limit: limit, + options: options, + indexPath: indexPath, + ), + ); + } + + /// ספירת תוצאות + static Future countResults( + String query, + List facets, + SearchOptions options, + ) async { + await initialize(); + + final indexPath = '${Settings.getValue('key-library-path') ?? 'C:/אוצריא'}${Platform.pathSeparator}index'; + + return await _searchIsolate!.compute( + CountSearchMessage( + query: query, + facets: facets, + options: options, + indexPath: indexPath, + ), + ); + } + + /// בניית ביטויים רגולריים מורכבים + static Future buildRegex( + List words, + Map>? alternativeWords, + Map>? searchOptions, + ) async { + await initialize(); + + return await _searchIsolate!.compute( + BuildRegexMessage( + words: words, + alternativeWords: alternativeWords, + searchOptions: searchOptions, + ), + ); + } + + /// שחרור משאבים + static Future dispose() async { + if (_searchIsolate != null) { + await IsolateManager.kill('search'); + _searchIsolate = null; + } + _readOnlyEngine = null; + } +} + +/// נקודת כניסה ל-Isolate של חיפוש +void _searchIsolateEntry(IsolateContext context) { + SearchEngine? searchEngine; + String? currentIndexPath; + + // פונקציה לקבלת או יצירת מנוע חיפוש + Future _getEngine(String indexPath) async { + if (searchEngine == null || currentIndexPath != indexPath) { + searchEngine = SearchEngine(path: indexPath); + currentIndexPath = indexPath; + } + return searchEngine!; + } + + // האזנה להודעות + context.messages.listen((message) async { + try { + if (message is TextSearchMessage) { + // ביצוע חיפוש + final engine = await _getEngine(message.indexPath); + + // בניית פרמטרי החיפוש + final regexData = _buildSearchParams( + message.query, + message.options, + ); + + // ביצוע החיפוש + final results = await engine.search( + regexTerms: regexData.regexTerms, + facets: message.facets, + limit: message.limit, + slop: regexData.slop, + maxExpansions: regexData.maxExpansions, + order: message.options.order, + ); + + context.send(SearchResultWrapper( + results: results, + totalCount: results.length, + )); + + } else if (message is CountSearchMessage) { + // ספירת תוצאות + final engine = await _getEngine(message.indexPath); + + // בניית פרמטרי החיפוש + final regexData = _buildSearchParams( + message.query, + message.options, + ); + + // ביצוע הספירה + final count = await engine.count( + regexTerms: regexData.regexTerms, + facets: message.facets, + slop: regexData.slop, + maxExpansions: regexData.maxExpansions, + ); + + context.send(SearchCountResult(count: count)); + + } else if (message is BuildRegexMessage) { + // בניית רגקס + final result = _buildAdvancedRegex( + message.words, + message.alternativeWords, + message.searchOptions, + ); + + context.send(result); + } + } catch (e) { + // שליחת שגיאה + if (message is TextSearchMessage) { + context.send(SearchResultWrapper( + results: [], + totalCount: 0, + error: e.toString(), + )); + } else if (message is CountSearchMessage) { + context.send(SearchCountResult( + count: 0, + error: e.toString(), + )); + } + } + }); +} + +/// בניית פרמטרי חיפוש +RegexBuildResult _buildSearchParams(String query, SearchOptions options) { + final words = query.trim().split(RegExp(r'\s+')); + + final hasCustomSpacing = options.customSpacing != null && options.customSpacing!.isNotEmpty; + final hasAlternativeWords = options.alternativeWords != null && options.alternativeWords!.isNotEmpty; + final hasSearchOptions = options.searchOptions != null && options.searchOptions!.isNotEmpty; + + List regexTerms; + int effectiveSlop; + + if (hasAlternativeWords || hasSearchOptions) { + // בניית query מתקדם + final result = _buildAdvancedRegex(words, options.alternativeWords, options.searchOptions); + regexTerms = result.regexTerms; + effectiveSlop = hasCustomSpacing + ? _getMaxCustomSpacing(options.customSpacing!, words.length) + : (options.fuzzy ? options.distance : 0); + } else if (options.fuzzy) { + regexTerms = words; + effectiveSlop = options.distance; + } else if (words.length == 1) { + regexTerms = [query]; + effectiveSlop = 0; + } else if (hasCustomSpacing) { + regexTerms = words; + effectiveSlop = _getMaxCustomSpacing(options.customSpacing!, words.length); + } else { + regexTerms = words; + effectiveSlop = options.distance; + } + + final maxExpansions = _calculateMaxExpansions( + options.fuzzy, + regexTerms.length, + searchOptions: options.searchOptions, + words: words, + ); + + return RegexBuildResult( + regexTerms: regexTerms, + slop: effectiveSlop, + maxExpansions: maxExpansions, + ); +} + +/// בניית רגקס מתקדם +RegexBuildResult _buildAdvancedRegex( + List words, + Map>? alternativeWords, + Map>? searchOptions, +) { + List regexTerms = []; + + for (int i = 0; i < words.length; i++) { + final word = words[i]; + final wordKey = '${word}_$i'; + + // קבלת אפשרויות החיפוש למילה + final wordOptions = searchOptions?[wordKey] ?? {}; + final hasPrefix = wordOptions['קידומות'] == true; + final hasSuffix = wordOptions['סיומות'] == true; + final hasGrammaticalPrefixes = wordOptions['קידומות דקדוקיות'] == true; + final hasGrammaticalSuffixes = wordOptions['סיומות דקדוקיות'] == true; + final hasFullPartialSpelling = wordOptions['כתיב מלא/חסר'] == true; + final hasPartialWord = wordOptions['חלק ממילה'] == true; + + // קבלת מילים חילופיות + final alternatives = alternativeWords?[i]; + + // בניית רשימת כל האפשרויות + final allOptions = [word]; + if (alternatives != null && alternatives.isNotEmpty) { + allOptions.addAll(alternatives); + } + + // סינון אפשרויות ריקות + final validOptions = allOptions.where((w) => w.trim().isNotEmpty).toList(); + + if (validOptions.isNotEmpty) { + final allVariations = {}; + + for (final option in validOptions) { + String pattern = option; + + // החלת אפשרויות חיפוש + if (hasFullPartialSpelling) { + pattern = _createSpellingPattern(pattern); + } + + if (hasGrammaticalPrefixes && hasGrammaticalSuffixes) { + pattern = _createFullMorphologicalPattern(pattern); + } else if (hasGrammaticalPrefixes) { + pattern = _createPrefixPattern(pattern); + } else if (hasGrammaticalSuffixes) { + pattern = _createSuffixPattern(pattern); + } else if (hasPrefix) { + pattern = '.*${RegExp.escape(pattern)}'; + } else if (hasSuffix) { + pattern = '${RegExp.escape(pattern)}.*'; + } else if (hasPartialWord) { + pattern = '.*${RegExp.escape(pattern)}.*'; + } else { + pattern = RegExp.escape(pattern); + } + + allVariations.add(pattern); + } + + // הגבלת מספר הוריאציות + final limitedVariations = allVariations.length > 20 + ? allVariations.take(20).toList() + : allVariations.toList(); + + final finalPattern = limitedVariations.length == 1 + ? limitedVariations.first + : '(${limitedVariations.join('|')})'; + + regexTerms.add(finalPattern); + } else { + regexTerms.add(word); + } + } + + return RegexBuildResult( + regexTerms: regexTerms, + slop: 0, + maxExpansions: 100, + ); +} + +/// חישוב מרווח מקסימלי +int _getMaxCustomSpacing(Map customSpacing, int wordCount) { + int maxSpacing = 0; + + for (int i = 0; i < wordCount - 1; i++) { + final spacingKey = '$i-${i + 1}'; + final customSpacingValue = customSpacing[spacingKey]; + + if (customSpacingValue != null && customSpacingValue.isNotEmpty) { + final spacingNum = int.tryParse(customSpacingValue) ?? 0; + maxSpacing = maxSpacing > spacingNum ? maxSpacing : spacingNum; + } + } + + return maxSpacing; +} + +/// חישוב maxExpansions +int _calculateMaxExpansions( + bool fuzzy, + int termCount, { + Map>? searchOptions, + List? words, +}) { + bool hasSuffixOrPrefix = false; + int shortestWordLength = 10; + + if (searchOptions != null && words != null) { + for (int i = 0; i < words.length; i++) { + final word = words[i]; + final wordKey = '${word}_$i'; + final wordOptions = searchOptions[wordKey] ?? {}; + + if (wordOptions['סיומות'] == true || + wordOptions['קידומות'] == true || + wordOptions['קידומות דקדוקיות'] == true || + wordOptions['סיומות דקדוקיות'] == true || + wordOptions['חלק ממילה'] == true) { + hasSuffixOrPrefix = true; + if (word.length < shortestWordLength) { + shortestWordLength = word.length; + } + } + } + } + + if (fuzzy) { + return 50; + } else if (hasSuffixOrPrefix) { + if (shortestWordLength <= 1) { + return 2000; + } else if (shortestWordLength <= 2) { + return 3000; + } else if (shortestWordLength <= 3) { + return 4000; + } else { + return 5000; + } + } else if (termCount > 1) { + return 100; + } else { + return 10; + } +} + +// פונקציות עזר ליצירת דפוסי רגקס +String _createSpellingPattern(String word) { + // יצירת וריאציות כתיב מלא/חסר + return word.replaceAll('י', '[י]?') + .replaceAll('ו', '[ו]?') + .replaceAll("'", "['\"]*"); +} + +String _createPrefixPattern(String word) { + return r'(ו|מ|כ|ב|ש|ל|ה|ד)?(כ|ב|ש|ל|ה|ד)?(ה)?' + RegExp.escape(word); +} + +String _createSuffixPattern(String word) { + return RegExp.escape(word) + r'(ות|ים|יה|יו|יך|ינו|יכם|יכן|יהם|יהן|י|ך|ו|ה|נו|כם|כן|ם|ן)?'; +} + +String _createFullMorphologicalPattern(String word) { + return r'(ו|מ|כ|ב|ש|ל|ה|ד)?(כ|ב|ש|ל|ה|ד)?(ה)?' + + RegExp.escape(word) + + r'(ות|ים|יה|יו|יך|ינו|יכם|יכן|יהם|יהן|י|ך|ו|ה|נו|כם|כן|ם|ן)?'; +} diff --git a/lib/utils/file_processing_isolate.dart b/lib/utils/file_processing_isolate.dart new file mode 100644 index 000000000..09470b0c8 --- /dev/null +++ b/lib/utils/file_processing_isolate.dart @@ -0,0 +1,373 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:otzaria/utils/isolate_manager.dart'; +import 'package:otzaria/utils/text_manipulation.dart'; + +/// סוגי הודעות לעיבוד קבצים +abstract class FileMessage {} + +class ReadFileMessage extends FileMessage { + final String filePath; + final String? encoding; + + ReadFileMessage({ + required this.filePath, + this.encoding = 'utf8', + }); +} + +class WriteFileMessage extends FileMessage { + final String filePath; + final String content; + final FileMode mode; + + WriteFileMessage({ + required this.filePath, + required this.content, + this.mode = FileMode.write, + }); +} + +class ParseJsonMessage extends FileMessage { + final String jsonString; + + ParseJsonMessage({required this.jsonString}); +} + +class EncodeJsonMessage extends FileMessage { + final dynamic object; + final bool pretty; + + EncodeJsonMessage({ + required this.object, + this.pretty = false, + }); +} + +class ProcessTextMessage extends FileMessage { + final String text; + final List operations; + + ProcessTextMessage({ + required this.text, + required this.operations, + }); +} + +class ParseTocMessage extends FileMessage { + final String bookContent; + + ParseTocMessage({required this.bookContent}); +} + +/// פעולות עיבוד טקסט +enum TextOperation { + stripHtml, + removeVowels, + removeSectionNames, + replaceParaphrases, +} + +/// תוצאות עיבוד קבצים +class FileResult { + final T? data; + final String? error; + final bool success; + + FileResult({ + this.data, + this.error, + }) : success = error == null; +} + +/// שירות עיבוד קבצים ב-Isolate +class FileProcessingIsolate { + static IsolateHandler? _isolateHandler; + + /// אתחול ה-Isolate + static Future initialize() async { + _isolateHandler ??= await IsolateManager.getOrCreate( + 'file_processing', + _fileProcessingEntry, + ); + } + + /// קריאת קובץ טקסט + static Future> readFile(String filePath, {String encoding = 'utf8'}) async { + await initialize(); + return await _isolateHandler!.compute>( + ReadFileMessage(filePath: filePath, encoding: encoding), + ); + } + + /// כתיבת קובץ טקסט + static Future> writeFile( + String filePath, + String content, { + FileMode mode = FileMode.write, + }) async { + await initialize(); + return await _isolateHandler!.compute>( + WriteFileMessage( + filePath: filePath, + content: content, + mode: mode, + ), + ); + } + + /// פענוח JSON + static Future> parseJson(String jsonString) async { + await initialize(); + return await _isolateHandler!.compute>( + ParseJsonMessage(jsonString: jsonString), + ); + } + + /// קידוד JSON + static Future> encodeJson(dynamic object, {bool pretty = false}) async { + await initialize(); + return await _isolateHandler!.compute>( + EncodeJsonMessage(object: object, pretty: pretty), + ); + } + + /// עיבוד טקסט עם פעולות שונות + static Future> processText( + String text, + List operations, + ) async { + await initialize(); + return await _isolateHandler!.compute>( + ProcessTextMessage(text: text, operations: operations), + ); + } + + /// פענוח תוכן עניינים + static Future>> parseToc(String bookContent) async { + await initialize(); + return await _isolateHandler!.compute>>( + ParseTocMessage(bookContent: bookContent), + ); + } + + /// שחרור ה-Isolate + static Future dispose() async { + if (_isolateHandler != null) { + await IsolateManager.kill('file_processing'); + _isolateHandler = null; + } + } +} + +/// נקודת כניסה ל-Isolate של עיבוד קבצים +void _fileProcessingEntry(IsolateContext context) { + // האזנה להודעות + context.messages.listen((message) async { + try { + if (message is ReadFileMessage) { + // קריאת קובץ + final file = File(message.filePath); + + if (!await file.exists()) { + context.send(FileResult( + error: 'File not found: ${message.filePath}', + )); + return; + } + + String content; + if (message.encoding == 'utf8') { + content = await file.readAsString(encoding: utf8); + } else { + content = await file.readAsString(); + } + + context.send(FileResult(data: content)); + + } else if (message is WriteFileMessage) { + // כתיבת קובץ + final file = File(message.filePath); + + // יצירת תיקייה אם לא קיימת + final directory = file.parent; + if (!await directory.exists()) { + await directory.create(recursive: true); + } + + await file.writeAsString( + message.content, + mode: message.mode, + encoding: utf8, + ); + + context.send(FileResult(data: true)); + + } else if (message is ParseJsonMessage) { + // פענוח JSON + final parsed = jsonDecode(message.jsonString); + context.send(FileResult(data: parsed)); + + } else if (message is EncodeJsonMessage) { + // קידוד JSON + String encoded; + if (message.pretty) { + const encoder = JsonEncoder.withIndent(' '); + encoded = encoder.convert(message.object); + } else { + encoded = jsonEncode(message.object); + } + + context.send(FileResult(data: encoded)); + + } else if (message is ProcessTextMessage) { + // עיבוד טקסט + String result = message.text; + + for (final operation in message.operations) { + switch (operation) { + case TextOperation.stripHtml: + result = stripHtmlIfNeeded(result); + break; + case TextOperation.removeVowels: + result = removeVolwels(result); + break; + case TextOperation.removeSectionNames: + result = removeSectionNames(result); + break; + case TextOperation.replaceParaphrases: + result = replaceParaphrases(result); + break; + } + } + + context.send(FileResult(data: result)); + + } else if (message is ParseTocMessage) { + // פענוח תוכן עניינים + final toc = _parseTocInIsolate(message.bookContent); + context.send(FileResult>(data: toc)); + } + } catch (e) { + // שליחת שגיאה + if (message is ReadFileMessage) { + context.send(FileResult(error: e.toString())); + } else if (message is WriteFileMessage) { + context.send(FileResult(error: e.toString())); + } else if (message is ParseJsonMessage) { + context.send(FileResult(error: e.toString())); + } else if (message is EncodeJsonMessage) { + context.send(FileResult(error: e.toString())); + } else if (message is ProcessTextMessage) { + context.send(FileResult(error: e.toString())); + } else if (message is ParseTocMessage) { + context.send(FileResult>(error: e.toString())); + } + } + }); +} + +/// פענוח תוכן עניינים בתוך ה-Isolate +List _parseTocInIsolate(String bookContent) { + List lines = bookContent.split('\n'); + List toc = []; + Map parents = {}; + + for (int i = 0; i < lines.length; i++) { + final String line = lines[i]; + if (line.startsWith(' children = []; + + TocEntry({ + required this.text, + required this.index, + required this.level, + this.parent, + }); + + Map toJson() => { + 'text': text, + 'index': index, + 'level': level, + 'children': children.map((e) => e.toJson()).toList(), + }; +} + +/// כיתת עזר לקריאה וכתיבה אסינכרונית של קבצים גדולים +class LargeFileProcessor { + /// קריאת קובץ גדול בחלקים + static Stream readLargeFile(String filePath, {int chunkSize = 1024 * 1024}) async* { + final file = File(filePath); + final inputStream = file.openRead(); + + await for (final chunk in inputStream.transform(utf8.decoder)) { + yield chunk; + } + } + + /// כתיבת קובץ גדול בחלקים + static Future writeLargeFile( + String filePath, + Stream dataStream, + ) async { + final file = File(filePath); + final sink = file.openWrite(); + + await for (final chunk in dataStream) { + sink.write(chunk); + } + + await sink.flush(); + await sink.close(); + } + + /// עיבוד קובץ JSON גדול שורה-שורה + static Stream processJsonLines(String filePath) async* { + final file = File(filePath); + final lines = file.openRead() + .transform(utf8.decoder) + .transform(const LineSplitter()); + + await for (final line in lines) { + if (line.trim().isNotEmpty) { + try { + yield jsonDecode(line); + } catch (e) { + debugPrint('Error parsing JSON line: $e'); + } + } + } + } +} diff --git a/lib/utils/isolate_manager.dart b/lib/utils/isolate_manager.dart new file mode 100644 index 000000000..5ed23ba03 --- /dev/null +++ b/lib/utils/isolate_manager.dart @@ -0,0 +1,182 @@ +import 'dart:async'; +import 'dart:isolate'; + +/// מנהל מרכזי לכל ה-Isolates באפליקציה +/// +/// מטפל ביצירה, תקשורת והשמדה של Isolates לפעולות כבדות +class IsolateManager { + static final Map _isolates = {}; + + /// יצירת Isolate חדש או קבלת קיים + static Future getOrCreate( + String name, + IsolateEntryPoint entryPoint, { + Map? initialData, + }) async { + if (_isolates.containsKey(name)) { + return _isolates[name]!; + } + + final handler = await IsolateHandler.spawn( + name: name, + entryPoint: entryPoint, + initialData: initialData, + ); + + _isolates[name] = handler; + return handler; + } + + /// סגירת Isolate ספציפי + static Future kill(String name) async { + final isolate = _isolates[name]; + if (isolate != null) { + await isolate.dispose(); + _isolates.remove(name); + } + } + + /// סגירת כל ה-Isolates + static Future killAll() async { + for (final isolate in _isolates.values) { + await isolate.dispose(); + } + _isolates.clear(); + } +} + +/// נקודת כניסה ל-Isolate +typedef IsolateEntryPoint = void Function(IsolateContext context); + +/// הקשר של ה-Isolate +class IsolateContext { + final SendPort sendPort; + final Map? initialData; + final ReceivePort receivePort = ReceivePort(); + + IsolateContext({ + required this.sendPort, + this.initialData, + }); + + /// שליחת תוצאה חזרה ל-Main thread + void send(dynamic message) { + sendPort.send(message); + } + + /// האזנה להודעות מה-Main thread + Stream get messages => receivePort.asBroadcastStream(); +} + +/// מטפל ב-Isolate בודד +class IsolateHandler { + final String name; + final Isolate _isolate; + final SendPort _sendPort; + final ReceivePort _receivePort; + final StreamController _responseController; + + IsolateHandler._({ + required this.name, + required Isolate isolate, + required SendPort sendPort, + required ReceivePort receivePort, + }) : _isolate = isolate, + _sendPort = sendPort, + _receivePort = receivePort, + _responseController = StreamController.broadcast(); + + /// יצירת Isolate חדש + static Future spawn({ + required String name, + required IsolateEntryPoint entryPoint, + Map? initialData, + }) async { + final receivePort = ReceivePort(); + final completer = Completer(); + + // האזנה להודעה הראשונה שתכיל את ה-SendPort + receivePort.listen((message) { + if (message is SendPort && !completer.isCompleted) { + completer.complete(message); + } + }); + + // יצירת ה-Isolate + final isolate = await Isolate.spawn( + _isolateEntryWrapper, + _IsolateStartupData( + sendPort: receivePort.sendPort, + entryPoint: entryPoint, + initialData: initialData, + ), + ); + + // קבלת ה-SendPort מה-Isolate + final sendPort = await completer.future; + + final handler = IsolateHandler._( + name: name, + isolate: isolate, + sendPort: sendPort, + receivePort: receivePort, + ); + + // האזנה להודעות מה-Isolate + receivePort.listen((message) { + if (message is! SendPort) { + handler._responseController.add(message); + } + }); + + return handler; + } + + /// שליחת הודעה ל-Isolate + Future compute(dynamic message) async { + _sendPort.send(message); + return await _responseController.stream.first as T; + } + + /// שליחת הודעה ל-Isolate ללא המתנה לתשובה + void send(dynamic message) { + _sendPort.send(message); + } + + /// האזנה לתשובות מה-Isolate + Stream get responses => _responseController.stream; + + /// סגירת ה-Isolate + Future dispose() async { + _isolate.kill(priority: Isolate.immediate); + _receivePort.close(); + await _responseController.close(); + } +} + +/// מידע להפעלת Isolate +class _IsolateStartupData { + final SendPort sendPort; + final IsolateEntryPoint entryPoint; + final Map? initialData; + + _IsolateStartupData({ + required this.sendPort, + required this.entryPoint, + this.initialData, + }); +} + +/// Wrapper לנקודת הכניסה ל-Isolate +void _isolateEntryWrapper(_IsolateStartupData data) { + final context = IsolateContext( + sendPort: data.sendPort, + initialData: data.initialData, + ); + + // שליחת ה-SendPort חזרה ל-Main thread + data.sendPort.send(context.receivePort.sendPort); + + // הפעלת נקודת הכניסה + data.entryPoint(context); +} From d7960a6b16f1a3ca0546819fcbad7b7a5f2cd457 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 2 Sep 2025 23:35:58 +0300 Subject: [PATCH 155/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=A7?= =?UTF-8?q?=D7=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/search/view/full_text_settings_widgets.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/search/view/full_text_settings_widgets.dart b/lib/search/view/full_text_settings_widgets.dart index 458c22edc..6565cd4c0 100644 --- a/lib/search/view/full_text_settings_widgets.dart +++ b/lib/search/view/full_text_settings_widgets.dart @@ -153,7 +153,7 @@ class NumOfResults extends StatelessWidget { return BlocBuilder( builder: (context, state) { return SizedBox( - width: 150, + width: 154, height: 52, // גובה קבוע child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), @@ -565,8 +565,8 @@ class OrderOfResults extends StatelessWidget { return BlocBuilder( builder: (context, state) { return SizedBox( - width: 175, // רוחב גדול יותר לטקסט הארוך - height: 52, // גובה קבוע כמו NumOfResults + width: 183, + height: 52, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), child: DropdownButtonFormField( From 1a648e2c2f6450293d2918cc951aa98bc4f31646 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 3 Sep 2025 00:16:35 +0300 Subject: [PATCH 156/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=91?= =?UTF-8?q?=D7=90=D7=92=20=D7=91=D7=9B=D7=A4=D7=AA=D7=95=D7=A8=D7=99=20?= =?UTF-8?q?=D7=94=D7=99=D7=A1=D7=98=D7=95=D7=A8=D7=99=D7=94,=20=D7=91?= =?UTF-8?q?=D7=9E=D7=A1=D7=9A=20=D7=A2=D7=99=D7=95=D7=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/tabs/reading_screen.dart | 87 +++++++++++++++++++----------------- 1 file changed, 45 insertions(+), 42 deletions(-) diff --git a/lib/tabs/reading_screen.dart b/lib/tabs/reading_screen.dart index d38464218..a1fc4cc25 100644 --- a/lib/tabs/reading_screen.dart +++ b/lib/tabs/reading_screen.dart @@ -30,6 +30,8 @@ class ReadingScreen extends StatefulWidget { State createState() => _ReadingScreenState(); } +const double _kAppBarControlsWidth = 280.0; + class _ReadingScreenState extends State with TickerProviderStateMixin, WidgetsBindingObserver { @override @@ -160,54 +162,55 @@ class _ReadingScreenState extends State return Scaffold( appBar: AppBar( - title: Stack( + // 1. משתמשים בקבוע שהגדרנו עבור הרוחב + leadingWidth: _kAppBarControlsWidth, + leading: Row( + mainAxisSize: MainAxisSize.min, children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - // קבוצת היסטוריה וסימניות - IconButton( - icon: const Icon(Icons.history), - tooltip: 'הצג היסטוריה', - onPressed: () => _showHistoryDialog(context), - ), - IconButton( - icon: const Icon(Icons.bookmark), - tooltip: 'הצג סימניות', - onPressed: () => _showBookmarksDialog(context), - ), - // קו מפריד - Container( - height: 24, - width: 1, - color: Colors.grey.shade400, - margin: const EdgeInsets.symmetric(horizontal: 2), - ), - // קבוצת שולחן עבודה עם אנימציה - SizedBox( - width: 180, // רוחב קבוע למניעת הזזת הטאבים - child: WorkspaceIconButton( - onPressed: () => - _showSaveWorkspaceDialog(context), - ), - ), - ], + // קבוצת היסטוריה וסימניות + IconButton( + icon: const Icon(Icons.history), + tooltip: 'הצג היסטוריה', + onPressed: () => _showHistoryDialog(context), ), - Center( - child: Container( - constraints: const BoxConstraints(maxHeight: 50), - child: TabBar( - controller: controller, - isScrollable: true, - tabAlignment: TabAlignment.center, - tabs: state.tabs - .map((tab) => _buildTab(context, tab, state)) - .toList(), - ), + IconButton( + icon: const Icon(Icons.bookmark), + tooltip: 'הצג סימניות', + onPressed: () => _showBookmarksDialog(context), + ), + // קו מפריד + Container( + height: 24, + width: 1, + color: Colors.grey.shade400, + margin: const EdgeInsets.symmetric(horizontal: 2), + ), + // קבוצת שולחן עבודה עם אנימציה + SizedBox( + width: 180, + child: WorkspaceIconButton( + onPressed: () => _showSaveWorkspaceDialog(context), ), ), ], ), + title: Container( + constraints: const BoxConstraints(maxHeight: 50), + child: TabBar( + controller: controller, + isScrollable: true, + tabAlignment: TabAlignment.center, + tabs: state.tabs + .map((tab) => _buildTab(context, tab, state)) + .toList(), + ), + ), + centerTitle: true, + + // 2. משתמשים באותו קבוע בדיוק עבור ווידג'ט הדמה + actions: const [ + SizedBox(width: _kAppBarControlsWidth), + ], ), body: SizedBox.fromSize( size: MediaQuery.of(context).size, From e7e2bab891b6ef608968e8a278bc017a29e98330 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 3 Sep 2025 21:14:57 +0300 Subject: [PATCH 157/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=AA?= =?UTF-8?q?=D7=A6=D7=95=D7=92=D7=94=20=D7=91=D7=97=D7=99=D7=A4=D7=95=D7=A9?= =?UTF-8?q?=20=D7=91=D7=98=D7=A7=D7=A1=D7=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/text_book/view/text_book_search_screen.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/text_book/view/text_book_search_screen.dart b/lib/text_book/view/text_book_search_screen.dart index e95798f0f..48ad6b2ef 100644 --- a/lib/text_book/view/text_book_search_screen.dart +++ b/lib/text_book/view/text_book_search_screen.dart @@ -179,8 +179,6 @@ class TextBookSearchViewState extends State if (settingsState.replaceHolyNames) { snippet = utils.replaceHolyNames(snippet); } - // החלת עיצוב הסוגריים העגולים - snippet = utils.formatTextWithParentheses(snippet); return ListTile( subtitle: SearchHighlightText(snippet, From df3266be024f5935b396c791309694b6b67918a3 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 4 Sep 2025 07:50:16 +0300 Subject: [PATCH 158/197] =?UTF-8?q?=D7=9E=D7=95=D7=A4=D7=A2=20=D7=99=D7=97?= =?UTF-8?q?=D7=99=D7=93=20=D7=91=D7=9C=D7=91=D7=93!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/main.dart | 9 +++++++++ pubspec.yaml | 1 + 2 files changed, 10 insertions(+) diff --git a/lib/main.dart b/lib/main.dart index 9e3faab0d..443164279 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_single_instance/flutter_single_instance.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hive/hive.dart'; import 'package:otzaria/app.dart'; @@ -75,6 +76,14 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); + // Check for single instance + FlutterSingleInstance flutterSingleInstance = FlutterSingleInstance(); + bool isFirstInstance = await flutterSingleInstance.isFirstInstance(); + if (!isFirstInstance) { + // If not the first instance, exit the app + exit(0); + } + // Initialize bloc observer for debugging Bloc.observer = AppBlocObserver(); diff --git a/pubspec.yaml b/pubspec.yaml index 71c209960..c8955fa7b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -109,6 +109,7 @@ dependencies: sqflite_common_ffi: ^2.3.0 shared_preferences: ^2.5.3 super_clipboard: ^0.9.1 + flutter_single_instance: ^1.1.2 dependency_overrides: # it forces the version of the intl package to be 0.19.0 across all dependencies, even if some packages specify a different compatible version. From 01093dc7eae095040473a4097956d951760aefe3 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 4 Sep 2025 16:19:19 +0300 Subject: [PATCH 159/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=9E?= =?UTF-8?q?=D7=99=D7=93=D7=95=D7=AA=20=D7=96=D7=9E=D7=A0=D7=99=D7=A0=D7=95?= =?UTF-8?q?=20=D7=9C=D7=94=D7=9E=D7=A8=D7=AA=20'=D7=96=D7=9E=D7=9F'=20?= =?UTF-8?q?=D7=91=D7=9E=D7=9E=D7=99=D7=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../measurement_converter_screen.dart | 68 +++++++++---------- .../measurement_data.dart | 40 +++++++++++ 2 files changed, 74 insertions(+), 34 deletions(-) diff --git a/lib/tools/measurement_converter/measurement_converter_screen.dart b/lib/tools/measurement_converter/measurement_converter_screen.dart index 54e7746e2..815315d4f 100644 --- a/lib/tools/measurement_converter/measurement_converter_screen.dart +++ b/lib/tools/measurement_converter/measurement_converter_screen.dart @@ -8,6 +8,7 @@ const List modernLengthUnits = ['ס"מ', 'מטר', 'ק"מ']; const List modernAreaUnits = ['מ"ר', 'דונם']; const List modernVolumeUnits = ['סמ"ק', 'ליטר']; const List modernWeightUnits = ['גרם', 'ק"ג']; +const List modernTimeUnits = ['שניות', 'דקות', 'שעות', 'ימים']; // END OF ADDITIONS class MeasurementConverterScreen extends StatefulWidget { @@ -39,9 +40,7 @@ class _MeasurementConverterScreenState 'שטח': areaConversionFactors.keys.toList()..addAll(modernAreaUnits), 'נפח': volumeConversionFactors.keys.toList()..addAll(modernVolumeUnits), 'משקל': weightConversionFactors.keys.toList()..addAll(modernWeightUnits), - 'זמן': timeConversionFactors.keys.first.isNotEmpty - ? timeConversionFactors[timeConversionFactors.keys.first]!.keys.toList() - : [], + 'זמן': timeConversionFactors.keys.toList()..addAll(modernTimeUnits), }; final Map> _opinions = { @@ -49,7 +48,7 @@ class _MeasurementConverterScreenState 'שטח': modernAreaFactors.keys.toList(), 'נפח': modernVolumeFactors.keys.toList(), 'משקל': modernWeightFactors.keys.toList(), - 'זמן': timeConversionFactors.keys.toList(), + 'זמן': modernTimeFactors.keys.toList(), }; @override @@ -212,6 +211,19 @@ class _MeasurementConverterScreenState return value; // Already in g } break; + case 'זמן': // Base unit: seconds + if (modernTimeUnits.contains(unit)) { + if (unit == 'שניות') return 1.0; + if (unit == 'דקות') return 60.0; + if (unit == 'שעות') return 3600.0; + if (unit == 'ימים') return 86400.0; + + } else { + final value = modernTimeFactors[opinion]![unit]; + if (value == null) return null; + return value; // Already in seconds + } + break; } return null; } @@ -229,26 +241,14 @@ class _MeasurementConverterScreenState } // Check if both units are ancient - bool fromIsAncient = !(_units[_selectedCategory]! - .sublist(_units[_selectedCategory]!.length - modernLengthUnits.length) - .contains(_selectedFromUnit)); - bool toIsAncient = !(_units[_selectedCategory]! - .sublist(_units[_selectedCategory]!.length - modernLengthUnits.length) - .contains(_selectedToUnit)); + final modernUnits = _getModernUnitsForCategory(_selectedCategory); + bool fromIsAncient = !modernUnits.contains(_selectedFromUnit); + bool toIsAncient = !modernUnits.contains(_selectedToUnit); double result = 0.0; // ----- CONVERSION LOGIC ----- - if (_selectedCategory == 'זמן') { - if (_selectedOpinion != null) { - final fromFactor = - timeConversionFactors[_selectedOpinion]![_selectedFromUnit]!; - final toFactor = - timeConversionFactors[_selectedOpinion]![_selectedToUnit]!; - final conversionFactor = fromFactor / toFactor; - result = input * conversionFactor; - } - } else if (fromIsAncient && toIsAncient) { + if (fromIsAncient && toIsAncient) { // Case 1: Ancient to Ancient conversion (doesn't need opinion) double conversionFactor = 1.0; switch (_selectedCategory) { @@ -268,6 +268,10 @@ class _MeasurementConverterScreenState conversionFactor = weightConversionFactors[_selectedFromUnit]![_selectedToUnit]!; break; + case 'זמן': + conversionFactor = + timeConversionFactors[_selectedFromUnit]![_selectedToUnit]!; + break; } result = input * conversionFactor; } else { @@ -509,6 +513,8 @@ class _MeasurementConverterScreenState return modernVolumeUnits; case 'משקל': return modernWeightUnits; + case 'זמן': + return modernTimeUnits; default: return []; } @@ -580,23 +586,17 @@ class _MeasurementConverterScreenState 'שטח': modernAreaUnits, 'נפח': modernVolumeUnits, 'משקל': modernWeightUnits, + 'זמן': modernTimeUnits, }; Widget _buildOpinionSelector() { - // Default to enabled - bool isOpinionEnabled = true; - - // For categories other than 'זמן' - if (_selectedCategory != 'זמן') { - final moderns = _modernUnits[_selectedCategory] ?? []; - final bool isFromModern = moderns.contains(_selectedFromUnit); - final bool isToModern = moderns.contains(_selectedToUnit); - - // Disable ONLY if converting from ancient to ancient - if (!isFromModern && !isToModern) { - isOpinionEnabled = false; - } - } + // Check if opinion selector should be shown + final moderns = _modernUnits[_selectedCategory] ?? []; + final bool isFromModern = moderns.contains(_selectedFromUnit); + final bool isToModern = moderns.contains(_selectedToUnit); + + // Show opinion selector ONLY if at least one unit is modern + bool isOpinionEnabled = isFromModern || isToModern; // If not enabled, don't show the selector at all if (!isOpinionEnabled) { diff --git a/lib/tools/measurement_converter/measurement_data.dart b/lib/tools/measurement_converter/measurement_data.dart index c5fec29a4..5582e9829 100644 --- a/lib/tools/measurement_converter/measurement_data.dart +++ b/lib/tools/measurement_converter/measurement_data.dart @@ -405,7 +405,47 @@ const Map> modernVolumeFactors = { }, }; +// Ancient time units conversion factors (between ancient units) const Map> timeConversionFactors = { + 'הילוך ארבע אמות': { + 'הילוך ארבע אמות': 1, + 'הילוך מאה אמה': 1 / 25, + 'הילוך שלושה רבעי מיל': 1 / 375, + 'הילוך מיל': 1 / 500, + 'הילוך ארבעה מילים': 1 / 2000, + }, + 'הילוך מאה אמה': { + 'הילוך ארבע אמות': 25, + 'הילוך מאה אמה': 1, + 'הילוך שלושה רבעי מיל': 1 / 15, + 'הילוך מיל': 1 / 20, + 'הילוך ארבעה מילים': 1 / 80, + }, + 'הילוך שלושה רבעי מיל': { + 'הילוך ארבע אמות': 375, + 'הילוך מאה אמה': 15, + 'הילוך שלושה רבעי מיל': 1, + 'הילוך מיל': 1 / (1 + 1/3), + 'הילוך ארבעה מילים': 1 / (5 + 1/3), + }, + 'הילוך מיל': { + 'הילוך ארבע אמות': 500, + 'הילוך מאה אמה': 20, + 'הילוך שלושה רבעי מיל': 1 + 1/3, + 'הילוך מיל': 1, + 'הילוך ארבעה מילים': 1 / 4, + }, + 'הילוך ארבעה מילים': { + 'הילוך ארבע אמות': 2000, + 'הילוך מאה אמה': 80, + 'הילוך שלושה רבעי מיל': 5 + 1/3, + 'הילוך מיל': 4, + 'הילוך ארבעה מילים': 1, + }, +}; + +// Modern time conversion factors (ancient units to modern units) +const Map> modernTimeFactors = { 'שולחן ערוך': { 'הילוך ארבע אמות': 2.16, // seconds 'הילוך מאה אמה': 54, // seconds From c8f245c97dfc00c4f491c919202aa7fbbd477f31 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 4 Sep 2025 16:26:27 +0300 Subject: [PATCH 160/197] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20=D7=92?= =?UTF-8?q?=D7=99=D7=A8=D7=A1=D7=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++-- installer/otzaria.iss | 2 +- installer/otzaria_full.iss | 2 +- pubspec.yaml | 4 ++-- version.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index b57d2ca8f..60749fd0f 100644 --- a/.gitignore +++ b/.gitignore @@ -60,7 +60,7 @@ android/ build/ fonts/ -installer/otzaria-0.9.45-windows.exe -installer/otzaria-0.9.45-windows-full.exe +installer/otzaria-0.9.46-windows.exe +installer/otzaria-0.9.46-windows-full.exe pubspec.lock flutter/ diff --git a/installer/otzaria.iss b/installer/otzaria.iss index 62aa2987e..d8a7728fa 100644 --- a/installer/otzaria.iss +++ b/installer/otzaria.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.45" +#define MyAppVersion "0.9.46" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/installer/otzaria_full.iss b/installer/otzaria_full.iss index 7ef31340f..8d8aedd5a 100644 --- a/installer/otzaria_full.iss +++ b/installer/otzaria_full.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.45" +#define MyAppVersion "0.9.46" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/pubspec.yaml b/pubspec.yaml index c8955fa7b..7a1b4740a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,7 @@ msix_config: publisher_display_name: sivan22 identity_name: sivan22.Otzaria description: "ספריית ספרים יהודיים עם אפשרויות חיפוש חכם" - msix_version: 0.9.45.0 + msix_version: 0.9.46.0 logo_path: assets/icon/icon.png publisher: CN=sivan22, O=sivan22, C=IL certificate_path: sivan22.pfx @@ -36,7 +36,7 @@ msix_config: # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.9.45 +version: 0.9.46 environment: sdk: ">=3.2.6 <4.0.0" diff --git a/version.json b/version.json index 2d70c4b20..48f9d5664 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "0.9.45" + "version": "0.9.46" } \ No newline at end of file From 10857bc537bffc7701ce4058bf23cea6697d1932 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 4 Sep 2025 16:40:46 +0300 Subject: [PATCH 161/197] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20=D7=92?= =?UTF-8?q?=D7=99=D7=A8=D7=A1=D7=90=20=D7=9C=2047?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +-- installer/otzaria.iss | 2 +- installer/otzaria_full.iss | 2 +- pubspec.lock | 61 +++++++++++++++++--------------------- pubspec.yaml | 4 +-- version.json | 2 +- 6 files changed, 35 insertions(+), 40 deletions(-) diff --git a/.gitignore b/.gitignore index 60749fd0f..e3d522707 100644 --- a/.gitignore +++ b/.gitignore @@ -60,7 +60,7 @@ android/ build/ fonts/ -installer/otzaria-0.9.46-windows.exe -installer/otzaria-0.9.46-windows-full.exe +installer/otzaria-0.9.47-windows.exe +installer/otzaria-0.9.47-windows-full.exe pubspec.lock flutter/ diff --git a/installer/otzaria.iss b/installer/otzaria.iss index d8a7728fa..e0036c96c 100644 --- a/installer/otzaria.iss +++ b/installer/otzaria.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.46" +#define MyAppVersion "0.9.47" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/installer/otzaria_full.iss b/installer/otzaria_full.iss index 8d8aedd5a..a4e019ae0 100644 --- a/installer/otzaria_full.iss +++ b/installer/otzaria_full.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.46" +#define MyAppVersion "0.9.47" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/pubspec.lock b/pubspec.lock index be5f0423c..e464c504b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,23 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" url: "https://pub.dev" source: hosted - version: "76.0.0" - _macros: - dependency: transitive - description: dart - source: sdk - version: "0.3.3" + version: "67.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" url: "https://pub.dev" source: hosted - version: "6.11.0" + version: "6.4.1" archive: dependency: "direct main" description: @@ -322,10 +317,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" url: "https://pub.dev" source: hosted - version: "2.3.7" + version: "2.3.6" dbus: dependency: transitive description: @@ -530,6 +525,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.4" + flutter_single_instance: + dependency: "direct main" + description: + name: flutter_single_instance + sha256: a0eef1d359705cdbc9031d551a8c4fc68687b731c71881e8eeb97e1a12b9c7a0 + url: "https://pub.dev" + source: hosted + version: "1.1.2" flutter_spinbox: dependency: "direct main" description: @@ -816,26 +819,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.1" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -852,14 +855,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" - macros: - dependency: transitive - description: - name: macros - sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" - url: "https://pub.dev" - source: hosted - version: "0.1.3-main.0" markdown: dependency: transitive description: @@ -1494,26 +1489,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "301b213cd241ca982e9ba50266bd3f5bd1ea33f1455554c5abb85d1be0e2d87e" + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" url: "https://pub.dev" source: hosted - version: "1.25.15" + version: "1.26.2" test_api: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.6" test_core: dependency: transitive description: name: test_core - sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa" + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" url: "https://pub.dev" source: hosted - version: "0.6.8" + version: "0.6.11" timing: dependency: transitive description: @@ -1654,10 +1649,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" video_player: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7a1b4740a..9c3e05323 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,7 @@ msix_config: publisher_display_name: sivan22 identity_name: sivan22.Otzaria description: "ספריית ספרים יהודיים עם אפשרויות חיפוש חכם" - msix_version: 0.9.46.0 + msix_version: 0.9.47.0 logo_path: assets/icon/icon.png publisher: CN=sivan22, O=sivan22, C=IL certificate_path: sivan22.pfx @@ -36,7 +36,7 @@ msix_config: # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.9.46 +version: 0.9.47 environment: sdk: ">=3.2.6 <4.0.0" diff --git a/version.json b/version.json index 48f9d5664..43ec759ae 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "0.9.46" + "version": "0.9.47" } \ No newline at end of file From bdcd7b197fd45c744af65f58ba48881ed0130496 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 4 Sep 2025 19:12:09 +0300 Subject: [PATCH 162/197] =?UTF-8?q?=D7=9E=D7=99=D7=A7=D7=95=D7=93=20=D7=90?= =?UTF-8?q?=D7=95=D7=98=D7=95=D7=9E=D7=98=D7=99=20=D7=91=D7=AA=D7=99=D7=91?= =?UTF-8?q?=D7=AA=20=D7=94=D7=A7=D7=9C=D7=98=20=D7=91=D7=9E=D7=9E=D7=99?= =?UTF-8?q?=D7=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../measurement_converter_screen.dart | 125 +++++++++++++++--- 1 file changed, 105 insertions(+), 20 deletions(-) diff --git a/lib/tools/measurement_converter/measurement_converter_screen.dart b/lib/tools/measurement_converter/measurement_converter_screen.dart index 815315d4f..06ef7c068 100644 --- a/lib/tools/measurement_converter/measurement_converter_screen.dart +++ b/lib/tools/measurement_converter/measurement_converter_screen.dart @@ -27,6 +27,8 @@ class _MeasurementConverterScreenState String? _selectedOpinion; final TextEditingController _inputController = TextEditingController(); final TextEditingController _resultController = TextEditingController(); + final FocusNode _inputFocusNode = FocusNode(); + final FocusNode _screenFocusNode = FocusNode(); // Maps to remember user selections for each category final Map _rememberedFromUnits = {}; @@ -57,6 +59,13 @@ class _MeasurementConverterScreenState _resetDropdowns(); } + @override + void dispose() { + _inputFocusNode.dispose(); + _screenFocusNode.dispose(); + super.dispose(); + } + void _resetDropdowns() { setState(() { // Restore remembered selections or use defaults @@ -82,9 +91,9 @@ class _MeasurementConverterScreenState // Restore remembered input value or clear _inputController.text = _rememberedInputValues[_selectedCategory] ?? ''; _resultController.clear(); - + // Convert if there's a remembered input value - if (_rememberedInputValues[_selectedCategory] != null && + if (_rememberedInputValues[_selectedCategory] != null && _rememberedInputValues[_selectedCategory]!.isNotEmpty) { _convert(); } @@ -217,7 +226,6 @@ class _MeasurementConverterScreenState if (unit == 'דקות') return 60.0; if (unit == 'שעות') return 3600.0; if (unit == 'ימים') return 86400.0; - } else { final value = modernTimeFactors[opinion]![unit]; if (value == null) return null; @@ -314,25 +322,81 @@ class _MeasurementConverterScreenState @override Widget build(BuildContext context) { return Scaffold( - body: Padding( - padding: const EdgeInsets.all(16.0), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildCategorySelector(), - const SizedBox(height: 20), - _buildUnitSelectors(), - const SizedBox(height: 20), - if (_opinions.containsKey(_selectedCategory) && - _opinions[_selectedCategory]!.isNotEmpty) ...[ - _buildOpinionSelector(), + body: Focus( + focusNode: _screenFocusNode, + autofocus: true, + onKeyEvent: (node, event) { + if (event is KeyDownEvent) { + final String character = event.character ?? ''; + + // Check if the pressed key is a number or decimal point + if (RegExp(r'[0-9.]').hasMatch(character)) { + // Auto-focus the input field and add the character + if (!_inputFocusNode.hasFocus) { + _inputFocusNode.requestFocus(); + // Add the typed character to the input field + WidgetsBinding.instance.addPostFrameCallback((_) { + final currentText = _inputController.text; + final newText = currentText + character; + _inputController.text = newText; + _inputController.selection = TextSelection.fromPosition( + TextPosition(offset: newText.length), + ); + _convert(); + }); + return KeyEventResult.handled; + } + } + // Check if the pressed key is a delete/backspace key + else if (event.logicalKey == LogicalKeyboardKey.backspace || + event.logicalKey == LogicalKeyboardKey.delete) { + // Auto-focus the input field and handle deletion + if (!_inputFocusNode.hasFocus) { + _inputFocusNode.requestFocus(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final currentText = _inputController.text; + if (currentText.isNotEmpty) { + String newText; + if (event.logicalKey == LogicalKeyboardKey.backspace) { + // Remove last character + newText = currentText.substring(0, currentText.length - 1); + } else { + // Delete key - remove first character (or handle as backspace for simplicity) + newText = currentText.substring(0, currentText.length - 1); + } + _inputController.text = newText; + _inputController.selection = TextSelection.fromPosition( + TextPosition(offset: newText.length), + ); + _convert(); + } + }); + return KeyEventResult.handled; + } + } + } + return KeyEventResult.ignored; + }, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildCategorySelector(), + const SizedBox(height: 20), + _buildUnitSelectors(), + const SizedBox(height: 20), + if (_opinions.containsKey(_selectedCategory) && + _opinions[_selectedCategory]!.isNotEmpty) ...[ + _buildOpinionSelector(), + const SizedBox(height: 20), + ], + _buildInputField(), const SizedBox(height: 20), + _buildResultDisplay(), ], - _buildInputField(), - const SizedBox(height: 20), - _buildResultDisplay(), - ], + ), ), ), ), @@ -398,6 +462,10 @@ class _MeasurementConverterScreenState _selectedCategory = category; _resetDropdowns(); }); + // Restore focus to the screen after category change + WidgetsBinding.instance.addPostFrameCallback((_) { + _screenFocusNode.requestFocus(); + }); } }, child: Container( @@ -439,6 +507,10 @@ class _MeasurementConverterScreenState setState(() => _selectedFromUnit = val); _rememberedFromUnits[_selectedCategory] = val!; _convert(); + // Restore focus to the screen after unit change + WidgetsBinding.instance.addPostFrameCallback((_) { + _screenFocusNode.requestFocus(); + }); }), ), const SizedBox(width: 10), @@ -454,6 +526,10 @@ class _MeasurementConverterScreenState _selectedToUnit = temp; _convert(); }); + // Restore focus to the screen after swap + WidgetsBinding.instance.addPostFrameCallback((_) { + _screenFocusNode.requestFocus(); + }); }, ), ], @@ -464,6 +540,10 @@ class _MeasurementConverterScreenState setState(() => _selectedToUnit = val); _rememberedToUnits[_selectedCategory] = val!; _convert(); + // Restore focus to the screen after unit change + WidgetsBinding.instance.addPostFrameCallback((_) { + _screenFocusNode.requestFocus(); + }); }), ), ], @@ -719,6 +799,10 @@ class _MeasurementConverterScreenState _rememberedOpinions[_selectedCategory] = opinion; _convert(); }); + // Restore focus to the screen after opinion change + WidgetsBinding.instance.addPostFrameCallback((_) { + _screenFocusNode.requestFocus(); + }); }, child: Container( width: minWidth, @@ -752,6 +836,7 @@ class _MeasurementConverterScreenState Widget _buildInputField() { return TextField( controller: _inputController, + focusNode: _inputFocusNode, decoration: const InputDecoration( labelText: 'ערך להמרה', border: OutlineInputBorder(), From 4af4f83a2207ff9746cfbc27300d7284a8708342 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 4 Sep 2025 21:47:46 +0300 Subject: [PATCH 163/197] =?UTF-8?q?=D7=9C=D7=97=D7=A6=D7=9F=20'=D7=98?= =?UTF-8?q?=D7=A2=D7=99=D7=A0=D7=94=20=D7=9E=D7=97=D7=93=D7=A9',=20=D7=95?= =?UTF-8?q?=D7=98=D7=A2=D7=99=D7=A0=D7=94=20=D7=9E=D7=97=D7=93=D7=A9=20?= =?UTF-8?q?=D7=90=D7=95=D7=98=D7=95=D7=9E=D7=98=D7=99=D7=AA=20=D7=9C=D7=90?= =?UTF-8?q?=D7=97=D7=A8=20=D7=A1=D7=A0=D7=9B=D7=A8=D7=95=D7=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/library/bloc/library_bloc.dart | 62 ++++++++++++++++++++++++++- lib/library/view/library_browser.dart | 23 +++++++++- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/lib/library/bloc/library_bloc.dart b/lib/library/bloc/library_bloc.dart index 565554bf4..af815d8d5 100644 --- a/lib/library/bloc/library_bloc.dart +++ b/lib/library/bloc/library_bloc.dart @@ -48,15 +48,32 @@ class LibraryBloc extends Bloc { ) async { emit(state.copyWith(isLoading: true)); try { + // שמירת המיקום הנוכחי בספרייה + final currentCategoryPath = _getCurrentCategoryPath(state.currentCategory); + final libraryPath = Settings.getValue('key-library-path'); if (libraryPath != null) { FileSystemData.instance.libraryPath = libraryPath; } + + // רענון הספרייה מהמערכת קבצים + DataRepository.instance.library = FileSystemData.instance.getLibrary(); final library = await _repository.library; - TantivyDataProvider.instance.reopenIndex(); + + try { + TantivyDataProvider.instance.reopenIndex(); + } catch (e) { + // אם יש בעיה עם פתיחת האינדקס מחדש, נמשיך בלי זה + // הספרייה עדיין תתרענן אבל החיפוש עלול לא לעבוד עד להפעלה מחדש + print('Warning: Could not reopen search index: $e'); + } + + // חזרה לאותה תיקייה שהיתה פתוחה קודם + final targetCategory = _findCategoryByPath(library, currentCategoryPath); + emit(state.copyWith( library: library, - currentCategory: library, + currentCategory: targetCategory ?? library, isLoading: false, )); } catch (e) { @@ -66,6 +83,47 @@ class LibraryBloc extends Bloc { )); } } + + /// מחזיר את הנתיב של התיקייה הנוכחית + List _getCurrentCategoryPath(Category? category) { + if (category == null) return []; + + final path = []; + Category? current = category; + final visited = {}; // למניעת לולאות אינסופיות + + while (current != null && current.parent != null && current.parent != current) { + // בדיקה שלא ביקרנו כבר בקטגוריה הזו (למניעת לולאה אינסופית) + if (visited.contains(current)) { + break; + } + visited.add(current); + + path.insert(0, current.title); + current = current.parent; + } + + return path; + } + + /// מוצא תיקייה לפי נתיב + Category? _findCategoryByPath(Category rootCategory, List path) { + if (path.isEmpty) return rootCategory; + + Category current = rootCategory; + + for (final categoryName in path) { + try { + final found = current.subCategories.where((cat) => cat.title == categoryName).first; + current = found; + } catch (e) { + // אם לא מצאנו את התיקייה, נחזיר את הקרובה ביותר + return current; + } + } + + return current; + } Future _onUpdateLibraryPath( UpdateLibraryPath event, diff --git a/lib/library/view/library_browser.dart b/lib/library/view/library_browser.dart index 3794d6515..89f965f9c 100644 --- a/lib/library/view/library_browser.dart +++ b/lib/library/view/library_browser.dart @@ -19,6 +19,7 @@ import 'package:otzaria/tabs/models/text_tab.dart'; import 'package:otzaria/daf_yomi/daf_yomi_helper.dart'; import 'package:otzaria/file_sync/file_sync_bloc.dart'; import 'package:otzaria/file_sync/file_sync_repository.dart'; +import 'package:otzaria/file_sync/file_sync_state.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:otzaria/daf_yomi/daf_yomi.dart'; import 'package:otzaria/file_sync/file_sync_widget.dart'; @@ -147,7 +148,7 @@ class _LibraryBrowserState extends State color: Colors.grey.shade400, margin: const EdgeInsets.symmetric(horizontal: 2), ), - // קבוצת סינכרון + // קבוצת טעינה מחדש וסנכרון BlocProvider( create: (context) => FileSyncBloc( repository: FileSyncRepository( @@ -156,7 +157,25 @@ class _LibraryBrowserState extends State branch: "main", ), ), - child: const SyncIconButton(), + child: BlocListener( + listener: (context, syncState) { + // אם הסינכרון הושלם או הופסק והיו קבצים חדשים + if ((syncState.status == FileSyncStatus.completed || + syncState.status == FileSyncStatus.error) && + syncState.hasNewSync) { + // הפעלת רענון אוטומטי של הספרייה + context.read().add(RefreshLibrary()); + } + }, + child: const SyncIconButton(), + ), + ), + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'טעינה מחדש של רשימת הספרים', + onPressed: () { + context.read().add(RefreshLibrary()); + }, ), // קו מפריד Container( From 3ec413c19e0fa7d386929879b1e471d8cbc3f4d3 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 4 Sep 2025 21:59:30 +0300 Subject: [PATCH 164/197] =?UTF-8?q?=D7=91=D7=99=D7=98=D7=95=D7=9C=20=D7=94?= =?UTF-8?q?=D7=A2=D7=91=D7=A8=D7=AA=20=D7=A4=D7=A2=D7=95=D7=9C=D7=95=D7=AA?= =?UTF-8?q?=20=D7=9C=20Isolate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/indexing_repository.dart | 88 +-- .../services/indexing_isolate_service.dart | 437 --------------- lib/navigation/main_window_screen.dart | 13 - .../services/pdf_isolate_service.dart | 438 --------------- lib/search/search_repository.dart | 58 +- .../services/search_isolate_service.dart | 501 ------------------ lib/utils/file_processing_isolate.dart | 373 ------------- lib/utils/isolate_manager.dart | 182 ------- 8 files changed, 58 insertions(+), 2032 deletions(-) delete mode 100644 lib/indexing/services/indexing_isolate_service.dart delete mode 100644 lib/pdf_book/services/pdf_isolate_service.dart delete mode 100644 lib/search/services/search_isolate_service.dart delete mode 100644 lib/utils/file_processing_isolate.dart delete mode 100644 lib/utils/isolate_manager.dart diff --git a/lib/indexing/repository/indexing_repository.dart b/lib/indexing/repository/indexing_repository.dart index 66840c995..abaa95f3f 100644 --- a/lib/indexing/repository/indexing_repository.dart +++ b/lib/indexing/repository/indexing_repository.dart @@ -8,14 +8,13 @@ import 'package:otzaria/models/books.dart'; import 'package:otzaria/utils/text_manipulation.dart'; import 'package:pdfrx/pdfrx.dart'; import 'package:otzaria/utils/ref_helper.dart'; -import 'package:otzaria/indexing/services/indexing_isolate_service.dart'; class IndexingRepository { final TantivyDataProvider _tantivyDataProvider; IndexingRepository(this._tantivyDataProvider); - /// Indexes all books in the provided library using an Isolate. + /// Indexes all books in the provided library. /// /// [library] The library containing books to index /// [onProgress] Callback function to report progress @@ -24,39 +23,63 @@ class IndexingRepository { void Function(int processed, int total) onProgress, ) async { _tantivyDataProvider.isIndexing.value = true; - - try { - // הפעלת האינדוקס ב-Isolate - final progressStream = await IndexingIsolateService.startIndexing(library); - - // האזנה להתקדמות מה-Isolate - await for (final progress in progressStream) { - if (progress.error != null) { - debugPrint('Indexing error: ${progress.error}'); - _tantivyDataProvider.isIndexing.value = false; - throw Exception(progress.error); - } - - // דיווח התקדמות - onProgress(progress.processed, progress.total); - - // עדכון רשימת הספרים שהושלמו - if (progress.currentBook != null) { - _tantivyDataProvider.booksDone.add('${progress.currentBook}textBook'); - _tantivyDataProvider.booksDone.add('${progress.currentBook}pdfBook'); - saveIndexedBooks(); - } - - if (progress.isComplete) { - _tantivyDataProvider.isIndexing.value = false; - break; + final allBooks = library.getAllBooks(); + final totalBooks = allBooks.length; + int processedBooks = 0; + + for (Book book in allBooks) { + // Check if indexing was cancelled + if (!_tantivyDataProvider.isIndexing.value) { + return; + } + + try { + // Check if this book has already been indexed + if (book is TextBook) { + if (!_tantivyDataProvider.booksDone + .contains("${book.title}textBook")) { + if (_tantivyDataProvider.booksDone.contains( + sha1.convert(utf8.encode((await book.text))).toString())) { + _tantivyDataProvider.booksDone.add("${book.title}textBook"); + } else { + await _indexTextBook(book); + _tantivyDataProvider.booksDone.add("${book.title}textBook"); + } + } + } else if (book is PdfBook) { + if (!_tantivyDataProvider.booksDone + .contains("${book.title}pdfBook")) { + if (_tantivyDataProvider.booksDone.contains( + sha1.convert(await File(book.path).readAsBytes()).toString())) { + _tantivyDataProvider.booksDone.add("${book.title}pdfBook"); + } else { + await _indexPdfBook(book); + _tantivyDataProvider.booksDone.add("${book.title}pdfBook"); + } + } } + + processedBooks++; + // Report progress + onProgress(processedBooks, totalBooks); + } catch (e) { + // Use async error handling to prevent event loop blocking + await Future.microtask(() { + debugPrint('Error adding ${book.title} to index: $e'); + }); + processedBooks++; + // Still report progress even after error + onProgress(processedBooks, totalBooks); + // Yield control back to event loop after error + await Future.delayed(Duration.zero); } - } catch (e) { - _tantivyDataProvider.isIndexing.value = false; - debugPrint('Error in indexing: $e'); - rethrow; + + await Future.delayed(Duration.zero); + } + + // Reset indexing flag after completion + _tantivyDataProvider.isIndexing.value = false; } /// Indexes a text-based book by processing its content and adding it to the search index and reference index. @@ -175,7 +198,6 @@ class IndexingRepository { /// Cancels the ongoing indexing process. void cancelIndexing() { _tantivyDataProvider.isIndexing.value = false; - IndexingIsolateService.cancelIndexing(); } /// Persists the list of indexed books to disk. diff --git a/lib/indexing/services/indexing_isolate_service.dart b/lib/indexing/services/indexing_isolate_service.dart deleted file mode 100644 index 28d48b37f..000000000 --- a/lib/indexing/services/indexing_isolate_service.dart +++ /dev/null @@ -1,437 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'package:flutter/foundation.dart'; -import 'package:otzaria/library/models/library.dart'; -import 'package:otzaria/models/books.dart'; -import 'package:otzaria/utils/text_manipulation.dart'; -import 'package:otzaria/utils/isolate_manager.dart'; -import 'package:pdfrx/pdfrx.dart'; -import 'package:otzaria/utils/ref_helper.dart'; -import 'package:search_engine/search_engine.dart'; -import 'package:flutter_settings_screens/flutter_settings_screens.dart'; - -/// הודעות לתקשורת עם ה-Isolate -abstract class IndexingMessage {} - -class StartIndexingMessage extends IndexingMessage { - final List books; - final String indexPath; - final String refIndexPath; - - StartIndexingMessage({ - required this.books, - required this.indexPath, - required this.refIndexPath, - }); -} - -class CancelIndexingMessage extends IndexingMessage {} - -class IndexingProgressMessage { - final int processed; - final int total; - final String? currentBook; - final bool isComplete; - final String? error; - - IndexingProgressMessage({ - required this.processed, - required this.total, - this.currentBook, - this.isComplete = false, - this.error, - }); -} - -/// נתוני ספר לאינדוקס -class BookData { - final String title; - final String? path; - final String topics; - final BookType type; - final String? textContent; // לספרי טקסט - - BookData({ - required this.title, - this.path, - required this.topics, - required this.type, - this.textContent, - }); -} - -enum BookType { text, pdf } - -/// שירות אינדוקס שרץ ב-Isolate -class IndexingIsolateService { - static IsolateHandler? _isolateHandler; - static StreamController? _progressController; - static bool _isIndexing = false; - - /// התחלת תהליך האינדוקס ב-Isolate - static Future> startIndexing( - Library library, - ) async { - if (_isIndexing) { - throw Exception('Indexing already in progress'); - } - - _isIndexing = true; - _progressController = StreamController.broadcast(); - - // הכנת נתוני הספרים - final books = await _prepareBooksData(library); - - // קבלת נתיבי האינדקס - final indexPath = '${Settings.getValue('key-library-path') ?? 'C:/אוצריא'}${Platform.pathSeparator}index'; - final refIndexPath = '${Settings.getValue('key-library-path') ?? 'C:/אוצריא'}${Platform.pathSeparator}ref_index'; - - // יצירת ה-Isolate - _isolateHandler = await IsolateManager.getOrCreate( - 'indexing', - _indexingIsolateEntry, - ); - - // האזנה לתגובות מה-Isolate - _isolateHandler!.responses.listen((message) { - if (message is IndexingProgressMessage) { - _progressController?.add(message); - - if (message.isComplete || message.error != null) { - _isIndexing = false; - _progressController?.close(); - _progressController = null; - } - } - }); - - // שליחת הודעת התחלה - _isolateHandler!.send(StartIndexingMessage( - books: books, - indexPath: indexPath, - refIndexPath: refIndexPath, - )); - - return _progressController!.stream; - } - - /// ביטול תהליך האינדוקס - static Future cancelIndexing() async { - if (_isolateHandler != null) { - _isolateHandler!.send(CancelIndexingMessage()); - await IsolateManager.kill('indexing'); - _isolateHandler = null; - } - - _isIndexing = false; - _progressController?.close(); - _progressController = null; - } - - /// הכנת נתוני הספרים לאינדוקס - static Future> _prepareBooksData(Library library) async { - final books = []; - final allBooks = library.getAllBooks(); - - for (final book in allBooks) { - if (book is TextBook) { - // טעינת תוכן הטקסט מראש - final text = await book.text; - books.add(BookData( - title: book.title, - topics: book.topics, - type: BookType.text, - textContent: text, - )); - } else if (book is PdfBook) { - books.add(BookData( - title: book.title, - path: book.path, - topics: book.topics, - type: BookType.pdf, - )); - } - } - - return books; - } -} - -/// נקודת הכניסה ל-Isolate של האינדוקס -void _indexingIsolateEntry(IsolateContext context) { - SearchEngine? searchEngine; - ReferenceSearchEngine? refEngine; - bool shouldCancel = false; - Set booksDone = {}; - - // האזנה להודעות - context.messages.listen((message) async { - if (message is StartIndexingMessage) { - shouldCancel = false; - - try { - // יצירת מנועי החיפוש עם הרשאות כתיבה - searchEngine = SearchEngine(path: message.indexPath); - refEngine = ReferenceSearchEngine(path: message.refIndexPath); - - final totalBooks = message.books.length; - int processedBooks = 0; - - for (final book in message.books) { - if (shouldCancel) break; - - try { - // שליחת עדכון התקדמות - context.send(IndexingProgressMessage( - processed: processedBooks, - total: totalBooks, - currentBook: book.title, - )); - - // אינדוקס הספר - if (book.type == BookType.text) { - await _indexTextBookInIsolate( - book, - searchEngine!, - refEngine!, - booksDone, - ); - } else if (book.type == BookType.pdf) { - await _indexPdfBookInIsolate( - book, - searchEngine!, - booksDone, - ); - } - - processedBooks++; - - // ביצוע commit מדי פעם כדי לשחרר לוקים - if (processedBooks % 10 == 0) { - await searchEngine?.commit(); - await refEngine?.commit(); - } - } catch (e) { - debugPrint('Error indexing ${book.title}: $e'); - processedBooks++; - } - - // תן ל-Isolate לנשום - await Future.delayed(Duration.zero); - } - - // סיום מוצלח - await searchEngine?.commit(); - await refEngine?.commit(); - - context.send(IndexingProgressMessage( - processed: processedBooks, - total: totalBooks, - isComplete: true, - )); - } catch (e) { - context.send(IndexingProgressMessage( - processed: 0, - total: 0, - error: e.toString(), - )); - } - } else if (message is CancelIndexingMessage) { - shouldCancel = true; - } - }); -} - -/// אינדוקס ספר טקסט בתוך ה-Isolate -Future _indexTextBookInIsolate( - BookData book, - SearchEngine searchEngine, - ReferenceSearchEngine refEngine, - Set booksDone, -) async { - // בדיקה אם כבר אונדקס - final bookKey = "${book.title}textBook"; - if (booksDone.contains(bookKey)) return; - - final text = book.textContent ?? ''; - final title = book.title; - final topics = "/${book.topics.replaceAll(', ', '/')}"; - - final texts = text.split('\n'); - List reference = []; - - for (int i = 0; i < texts.length; i++) { - // תן לאירועים אחרים לרוץ - if (i % 100 == 0) { - await Future.delayed(Duration.zero); - } - - String line = texts[i]; - - if (line.startsWith(' - element.substring(0, 4) == line.substring(0, 4))) { - reference.removeRange( - reference.indexWhere((element) => - element.substring(0, 4) == line.substring(0, 4)), - reference.length); - } - reference.add(line); - - // אינדוקס כרפרנס - String refText = stripHtmlIfNeeded(reference.join(" ")); - final shortref = replaceParaphrases(removeSectionNames(refText)); - - refEngine.addDocument( - id: BigInt.from(DateTime.now().microsecondsSinceEpoch), - title: title, - reference: refText, - shortRef: shortref, - segment: BigInt.from(i), - isPdf: false, - filePath: ''); - } else { - line = stripHtmlIfNeeded(line); - line = removeVolwels(line); - - // הוספה לאינדקס - searchEngine.addDocument( - id: BigInt.from(DateTime.now().microsecondsSinceEpoch), - title: title, - reference: stripHtmlIfNeeded(reference.join(', ')), - topics: '$topics/$title', - text: line, - segment: BigInt.from(i), - isPdf: false, - filePath: ''); - } - } - - booksDone.add(bookKey); -} - -/// אינדוקס ספר PDF בתוך ה-Isolate -Future _indexPdfBookInIsolate( - BookData book, - SearchEngine searchEngine, - Set booksDone, -) async { - // בדיקה אם כבר אונדקס - final bookKey = "${book.title}pdfBook"; - if (booksDone.contains(bookKey)) return; - - final document = await PdfDocument.openFile(book.path!); - final pages = document.pages; - final outline = await document.loadOutline(); - final title = book.title; - final topics = "/${book.topics.replaceAll(', ', '/')}"; - - for (int i = 0; i < pages.length; i++) { - final texts = (await pages[i].loadText()).fullText.split('\n'); - - for (int j = 0; j < texts.length; j++) { - // תן לאירועים אחרים לרוץ - if (j % 50 == 0) { - await Future.delayed(Duration.zero); - } - - final bookmark = await refFromPageNumber(i + 1, outline, title); - final ref = bookmark.isNotEmpty - ? '$title, $bookmark, עמוד ${i + 1}' - : '$title, עמוד ${i + 1}'; - - searchEngine.addDocument( - id: BigInt.from(DateTime.now().microsecondsSinceEpoch), - title: title, - reference: ref, - topics: '$topics/$title', - text: texts[j], - segment: BigInt.from(i), - isPdf: true, - filePath: book.path!); - } - } - - booksDone.add(bookKey); -} - -/// מחלקת עזר לגישת קריאה בלבד לאינדקס (לחיפוש במקביל לאינדוקס) -class ReadOnlySearchEngine { - late SearchEngine _engine; - final String indexPath; - - ReadOnlySearchEngine({required this.indexPath}) { - // פתיחת האינדקס במצב קריאה בלבד - _initEngine(); - } - - void _initEngine() { - try { - // ניסיון לפתוח במצב קריאה - _engine = SearchEngine(path: indexPath); - } catch (e) { - debugPrint('Failed to open search engine in read-only mode: $e'); - rethrow; - } - } - - /// חיפוש באינדקס (קריאה בלבד) - Future> search({ - required List regexTerms, - required List facets, - required int limit, - int slop = 0, - int maxExpansions = 10, - ResultsOrder order = ResultsOrder.relevance, - }) async { - try { - return await _engine.search( - regexTerms: regexTerms, - facets: facets, - limit: limit, - slop: slop, - maxExpansions: maxExpansions, - order: order, - ); - } catch (e) { - // אם יש בעיה בגישה, ננסה לפתוח מחדש - _initEngine(); - return await _engine.search( - regexTerms: regexTerms, - facets: facets, - limit: limit, - slop: slop, - maxExpansions: maxExpansions, - order: order, - ); - } - } - - /// ספירת תוצאות (קריאה בלבד) - Future count({ - required List regexTerms, - required List facets, - int slop = 0, - int maxExpansions = 10, - }) async { - try { - return await _engine.count( - regexTerms: regexTerms, - facets: facets, - slop: slop, - maxExpansions: maxExpansions, - ); - } catch (e) { - // אם יש בעיה בגישה, ננסה לפתוח מחדש - _initEngine(); - return await _engine.count( - regexTerms: regexTerms, - facets: facets, - slop: slop, - maxExpansions: maxExpansions, - ); - } - } -} diff --git a/lib/navigation/main_window_screen.dart b/lib/navigation/main_window_screen.dart index 70acee689..d34d79440 100644 --- a/lib/navigation/main_window_screen.dart +++ b/lib/navigation/main_window_screen.dart @@ -7,7 +7,6 @@ import 'package:otzaria/indexing/bloc/indexing_event.dart'; import 'package:otzaria/navigation/bloc/navigation_bloc.dart'; import 'package:otzaria/navigation/bloc/navigation_event.dart'; import 'package:otzaria/navigation/bloc/navigation_state.dart'; -import 'package:otzaria/utils/isolate_manager.dart'; import 'package:otzaria/settings/settings_bloc.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; @@ -60,21 +59,9 @@ class MainWindowScreenState extends State @override void dispose() { pageController.dispose(); - // סגירת כל ה-Isolates בעת יציאה מהאפליקציה - _cleanupIsolates(); super.dispose(); } - /// ניקוי כל ה-Isolates - void _cleanupIsolates() async { - try { - await IsolateManager.killAll(); - } catch (e) { - // אם יש שגיאה בניקוי, נמשיך בכל זאת - debugPrint('Error cleaning up isolates: $e'); - } - } - void _handleOrientationChange(BuildContext context, Orientation orientation) { if (_previousOrientation != orientation) { _previousOrientation = orientation; diff --git a/lib/pdf_book/services/pdf_isolate_service.dart b/lib/pdf_book/services/pdf_isolate_service.dart deleted file mode 100644 index ef86db990..000000000 --- a/lib/pdf_book/services/pdf_isolate_service.dart +++ /dev/null @@ -1,438 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; -import 'package:flutter/foundation.dart'; -import 'package:otzaria/utils/isolate_manager.dart'; -import 'package:pdfrx/pdfrx.dart'; -import 'dart:ui' as ui; - -/// סוגי הודעות לעיבוד PDF -abstract class PdfMessage {} - -class LoadPdfTextMessage extends PdfMessage { - final String filePath; - final int pageNumber; - - LoadPdfTextMessage({ - required this.filePath, - required this.pageNumber, - }); -} - -class SearchPdfMessage extends PdfMessage { - final String filePath; - final String query; - final int maxResults; - - SearchPdfMessage({ - required this.filePath, - required this.query, - this.maxResults = 100, - }); -} - -class GenerateThumbnailMessage extends PdfMessage { - final String filePath; - final int pageNumber; - final double scale; - - GenerateThumbnailMessage({ - required this.filePath, - required this.pageNumber, - this.scale = 0.3, - }); -} - -class LoadOutlineMessage extends PdfMessage { - final String filePath; - - LoadOutlineMessage({required this.filePath}); -} - -/// תוצאות עיבוד PDF -class PdfTextResult { - final String text; - final int pageNumber; - final String? error; - - PdfTextResult({ - required this.text, - required this.pageNumber, - this.error, - }); -} - -class PdfSearchResult { - final List matches; - final String? error; - - PdfSearchResult({ - required this.matches, - this.error, - }); -} - -class PdfSearchMatch { - final int pageNumber; - final String text; - final int startIndex; - final int endIndex; - - PdfSearchMatch({ - required this.pageNumber, - required this.text, - required this.startIndex, - required this.endIndex, - }); -} - -class PdfThumbnailResult { - final Uint8List? imageData; - final int pageNumber; - final String? error; - - PdfThumbnailResult({ - this.imageData, - required this.pageNumber, - this.error, - }); -} - -class PdfOutlineResult { - final List? outline; - final String? error; - - PdfOutlineResult({ - this.outline, - this.error, - }); -} - -/// שירות עיבוד PDF ב-Isolate -class PdfIsolateService { - static final Map _pdfIsolates = {}; - - /// קבלת או יצירת Isolate לקובץ PDF ספציפי - static Future _getOrCreateIsolate(String filePath) async { - final isolateName = 'pdf_${filePath.hashCode}'; - - if (_pdfIsolates.containsKey(isolateName)) { - return _pdfIsolates[isolateName]!; - } - - final handler = await IsolateManager.getOrCreate( - isolateName, - _pdfIsolateEntry, - initialData: {'filePath': filePath}, - ); - - _pdfIsolates[isolateName] = handler; - return handler; - } - - /// טעינת טקסט מעמוד PDF - static Future loadPageText(String filePath, int pageNumber) async { - final isolate = await _getOrCreateIsolate(filePath); - - return await isolate.compute( - LoadPdfTextMessage( - filePath: filePath, - pageNumber: pageNumber, - ), - ); - } - - /// חיפוש טקסט ב-PDF - static Future searchInPdf( - String filePath, - String query, { - int maxResults = 100, - }) async { - final isolate = await _getOrCreateIsolate(filePath); - - return await isolate.compute( - SearchPdfMessage( - filePath: filePath, - query: query, - maxResults: maxResults, - ), - ); - } - - /// יצירת תמונה ממוזערת של עמוד - static Future generateThumbnail( - String filePath, - int pageNumber, { - double scale = 0.3, - }) async { - final isolate = await _getOrCreateIsolate(filePath); - - return await isolate.compute( - GenerateThumbnailMessage( - filePath: filePath, - pageNumber: pageNumber, - scale: scale, - ), - ); - } - - /// טעינת תוכן העניינים של PDF - static Future loadOutline(String filePath) async { - final isolate = await _getOrCreateIsolate(filePath); - - return await isolate.compute( - LoadOutlineMessage(filePath: filePath), - ); - } - - /// שחרור Isolate של קובץ PDF ספציפי - static Future disposePdfIsolate(String filePath) async { - final isolateName = 'pdf_${filePath.hashCode}'; - - if (_pdfIsolates.containsKey(isolateName)) { - await _pdfIsolates[isolateName]!.dispose(); - _pdfIsolates.remove(isolateName); - } - } - - /// שחרור כל ה-Isolates של PDF - static Future disposeAll() async { - for (final isolate in _pdfIsolates.values) { - await isolate.dispose(); - } - _pdfIsolates.clear(); - } -} - -/// נקודת כניסה ל-Isolate של PDF -void _pdfIsolateEntry(IsolateContext context) { - PdfDocument? document; - final filePath = context.initialData?['filePath'] as String?; - - // טעינת המסמך פעם אחת - Future _getDocument() async { - document ??= await PdfDocument.openFile(filePath!); - return document!; - } - - // האזנה להודעות - context.messages.listen((message) async { - try { - if (message is LoadPdfTextMessage) { - final doc = await _getDocument(); - final pages = doc.pages; - - if (message.pageNumber < 0 || message.pageNumber >= pages.length) { - context.send(PdfTextResult( - text: '', - pageNumber: message.pageNumber, - error: 'Invalid page number', - )); - return; - } - - final page = pages[message.pageNumber]; - final textPage = await page.loadText(); - - context.send(PdfTextResult( - text: textPage.fullText, - pageNumber: message.pageNumber, - )); - - } else if (message is SearchPdfMessage) { - final doc = await _getDocument(); - final pages = doc.pages; - final matches = []; - - for (int i = 0; i < pages.length && matches.length < message.maxResults; i++) { - final textPage = await pages[i].loadText(); - final text = textPage.fullText.toLowerCase(); - final query = message.query.toLowerCase(); - - int index = 0; - while ((index = text.indexOf(query, index)) != -1 && - matches.length < message.maxResults) { - // קח קונטקסט סביב המילה שנמצאה - final start = (index - 50).clamp(0, text.length); - final end = (index + query.length + 50).clamp(0, text.length); - - matches.add(PdfSearchMatch( - pageNumber: i, - text: textPage.fullText.substring(start, end), - startIndex: index - start, - endIndex: (index + query.length) - start, - )); - - index += query.length; - } - - // תן לאירועים אחרים לרוץ - if (i % 10 == 0) { - await Future.delayed(Duration.zero); - } - } - - context.send(PdfSearchResult(matches: matches)); - - } else if (message is GenerateThumbnailMessage) { - final doc = await _getDocument(); - final pages = doc.pages; - - if (message.pageNumber < 0 || message.pageNumber >= pages.length) { - context.send(PdfThumbnailResult( - pageNumber: message.pageNumber, - error: 'Invalid page number', - )); - return; - } - - final page = pages[message.pageNumber]; - - // יצירת תמונה של העמוד - final pageImage = await page.render( - width: (page.width * message.scale).toInt(), - height: (page.height * message.scale).toInt(), - ); - - // בדיקה שהרינדור הצליח - if (pageImage == null) { - context.send(PdfThumbnailResult( - pageNumber: message.pageNumber, - error: 'Failed to render page image.', - )); - return; - } - - // המרה ל-PNG - final uiImage = await pageImage.createImage(); - final byteData = await uiImage.toByteData(format: ui.ImageByteFormat.png); - - if (byteData == null) { - context.send(PdfThumbnailResult( - pageNumber: message.pageNumber, - error: 'Failed to convert image to byte data.', - )); - return; - } - - // 3. המרת ה-ByteData לרשימת בתים (Uint8List) - final Uint8List pngData = byteData.buffer.asUint8List(); - - context.send(PdfThumbnailResult( - imageData: pngData, - pageNumber: message.pageNumber, - )); - - - } else if (message is LoadOutlineMessage) { - final doc = await _getDocument(); - final outline = await doc.loadOutline(); - - context.send(PdfOutlineResult( - outline: outline, - )); - } - } catch (e) { - // שליחת שגיאה חזרה - if (message is LoadPdfTextMessage) { - context.send(PdfTextResult( - text: '', - pageNumber: message.pageNumber, - error: e.toString(), - )); - } else if (message is SearchPdfMessage) { - context.send(PdfSearchResult( - matches: [], - error: e.toString(), - )); - } else if (message is GenerateThumbnailMessage) { - context.send(PdfThumbnailResult( - pageNumber: message.pageNumber, - error: e.toString(), - )); - } else if (message is LoadOutlineMessage) { - context.send(PdfOutlineResult( - error: e.toString(), - )); - } - } - }); -} - -/// מחלקת עזר לעבודה עם PDF text search -class PdfTextSearcher { - final String filePath; - final Map _textCache = {}; - - PdfTextSearcher({required this.filePath}); - - /// טעינת טקסט מעמוד עם cache - Future loadText({required int pageNumber}) async { - if (_textCache.containsKey(pageNumber)) { - return PdfPageText( - fullText: _textCache[pageNumber]!, - fragments: [], - ); - } - - final result = await PdfIsolateService.loadPageText(filePath, pageNumber); - - if (result.error == null) { - _textCache[pageNumber] = result.text; - return PdfPageText( - fullText: result.text, - fragments: [], - ); - } - - return null; - } - - /// חיפוש טקסט בכל ה-PDF - Future> search(String query, {int maxResults = 100}) async { - final result = await PdfIsolateService.searchInPdf( - filePath, - query, - maxResults: maxResults, - ); - - if (result.error == null) { - return result.matches; - } - - return []; - } - - /// ניקוי ה-cache - void clearCache() { - _textCache.clear(); - } - - /// שחרור משאבים - Future dispose() async { - clearCache(); - await PdfIsolateService.disposePdfIsolate(filePath); - } -} - -/// מחלקת עזר ליצירת PdfPageText (תאימות לקוד קיים) -class PdfPageText { - final String fullText; - final List fragments; - - PdfPageText({ - required this.fullText, - required this.fragments, - }); -} - -class PdfTextFragment { - final String text; - final int index; - final int end; - - PdfTextFragment({ - required this.text, - required this.index, - required this.end, - }); -} diff --git a/lib/search/search_repository.dart b/lib/search/search_repository.dart index 36eb052f3..81844fd7e 100644 --- a/lib/search/search_repository.dart +++ b/lib/search/search_repository.dart @@ -1,7 +1,7 @@ import 'dart:math' as math; import 'package:otzaria/data/data_providers/tantivy_data_provider.dart'; +import 'package:otzaria/search/utils/hebrew_morphology.dart'; import 'package:otzaria/search/utils/regex_patterns.dart'; -import 'package:otzaria/search/services/search_isolate_service.dart'; import 'package:search_engine/search_engine.dart'; /// Performs a search operation across indexed texts. @@ -45,60 +45,8 @@ class SearchRepository { final finalPattern = SearchRegexPatterns.createSpellingWithPrefixPattern('ראשית'); print('🔍 Final createSpellingWithPrefixPattern result: $finalPattern'); - // בדיקה אם האינדקס רץ - אם כן, נשתמש ב-Isolate לחיפוש - final isIndexing = TantivyDataProvider.instance.isIndexing.value; - - if (isIndexing) { - print('🔄 Indexing in progress, using isolate search service'); - // שימוש ב-SearchIsolateService כשהאינדקס רץ - final isolateSearchOptions = SearchOptions( - fuzzy: fuzzy, - distance: distance, - customSpacing: customSpacing, - alternativeWords: alternativeWords, - searchOptions: searchOptions, - order: order, - ); - - final resultWrapper = await SearchIsolateService.searchTexts( - query, - facets, - limit, - isolateSearchOptions, - ); - - if (resultWrapper.error != null) { - print('❌ Search isolate error: ${resultWrapper.error}'); - // fallback לחיפוש רגיל - final index = await TantivyDataProvider.instance.engine; - return await _performDirectSearch(index, query, facets, limit, order, - fuzzy, distance, customSpacing, alternativeWords, searchOptions); - } - - print( - '✅ Isolate search completed, found ${resultWrapper.results.length} results'); - return resultWrapper.results; - } else { - // שימוש רגיל כשהאינדקס לא רץ - final index = await TantivyDataProvider.instance.engine; - return await _performDirectSearch(index, query, facets, limit, order, - fuzzy, distance, customSpacing, alternativeWords, searchOptions); - } - } + final index = await TantivyDataProvider.instance.engine; - /// ביצוע חיפוש ישיר (ללא Isolate) - Future> _performDirectSearch( - SearchEngine index, - String query, - List facets, - int limit, - ResultsOrder order, - bool fuzzy, - int distance, - Map? customSpacing, - Map>? alternativeWords, - Map>? searchOptions, - ) async { // בדיקה אם יש מרווחים מותאמים אישית, מילים חילופיות או אפשרויות חיפוש final hasCustomSpacing = customSpacing != null && customSpacing.isNotEmpty; final hasAlternativeWords = @@ -180,7 +128,7 @@ class SearchRepository { maxExpansions: maxExpansions, order: order); - print('✅ Direct search completed, found ${results.length} results'); + print('✅ Search completed, found ${results.length} results'); return results; } diff --git a/lib/search/services/search_isolate_service.dart b/lib/search/services/search_isolate_service.dart deleted file mode 100644 index 98b375bed..000000000 --- a/lib/search/services/search_isolate_service.dart +++ /dev/null @@ -1,501 +0,0 @@ -import 'dart:async'; -import 'package:otzaria/utils/isolate_manager.dart'; -import 'package:otzaria/indexing/services/indexing_isolate_service.dart'; -import 'package:search_engine/search_engine.dart'; -import 'package:flutter_settings_screens/flutter_settings_screens.dart'; -import 'dart:io'; - -/// הודעות חיפוש -abstract class SearchMessage {} - -class TextSearchMessage extends SearchMessage { - final String query; - final List facets; - final int limit; - final SearchOptions options; - final String indexPath; - - TextSearchMessage({ - required this.query, - required this.facets, - required this.limit, - required this.options, - required this.indexPath, - }); -} - -class CountSearchMessage extends SearchMessage { - final String query; - final List facets; - final SearchOptions options; - final String indexPath; - - CountSearchMessage({ - required this.query, - required this.facets, - required this.options, - required this.indexPath, - }); -} - -class BuildRegexMessage extends SearchMessage { - final List words; - final Map>? alternativeWords; - final Map>? searchOptions; - - BuildRegexMessage({ - required this.words, - this.alternativeWords, - this.searchOptions, - }); -} - -/// אפשרויות חיפוש -class SearchOptions { - final bool fuzzy; - final int distance; - final Map? customSpacing; - final Map>? alternativeWords; - final Map>? searchOptions; - final ResultsOrder order; - - SearchOptions({ - this.fuzzy = false, - this.distance = 2, - this.customSpacing, - this.alternativeWords, - this.searchOptions, - this.order = ResultsOrder.relevance, - }); -} - -/// תוצאות חיפוש -class SearchResultWrapper { - final List results; - final int totalCount; - final String? error; - - SearchResultWrapper({ - required this.results, - required this.totalCount, - this.error, - }); -} - -class SearchCountResult { - final int count; - final String? error; - - SearchCountResult({ - required this.count, - this.error, - }); -} - -class RegexBuildResult { - final List regexTerms; - final int slop; - final int maxExpansions; - - RegexBuildResult({ - required this.regexTerms, - required this.slop, - required this.maxExpansions, - }); -} - -/// שירות חיפוש ב-Isolate -class SearchIsolateService { - static IsolateHandler? _searchIsolate; - static ReadOnlySearchEngine? _readOnlyEngine; - - /// אתחול שירות החיפוש - static Future initialize() async { - if (_searchIsolate == null) { - _searchIsolate = await IsolateManager.getOrCreate( - 'search', - _searchIsolateEntry, - ); - - // אתחול מנוע חיפוש לקריאה בלבד - final indexPath = '${Settings.getValue('key-library-path') ?? 'C:/אוצריא'}${Platform.pathSeparator}index'; - _readOnlyEngine = ReadOnlySearchEngine(indexPath: indexPath); - } - } - - /// חיפוש טקסט - static Future searchTexts( - String query, - List facets, - int limit, - SearchOptions options, - ) async { - await initialize(); - - final indexPath = '${Settings.getValue('key-library-path') ?? 'C:/אוצריא'}${Platform.pathSeparator}index'; - - return await _searchIsolate!.compute( - TextSearchMessage( - query: query, - facets: facets, - limit: limit, - options: options, - indexPath: indexPath, - ), - ); - } - - /// ספירת תוצאות - static Future countResults( - String query, - List facets, - SearchOptions options, - ) async { - await initialize(); - - final indexPath = '${Settings.getValue('key-library-path') ?? 'C:/אוצריא'}${Platform.pathSeparator}index'; - - return await _searchIsolate!.compute( - CountSearchMessage( - query: query, - facets: facets, - options: options, - indexPath: indexPath, - ), - ); - } - - /// בניית ביטויים רגולריים מורכבים - static Future buildRegex( - List words, - Map>? alternativeWords, - Map>? searchOptions, - ) async { - await initialize(); - - return await _searchIsolate!.compute( - BuildRegexMessage( - words: words, - alternativeWords: alternativeWords, - searchOptions: searchOptions, - ), - ); - } - - /// שחרור משאבים - static Future dispose() async { - if (_searchIsolate != null) { - await IsolateManager.kill('search'); - _searchIsolate = null; - } - _readOnlyEngine = null; - } -} - -/// נקודת כניסה ל-Isolate של חיפוש -void _searchIsolateEntry(IsolateContext context) { - SearchEngine? searchEngine; - String? currentIndexPath; - - // פונקציה לקבלת או יצירת מנוע חיפוש - Future _getEngine(String indexPath) async { - if (searchEngine == null || currentIndexPath != indexPath) { - searchEngine = SearchEngine(path: indexPath); - currentIndexPath = indexPath; - } - return searchEngine!; - } - - // האזנה להודעות - context.messages.listen((message) async { - try { - if (message is TextSearchMessage) { - // ביצוע חיפוש - final engine = await _getEngine(message.indexPath); - - // בניית פרמטרי החיפוש - final regexData = _buildSearchParams( - message.query, - message.options, - ); - - // ביצוע החיפוש - final results = await engine.search( - regexTerms: regexData.regexTerms, - facets: message.facets, - limit: message.limit, - slop: regexData.slop, - maxExpansions: regexData.maxExpansions, - order: message.options.order, - ); - - context.send(SearchResultWrapper( - results: results, - totalCount: results.length, - )); - - } else if (message is CountSearchMessage) { - // ספירת תוצאות - final engine = await _getEngine(message.indexPath); - - // בניית פרמטרי החיפוש - final regexData = _buildSearchParams( - message.query, - message.options, - ); - - // ביצוע הספירה - final count = await engine.count( - regexTerms: regexData.regexTerms, - facets: message.facets, - slop: regexData.slop, - maxExpansions: regexData.maxExpansions, - ); - - context.send(SearchCountResult(count: count)); - - } else if (message is BuildRegexMessage) { - // בניית רגקס - final result = _buildAdvancedRegex( - message.words, - message.alternativeWords, - message.searchOptions, - ); - - context.send(result); - } - } catch (e) { - // שליחת שגיאה - if (message is TextSearchMessage) { - context.send(SearchResultWrapper( - results: [], - totalCount: 0, - error: e.toString(), - )); - } else if (message is CountSearchMessage) { - context.send(SearchCountResult( - count: 0, - error: e.toString(), - )); - } - } - }); -} - -/// בניית פרמטרי חיפוש -RegexBuildResult _buildSearchParams(String query, SearchOptions options) { - final words = query.trim().split(RegExp(r'\s+')); - - final hasCustomSpacing = options.customSpacing != null && options.customSpacing!.isNotEmpty; - final hasAlternativeWords = options.alternativeWords != null && options.alternativeWords!.isNotEmpty; - final hasSearchOptions = options.searchOptions != null && options.searchOptions!.isNotEmpty; - - List regexTerms; - int effectiveSlop; - - if (hasAlternativeWords || hasSearchOptions) { - // בניית query מתקדם - final result = _buildAdvancedRegex(words, options.alternativeWords, options.searchOptions); - regexTerms = result.regexTerms; - effectiveSlop = hasCustomSpacing - ? _getMaxCustomSpacing(options.customSpacing!, words.length) - : (options.fuzzy ? options.distance : 0); - } else if (options.fuzzy) { - regexTerms = words; - effectiveSlop = options.distance; - } else if (words.length == 1) { - regexTerms = [query]; - effectiveSlop = 0; - } else if (hasCustomSpacing) { - regexTerms = words; - effectiveSlop = _getMaxCustomSpacing(options.customSpacing!, words.length); - } else { - regexTerms = words; - effectiveSlop = options.distance; - } - - final maxExpansions = _calculateMaxExpansions( - options.fuzzy, - regexTerms.length, - searchOptions: options.searchOptions, - words: words, - ); - - return RegexBuildResult( - regexTerms: regexTerms, - slop: effectiveSlop, - maxExpansions: maxExpansions, - ); -} - -/// בניית רגקס מתקדם -RegexBuildResult _buildAdvancedRegex( - List words, - Map>? alternativeWords, - Map>? searchOptions, -) { - List regexTerms = []; - - for (int i = 0; i < words.length; i++) { - final word = words[i]; - final wordKey = '${word}_$i'; - - // קבלת אפשרויות החיפוש למילה - final wordOptions = searchOptions?[wordKey] ?? {}; - final hasPrefix = wordOptions['קידומות'] == true; - final hasSuffix = wordOptions['סיומות'] == true; - final hasGrammaticalPrefixes = wordOptions['קידומות דקדוקיות'] == true; - final hasGrammaticalSuffixes = wordOptions['סיומות דקדוקיות'] == true; - final hasFullPartialSpelling = wordOptions['כתיב מלא/חסר'] == true; - final hasPartialWord = wordOptions['חלק ממילה'] == true; - - // קבלת מילים חילופיות - final alternatives = alternativeWords?[i]; - - // בניית רשימת כל האפשרויות - final allOptions = [word]; - if (alternatives != null && alternatives.isNotEmpty) { - allOptions.addAll(alternatives); - } - - // סינון אפשרויות ריקות - final validOptions = allOptions.where((w) => w.trim().isNotEmpty).toList(); - - if (validOptions.isNotEmpty) { - final allVariations = {}; - - for (final option in validOptions) { - String pattern = option; - - // החלת אפשרויות חיפוש - if (hasFullPartialSpelling) { - pattern = _createSpellingPattern(pattern); - } - - if (hasGrammaticalPrefixes && hasGrammaticalSuffixes) { - pattern = _createFullMorphologicalPattern(pattern); - } else if (hasGrammaticalPrefixes) { - pattern = _createPrefixPattern(pattern); - } else if (hasGrammaticalSuffixes) { - pattern = _createSuffixPattern(pattern); - } else if (hasPrefix) { - pattern = '.*${RegExp.escape(pattern)}'; - } else if (hasSuffix) { - pattern = '${RegExp.escape(pattern)}.*'; - } else if (hasPartialWord) { - pattern = '.*${RegExp.escape(pattern)}.*'; - } else { - pattern = RegExp.escape(pattern); - } - - allVariations.add(pattern); - } - - // הגבלת מספר הוריאציות - final limitedVariations = allVariations.length > 20 - ? allVariations.take(20).toList() - : allVariations.toList(); - - final finalPattern = limitedVariations.length == 1 - ? limitedVariations.first - : '(${limitedVariations.join('|')})'; - - regexTerms.add(finalPattern); - } else { - regexTerms.add(word); - } - } - - return RegexBuildResult( - regexTerms: regexTerms, - slop: 0, - maxExpansions: 100, - ); -} - -/// חישוב מרווח מקסימלי -int _getMaxCustomSpacing(Map customSpacing, int wordCount) { - int maxSpacing = 0; - - for (int i = 0; i < wordCount - 1; i++) { - final spacingKey = '$i-${i + 1}'; - final customSpacingValue = customSpacing[spacingKey]; - - if (customSpacingValue != null && customSpacingValue.isNotEmpty) { - final spacingNum = int.tryParse(customSpacingValue) ?? 0; - maxSpacing = maxSpacing > spacingNum ? maxSpacing : spacingNum; - } - } - - return maxSpacing; -} - -/// חישוב maxExpansions -int _calculateMaxExpansions( - bool fuzzy, - int termCount, { - Map>? searchOptions, - List? words, -}) { - bool hasSuffixOrPrefix = false; - int shortestWordLength = 10; - - if (searchOptions != null && words != null) { - for (int i = 0; i < words.length; i++) { - final word = words[i]; - final wordKey = '${word}_$i'; - final wordOptions = searchOptions[wordKey] ?? {}; - - if (wordOptions['סיומות'] == true || - wordOptions['קידומות'] == true || - wordOptions['קידומות דקדוקיות'] == true || - wordOptions['סיומות דקדוקיות'] == true || - wordOptions['חלק ממילה'] == true) { - hasSuffixOrPrefix = true; - if (word.length < shortestWordLength) { - shortestWordLength = word.length; - } - } - } - } - - if (fuzzy) { - return 50; - } else if (hasSuffixOrPrefix) { - if (shortestWordLength <= 1) { - return 2000; - } else if (shortestWordLength <= 2) { - return 3000; - } else if (shortestWordLength <= 3) { - return 4000; - } else { - return 5000; - } - } else if (termCount > 1) { - return 100; - } else { - return 10; - } -} - -// פונקציות עזר ליצירת דפוסי רגקס -String _createSpellingPattern(String word) { - // יצירת וריאציות כתיב מלא/חסר - return word.replaceAll('י', '[י]?') - .replaceAll('ו', '[ו]?') - .replaceAll("'", "['\"]*"); -} - -String _createPrefixPattern(String word) { - return r'(ו|מ|כ|ב|ש|ל|ה|ד)?(כ|ב|ש|ל|ה|ד)?(ה)?' + RegExp.escape(word); -} - -String _createSuffixPattern(String word) { - return RegExp.escape(word) + r'(ות|ים|יה|יו|יך|ינו|יכם|יכן|יהם|יהן|י|ך|ו|ה|נו|כם|כן|ם|ן)?'; -} - -String _createFullMorphologicalPattern(String word) { - return r'(ו|מ|כ|ב|ש|ל|ה|ד)?(כ|ב|ש|ל|ה|ד)?(ה)?' + - RegExp.escape(word) + - r'(ות|ים|יה|יו|יך|ינו|יכם|יכן|יהם|יהן|י|ך|ו|ה|נו|כם|כן|ם|ן)?'; -} diff --git a/lib/utils/file_processing_isolate.dart b/lib/utils/file_processing_isolate.dart deleted file mode 100644 index 09470b0c8..000000000 --- a/lib/utils/file_processing_isolate.dart +++ /dev/null @@ -1,373 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter/foundation.dart'; -import 'package:otzaria/utils/isolate_manager.dart'; -import 'package:otzaria/utils/text_manipulation.dart'; - -/// סוגי הודעות לעיבוד קבצים -abstract class FileMessage {} - -class ReadFileMessage extends FileMessage { - final String filePath; - final String? encoding; - - ReadFileMessage({ - required this.filePath, - this.encoding = 'utf8', - }); -} - -class WriteFileMessage extends FileMessage { - final String filePath; - final String content; - final FileMode mode; - - WriteFileMessage({ - required this.filePath, - required this.content, - this.mode = FileMode.write, - }); -} - -class ParseJsonMessage extends FileMessage { - final String jsonString; - - ParseJsonMessage({required this.jsonString}); -} - -class EncodeJsonMessage extends FileMessage { - final dynamic object; - final bool pretty; - - EncodeJsonMessage({ - required this.object, - this.pretty = false, - }); -} - -class ProcessTextMessage extends FileMessage { - final String text; - final List operations; - - ProcessTextMessage({ - required this.text, - required this.operations, - }); -} - -class ParseTocMessage extends FileMessage { - final String bookContent; - - ParseTocMessage({required this.bookContent}); -} - -/// פעולות עיבוד טקסט -enum TextOperation { - stripHtml, - removeVowels, - removeSectionNames, - replaceParaphrases, -} - -/// תוצאות עיבוד קבצים -class FileResult { - final T? data; - final String? error; - final bool success; - - FileResult({ - this.data, - this.error, - }) : success = error == null; -} - -/// שירות עיבוד קבצים ב-Isolate -class FileProcessingIsolate { - static IsolateHandler? _isolateHandler; - - /// אתחול ה-Isolate - static Future initialize() async { - _isolateHandler ??= await IsolateManager.getOrCreate( - 'file_processing', - _fileProcessingEntry, - ); - } - - /// קריאת קובץ טקסט - static Future> readFile(String filePath, {String encoding = 'utf8'}) async { - await initialize(); - return await _isolateHandler!.compute>( - ReadFileMessage(filePath: filePath, encoding: encoding), - ); - } - - /// כתיבת קובץ טקסט - static Future> writeFile( - String filePath, - String content, { - FileMode mode = FileMode.write, - }) async { - await initialize(); - return await _isolateHandler!.compute>( - WriteFileMessage( - filePath: filePath, - content: content, - mode: mode, - ), - ); - } - - /// פענוח JSON - static Future> parseJson(String jsonString) async { - await initialize(); - return await _isolateHandler!.compute>( - ParseJsonMessage(jsonString: jsonString), - ); - } - - /// קידוד JSON - static Future> encodeJson(dynamic object, {bool pretty = false}) async { - await initialize(); - return await _isolateHandler!.compute>( - EncodeJsonMessage(object: object, pretty: pretty), - ); - } - - /// עיבוד טקסט עם פעולות שונות - static Future> processText( - String text, - List operations, - ) async { - await initialize(); - return await _isolateHandler!.compute>( - ProcessTextMessage(text: text, operations: operations), - ); - } - - /// פענוח תוכן עניינים - static Future>> parseToc(String bookContent) async { - await initialize(); - return await _isolateHandler!.compute>>( - ParseTocMessage(bookContent: bookContent), - ); - } - - /// שחרור ה-Isolate - static Future dispose() async { - if (_isolateHandler != null) { - await IsolateManager.kill('file_processing'); - _isolateHandler = null; - } - } -} - -/// נקודת כניסה ל-Isolate של עיבוד קבצים -void _fileProcessingEntry(IsolateContext context) { - // האזנה להודעות - context.messages.listen((message) async { - try { - if (message is ReadFileMessage) { - // קריאת קובץ - final file = File(message.filePath); - - if (!await file.exists()) { - context.send(FileResult( - error: 'File not found: ${message.filePath}', - )); - return; - } - - String content; - if (message.encoding == 'utf8') { - content = await file.readAsString(encoding: utf8); - } else { - content = await file.readAsString(); - } - - context.send(FileResult(data: content)); - - } else if (message is WriteFileMessage) { - // כתיבת קובץ - final file = File(message.filePath); - - // יצירת תיקייה אם לא קיימת - final directory = file.parent; - if (!await directory.exists()) { - await directory.create(recursive: true); - } - - await file.writeAsString( - message.content, - mode: message.mode, - encoding: utf8, - ); - - context.send(FileResult(data: true)); - - } else if (message is ParseJsonMessage) { - // פענוח JSON - final parsed = jsonDecode(message.jsonString); - context.send(FileResult(data: parsed)); - - } else if (message is EncodeJsonMessage) { - // קידוד JSON - String encoded; - if (message.pretty) { - const encoder = JsonEncoder.withIndent(' '); - encoded = encoder.convert(message.object); - } else { - encoded = jsonEncode(message.object); - } - - context.send(FileResult(data: encoded)); - - } else if (message is ProcessTextMessage) { - // עיבוד טקסט - String result = message.text; - - for (final operation in message.operations) { - switch (operation) { - case TextOperation.stripHtml: - result = stripHtmlIfNeeded(result); - break; - case TextOperation.removeVowels: - result = removeVolwels(result); - break; - case TextOperation.removeSectionNames: - result = removeSectionNames(result); - break; - case TextOperation.replaceParaphrases: - result = replaceParaphrases(result); - break; - } - } - - context.send(FileResult(data: result)); - - } else if (message is ParseTocMessage) { - // פענוח תוכן עניינים - final toc = _parseTocInIsolate(message.bookContent); - context.send(FileResult>(data: toc)); - } - } catch (e) { - // שליחת שגיאה - if (message is ReadFileMessage) { - context.send(FileResult(error: e.toString())); - } else if (message is WriteFileMessage) { - context.send(FileResult(error: e.toString())); - } else if (message is ParseJsonMessage) { - context.send(FileResult(error: e.toString())); - } else if (message is EncodeJsonMessage) { - context.send(FileResult(error: e.toString())); - } else if (message is ProcessTextMessage) { - context.send(FileResult(error: e.toString())); - } else if (message is ParseTocMessage) { - context.send(FileResult>(error: e.toString())); - } - } - }); -} - -/// פענוח תוכן עניינים בתוך ה-Isolate -List _parseTocInIsolate(String bookContent) { - List lines = bookContent.split('\n'); - List toc = []; - Map parents = {}; - - for (int i = 0; i < lines.length; i++) { - final String line = lines[i]; - if (line.startsWith(' children = []; - - TocEntry({ - required this.text, - required this.index, - required this.level, - this.parent, - }); - - Map toJson() => { - 'text': text, - 'index': index, - 'level': level, - 'children': children.map((e) => e.toJson()).toList(), - }; -} - -/// כיתת עזר לקריאה וכתיבה אסינכרונית של קבצים גדולים -class LargeFileProcessor { - /// קריאת קובץ גדול בחלקים - static Stream readLargeFile(String filePath, {int chunkSize = 1024 * 1024}) async* { - final file = File(filePath); - final inputStream = file.openRead(); - - await for (final chunk in inputStream.transform(utf8.decoder)) { - yield chunk; - } - } - - /// כתיבת קובץ גדול בחלקים - static Future writeLargeFile( - String filePath, - Stream dataStream, - ) async { - final file = File(filePath); - final sink = file.openWrite(); - - await for (final chunk in dataStream) { - sink.write(chunk); - } - - await sink.flush(); - await sink.close(); - } - - /// עיבוד קובץ JSON גדול שורה-שורה - static Stream processJsonLines(String filePath) async* { - final file = File(filePath); - final lines = file.openRead() - .transform(utf8.decoder) - .transform(const LineSplitter()); - - await for (final line in lines) { - if (line.trim().isNotEmpty) { - try { - yield jsonDecode(line); - } catch (e) { - debugPrint('Error parsing JSON line: $e'); - } - } - } - } -} diff --git a/lib/utils/isolate_manager.dart b/lib/utils/isolate_manager.dart deleted file mode 100644 index 5ed23ba03..000000000 --- a/lib/utils/isolate_manager.dart +++ /dev/null @@ -1,182 +0,0 @@ -import 'dart:async'; -import 'dart:isolate'; - -/// מנהל מרכזי לכל ה-Isolates באפליקציה -/// -/// מטפל ביצירה, תקשורת והשמדה של Isolates לפעולות כבדות -class IsolateManager { - static final Map _isolates = {}; - - /// יצירת Isolate חדש או קבלת קיים - static Future getOrCreate( - String name, - IsolateEntryPoint entryPoint, { - Map? initialData, - }) async { - if (_isolates.containsKey(name)) { - return _isolates[name]!; - } - - final handler = await IsolateHandler.spawn( - name: name, - entryPoint: entryPoint, - initialData: initialData, - ); - - _isolates[name] = handler; - return handler; - } - - /// סגירת Isolate ספציפי - static Future kill(String name) async { - final isolate = _isolates[name]; - if (isolate != null) { - await isolate.dispose(); - _isolates.remove(name); - } - } - - /// סגירת כל ה-Isolates - static Future killAll() async { - for (final isolate in _isolates.values) { - await isolate.dispose(); - } - _isolates.clear(); - } -} - -/// נקודת כניסה ל-Isolate -typedef IsolateEntryPoint = void Function(IsolateContext context); - -/// הקשר של ה-Isolate -class IsolateContext { - final SendPort sendPort; - final Map? initialData; - final ReceivePort receivePort = ReceivePort(); - - IsolateContext({ - required this.sendPort, - this.initialData, - }); - - /// שליחת תוצאה חזרה ל-Main thread - void send(dynamic message) { - sendPort.send(message); - } - - /// האזנה להודעות מה-Main thread - Stream get messages => receivePort.asBroadcastStream(); -} - -/// מטפל ב-Isolate בודד -class IsolateHandler { - final String name; - final Isolate _isolate; - final SendPort _sendPort; - final ReceivePort _receivePort; - final StreamController _responseController; - - IsolateHandler._({ - required this.name, - required Isolate isolate, - required SendPort sendPort, - required ReceivePort receivePort, - }) : _isolate = isolate, - _sendPort = sendPort, - _receivePort = receivePort, - _responseController = StreamController.broadcast(); - - /// יצירת Isolate חדש - static Future spawn({ - required String name, - required IsolateEntryPoint entryPoint, - Map? initialData, - }) async { - final receivePort = ReceivePort(); - final completer = Completer(); - - // האזנה להודעה הראשונה שתכיל את ה-SendPort - receivePort.listen((message) { - if (message is SendPort && !completer.isCompleted) { - completer.complete(message); - } - }); - - // יצירת ה-Isolate - final isolate = await Isolate.spawn( - _isolateEntryWrapper, - _IsolateStartupData( - sendPort: receivePort.sendPort, - entryPoint: entryPoint, - initialData: initialData, - ), - ); - - // קבלת ה-SendPort מה-Isolate - final sendPort = await completer.future; - - final handler = IsolateHandler._( - name: name, - isolate: isolate, - sendPort: sendPort, - receivePort: receivePort, - ); - - // האזנה להודעות מה-Isolate - receivePort.listen((message) { - if (message is! SendPort) { - handler._responseController.add(message); - } - }); - - return handler; - } - - /// שליחת הודעה ל-Isolate - Future compute(dynamic message) async { - _sendPort.send(message); - return await _responseController.stream.first as T; - } - - /// שליחת הודעה ל-Isolate ללא המתנה לתשובה - void send(dynamic message) { - _sendPort.send(message); - } - - /// האזנה לתשובות מה-Isolate - Stream get responses => _responseController.stream; - - /// סגירת ה-Isolate - Future dispose() async { - _isolate.kill(priority: Isolate.immediate); - _receivePort.close(); - await _responseController.close(); - } -} - -/// מידע להפעלת Isolate -class _IsolateStartupData { - final SendPort sendPort; - final IsolateEntryPoint entryPoint; - final Map? initialData; - - _IsolateStartupData({ - required this.sendPort, - required this.entryPoint, - this.initialData, - }); -} - -/// Wrapper לנקודת הכניסה ל-Isolate -void _isolateEntryWrapper(_IsolateStartupData data) { - final context = IsolateContext( - sendPort: data.sendPort, - initialData: data.initialData, - ); - - // שליחת ה-SendPort חזרה ל-Main thread - data.sendPort.send(context.receivePort.sendPort); - - // הפעלת נקודת הכניסה - data.entryPoint(context); -} From a31441f7999407119db9a2a2a43a22d07b1b134c Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 5 Sep 2025 03:17:36 +0300 Subject: [PATCH 165/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=91?= =?UTF-8?q?=D7=97=D7=99=D7=A8=D7=AA=20=D7=9B=D7=9C=20=D7=A9=D7=95=D7=A8?= =?UTF-8?q?=D7=AA=20=D7=94=D7=98=D7=A7=D7=A1=D7=98=20=D7=91=D7=93=D7=99?= =?UTF-8?q?=D7=95=D7=95=D7=97=20=D7=A9=D7=92=D7=99=D7=90=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/text_book/view/text_book_screen.dart | 32 +++++++++++++++++------- lib/widgets/phone_report_tab.dart | 16 ++++++------ 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index 7b78f1785..711d50e22 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -576,14 +576,6 @@ class _TextBookViewerBlocState extends State BuildContext context, TextBookLoaded state, ) async { - final currentRef = await refFromIndex( - state.positionsListener.itemPositions.value.isNotEmpty - ? state.positionsListener.itemPositions.value.first.index - : 0, - state.book.tableOfContents, - ); - - final bookDetails = await _getBookDetails(state.book.title); final allText = state.content; final visiblePositions = state.positionsListener.itemPositions.value .toList() @@ -594,6 +586,15 @@ class _TextBookViewerBlocState extends State if (!mounted) return; + // התחל לטעון את הנתונים הכבדים במקביל (ללא await) + final currentRefFuture = refFromIndex( + state.positionsListener.itemPositions.value.isNotEmpty + ? state.positionsListener.itemPositions.value.first.index + : 0, + state.book.tableOfContents, + ); + final bookDetailsFuture = _getBookDetails(state.book.title); + final dynamic result = await _showTabbedReportDialog( context, visibleText, @@ -604,6 +605,13 @@ class _TextBookViewerBlocState extends State if (result == null) return; // בוטל if (!mounted) return; + // עכשיו נחכה לנתונים שכבר רצים ברקע + final currentRef = await currentRefFuture; + final bookDetails = await bookDetailsFuture; + + if (result == null) return; // בוטל + if (!mounted) return; + // Handle different result types if (result is ReportedErrorData) { // Regular report - continue with existing flow @@ -1371,7 +1379,6 @@ class _TabbedReportDialogState extends State<_TabbedReportDialog> } Future _loadPhoneReportData() async { - // קוד זה נשאר זהה try { final availability = await _dataService.checkDataAvailability(widget.bookTitle); @@ -1555,6 +1562,8 @@ class _RegularReportTabState extends State<_RegularReportTab> { fontFamily: Settings.getValue('key-font-family') ?? 'candara', ), + textAlign: TextAlign.right, + textDirection: TextDirection.rtl, onSelectionChanged: (selection, cause) { if (selection.start != selection.end) { final newContent = widget.visibleText.substring( @@ -1569,6 +1578,9 @@ class _RegularReportTabState extends State<_RegularReportTab> { } } }, + contextMenuBuilder: (context, editableTextState) { + return const SizedBox.shrink(); + }, ), ), ), @@ -1625,3 +1637,5 @@ class _RegularReportTabState extends State<_RegularReportTab> { ); } } + + diff --git a/lib/widgets/phone_report_tab.dart b/lib/widgets/phone_report_tab.dart index fbb4b086b..9390aa4e2 100644 --- a/lib/widgets/phone_report_tab.dart +++ b/lib/widgets/phone_report_tab.dart @@ -165,31 +165,33 @@ class _PhoneReportTabState extends State { fontSize: widget.fontSize, fontFamily: Settings.getValue('key-font-family') ?? 'candara', ), + textAlign: TextAlign.right, textDirection: TextDirection.rtl, onSelectionChanged: (selection, cause) { if (selection.start != selection.end) { - final newContent = widget.visibleText.substring( + final selectedText = widget.visibleText.substring( selection.start, selection.end, ); - // --- כאן הלוגיקה הנכונה והמתוקנת --- + // חישוב מספר השורה על בסיס הטקסט הנבחר final textBeforeSelection = widget.visibleText.substring(0, selection.start); final lineOffset = '\n'.allMatches(textBeforeSelection).length; final newLineNumber = widget.lineNumber + lineOffset; - // --- סוף הלוגיקה --- - if (newContent.isNotEmpty) { + if (selectedText.isNotEmpty) { setState(() { - _selectedText = newContent; - _updatedLineNumber = - newLineNumber; // עדכון מספר השורה ב-state + _selectedText = selectedText; + _updatedLineNumber = newLineNumber; }); } } }, + contextMenuBuilder: (context, editableTextState) { + return const SizedBox.shrink(); + }, ), ), ), From d1188368ce0d2066844bcb79d0d2a3e04fccefc7 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 5 Sep 2025 13:41:44 +0300 Subject: [PATCH 166/197] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20=D7=92?= =?UTF-8?q?=D7=99=D7=A8=D7=A1=D7=90=20=D7=9C=2048?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++-- installer/otzaria.iss | 2 +- installer/otzaria_full.iss | 2 +- pubspec.yaml | 4 ++-- version.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index e3d522707..337377e81 100644 --- a/.gitignore +++ b/.gitignore @@ -60,7 +60,7 @@ android/ build/ fonts/ -installer/otzaria-0.9.47-windows.exe -installer/otzaria-0.9.47-windows-full.exe +installer/otzaria-0.9.48-windows.exe +installer/otzaria-0.9.48-windows-full.exe pubspec.lock flutter/ diff --git a/installer/otzaria.iss b/installer/otzaria.iss index e0036c96c..12e751e13 100644 --- a/installer/otzaria.iss +++ b/installer/otzaria.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.47" +#define MyAppVersion "0.9.48" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/installer/otzaria_full.iss b/installer/otzaria_full.iss index a4e019ae0..efc94e975 100644 --- a/installer/otzaria_full.iss +++ b/installer/otzaria_full.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.47" +#define MyAppVersion "0.9.48" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/pubspec.yaml b/pubspec.yaml index 9c3e05323..367beb8a8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,7 @@ msix_config: publisher_display_name: sivan22 identity_name: sivan22.Otzaria description: "ספריית ספרים יהודיים עם אפשרויות חיפוש חכם" - msix_version: 0.9.47.0 + msix_version: 0.9.48.0 logo_path: assets/icon/icon.png publisher: CN=sivan22, O=sivan22, C=IL certificate_path: sivan22.pfx @@ -36,7 +36,7 @@ msix_config: # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.9.47 +version: 0.9.48 environment: sdk: ">=3.2.6 <4.0.0" diff --git a/version.json b/version.json index 43ec759ae..8983ed199 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "0.9.47" + "version": "0.9.48" } \ No newline at end of file From b5c31800088137253cfe784327d05f8309c6bb13 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 5 Sep 2025 14:12:01 +0300 Subject: [PATCH 167/197] =?UTF-8?q?=D7=A8=D7=99=D7=A7=D7=95=D7=9F=20=D7=96?= =?UTF-8?q?=D7=99=D7=9B=D7=A8=D7=95=D7=9F=20=D7=9C=D7=90=D7=97=D7=A8=20?= =?UTF-8?q?=D7=A1=D7=92=D7=99=D7=A8=D7=AA=20=D7=93=D7=99=D7=90=D7=9C=D7=95?= =?UTF-8?q?=D7=92=20=D7=94=D7=93=D7=99=D7=95=D7=95=D7=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/text_book/view/text_book_screen.dart | 147 ++++++++++++++++------- 1 file changed, 102 insertions(+), 45 deletions(-) diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index 711d50e22..3282597fb 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -78,6 +78,10 @@ class _TextBookViewerBlocState extends State static const String _reportSeparator2 = '------------------------------'; static const String _fallbackMail = 'otzaria.200@gmail.com'; bool _isInitialFocusDone = false; + + // משתנים לשמירת נתונים כבדים שנטענים ברקע + Future>? _preloadedHeavyData; + bool _isLoadingHeavyData = false; String? encodeQueryParameters(Map params) { return params.entries @@ -586,58 +590,85 @@ class _TextBookViewerBlocState extends State if (!mounted) return; - // התחל לטעון את הנתונים הכבדים במקביל (ללא await) - final currentRefFuture = refFromIndex( - state.positionsListener.itemPositions.value.isNotEmpty - ? state.positionsListener.itemPositions.value.first.index - : 0, - state.book.tableOfContents, - ); - final bookDetailsFuture = _getBookDetails(state.book.title); - final dynamic result = await _showTabbedReportDialog( context, visibleText, state.fontSize, state.book.title, + state, // העבר את ה-state לדיאלוג ); - if (result == null) return; // בוטל - if (!mounted) return; - - // עכשיו נחכה לנתונים שכבר רצים ברקע - final currentRef = await currentRefFuture; - final bookDetails = await bookDetailsFuture; + try { + if (result == null) return; // בוטל + if (!mounted) return; + + // Handle different result types + if (result is ReportedErrorData) { + // Regular report - the heavy data should already be loaded by now + final ReportAction? action = + await _showConfirmationDialog(context, result); + + if (action == null || action == ReportAction.cancel) return; + + // Get the heavy data that was loaded in background + final heavyData = await _getPreloadedHeavyData(state); + + // Handle regular report actions + await _handleRegularReportAction(action, result, state, + heavyData['currentRef'], heavyData['bookDetails']); + } else if (result is PhoneReportData) { + // Phone report - handle directly + await _handlePhoneReport(result); + } + } finally { + // נקה את הנתונים הכבדים מהזיכרון בכל מקרה (דיווח או ביטול) + _clearHeavyDataFromMemory(); + } + } - if (result == null) return; // בוטל - if (!mounted) return; + /// Load heavy data for regular report in background + Future> _loadHeavyDataForRegularReport( + TextBookLoaded state) async { + final currentRef = await refFromIndex( + state.positionsListener.itemPositions.value.isNotEmpty + ? state.positionsListener.itemPositions.value.first.index + : 0, + state.book.tableOfContents, + ); - // Handle different result types - if (result is ReportedErrorData) { - // Regular report - continue with existing flow - final ReportAction? action = - await _showConfirmationDialog(context, result); + final bookDetails = await _getBookDetails(state.book.title); - if (action == null || action == ReportAction.cancel) return; + return {'currentRef': currentRef, 'bookDetails': bookDetails}; + } - // Handle regular report actions - await _handleRegularReportAction( - action, result, state, currentRef, bookDetails); - } else if (result is PhoneReportData) { - // Phone report - handle directly - await _handlePhoneReport(result); + /// Get preloaded heavy data or load it if not ready + Future> _getPreloadedHeavyData(TextBookLoaded state) async { + if (_preloadedHeavyData != null) { + return await _preloadedHeavyData!; + } else { + return await _loadHeavyDataForRegularReport(state); } } + /// Clear heavy data from memory to free up resources + void _clearHeavyDataFromMemory() { + _preloadedHeavyData = null; + _isLoadingHeavyData = false; + } + Future _showTabbedReportDialog( BuildContext context, String text, double fontSize, String bookTitle, + TextBookLoaded state, ) async { // קבל את מספר השורה ההתחלתי לפני פתיחת הדיאלוג final currentLineNumber = _getCurrentLineNumber(); + // התחל לטעון נתונים כבדים ברקע מיד אחרי פתיחת הדיאלוג + _startLoadingHeavyDataInBackground(state); + return showDialog( context: context, builder: (BuildContext context) { @@ -645,13 +676,29 @@ class _TextBookViewerBlocState extends State visibleText: text, fontSize: fontSize, bookTitle: bookTitle, - // העבר רק את מספר השורה ההתחלתי currentLineNumber: currentLineNumber, + state: state, // העבר את ה-state לדיאלוג ); }, ); } + /// Start loading heavy data in background immediately after dialog opens + void _startLoadingHeavyDataInBackground(TextBookLoaded state) { + if (_isLoadingHeavyData) return; // כבר טוען + + _isLoadingHeavyData = true; + + // התחל טעינה ברקע + _preloadedHeavyData = _loadHeavyDataForRegularReport(state).then((data) { + _isLoadingHeavyData = false; + return data; + }).catchError((error) { + _isLoadingHeavyData = false; + throw error; + }); + } + Future _showConfirmationDialog( BuildContext context, ReportedErrorData reportData, @@ -1338,13 +1385,15 @@ class _TabbedReportDialog extends StatefulWidget { final String visibleText; final double fontSize; final String bookTitle; - final int currentLineNumber; // חזרנו לפרמטר המקורי והנכון + final int currentLineNumber; + final TextBookLoaded state; const _TabbedReportDialog({ required this.visibleText, required this.fontSize, required this.bookTitle, - required this.currentLineNumber, // וגם כאן + required this.currentLineNumber, + required this.state, }); @override @@ -1369,7 +1418,13 @@ class _TabbedReportDialogState extends State<_TabbedReportDialog> void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); - _loadPhoneReportData(); + + // טען נתוני דיווח טלפוני רק אחרי שהדיאלוג נפתח + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _loadPhoneReportData(); + } + }); } @override @@ -1383,18 +1438,22 @@ class _TabbedReportDialogState extends State<_TabbedReportDialog> final availability = await _dataService.checkDataAvailability(widget.bookTitle); - setState(() { - _libraryVersion = availability['libraryVersion'] ?? 'unknown'; - _bookId = availability['bookId']; - _dataErrors = List.from(availability['errors'] ?? []); - _isLoadingData = false; - }); + if (mounted) { + setState(() { + _libraryVersion = availability['libraryVersion'] ?? 'unknown'; + _bookId = availability['bookId']; + _dataErrors = List.from(availability['errors'] ?? []); + _isLoadingData = false; + }); + } } catch (e) { debugPrint('Error loading phone report data: $e'); - setState(() { - _dataErrors = ['שגיאה בטעינת נתוני הדיווח']; - _isLoadingData = false; - }); + if (mounted) { + setState(() { + _dataErrors = ['שגיאה בטעינת נתוני הדיווח']; + _isLoadingData = false; + }); + } } } @@ -1637,5 +1696,3 @@ class _RegularReportTabState extends State<_RegularReportTab> { ); } } - - From 6cd709532700879444895887185fb0d1b723ecbc Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 5 Sep 2025 16:21:52 +0300 Subject: [PATCH 168/197] =?UTF-8?q?=D7=9E=D7=99=D7=A7=D7=95=D7=93=20=D7=91?= =?UTF-8?q?=D7=9E=D7=9E=D7=99=D7=A8=20=D7=92=D7=9D=20=D7=91=D7=9C=D7=97?= =?UTF-8?q?=D7=99=D7=A6=D7=94=20=D7=A2=D7=9C=20=D7=90=D7=A0=D7=98=D7=A8=20?= =?UTF-8?q?=D7=95=D7=98=D7=90=D7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../measurement_converter_screen.dart | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/tools/measurement_converter/measurement_converter_screen.dart b/lib/tools/measurement_converter/measurement_converter_screen.dart index 06ef7c068..bc74d68c5 100644 --- a/lib/tools/measurement_converter/measurement_converter_screen.dart +++ b/lib/tools/measurement_converter/measurement_converter_screen.dart @@ -29,6 +29,7 @@ class _MeasurementConverterScreenState final TextEditingController _resultController = TextEditingController(); final FocusNode _inputFocusNode = FocusNode(); final FocusNode _screenFocusNode = FocusNode(); + bool _showResultField = false; // Maps to remember user selections for each category final Map _rememberedFromUnits = {}; @@ -92,6 +93,9 @@ class _MeasurementConverterScreenState _inputController.text = _rememberedInputValues[_selectedCategory] ?? ''; _resultController.clear(); + // Update result field visibility based on input + _showResultField = _inputController.text.isNotEmpty; + // Convert if there's a remembered input value if (_rememberedInputValues[_selectedCategory] != null && _rememberedInputValues[_selectedCategory]!.isNotEmpty) { @@ -328,7 +332,7 @@ class _MeasurementConverterScreenState onKeyEvent: (node, event) { if (event is KeyDownEvent) { final String character = event.character ?? ''; - + // Check if the pressed key is a number or decimal point if (RegExp(r'[0-9.]').hasMatch(character)) { // Auto-focus the input field and add the character @@ -342,6 +346,9 @@ class _MeasurementConverterScreenState _inputController.selection = TextSelection.fromPosition( TextPosition(offset: newText.length), ); + setState(() { + _showResultField = newText.isNotEmpty; + }); _convert(); }); return KeyEventResult.handled; @@ -349,7 +356,7 @@ class _MeasurementConverterScreenState } // Check if the pressed key is a delete/backspace key else if (event.logicalKey == LogicalKeyboardKey.backspace || - event.logicalKey == LogicalKeyboardKey.delete) { + event.logicalKey == LogicalKeyboardKey.delete) { // Auto-focus the input field and handle deletion if (!_inputFocusNode.hasFocus) { _inputFocusNode.requestFocus(); @@ -359,15 +366,20 @@ class _MeasurementConverterScreenState String newText; if (event.logicalKey == LogicalKeyboardKey.backspace) { // Remove last character - newText = currentText.substring(0, currentText.length - 1); + newText = + currentText.substring(0, currentText.length - 1); } else { // Delete key - remove first character (or handle as backspace for simplicity) - newText = currentText.substring(0, currentText.length - 1); + newText = + currentText.substring(0, currentText.length - 1); } _inputController.text = newText; _inputController.selection = TextSelection.fromPosition( TextPosition(offset: newText.length), ); + setState(() { + _showResultField = newText.isNotEmpty; + }); _convert(); } }); @@ -393,8 +405,10 @@ class _MeasurementConverterScreenState const SizedBox(height: 20), ], _buildInputField(), - const SizedBox(height: 20), - _buildResultDisplay(), + if (_showResultField) ...[ + const SizedBox(height: 20), + _buildResultDisplay(), + ], ], ), ), @@ -846,6 +860,11 @@ class _MeasurementConverterScreenState FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')), ], onChanged: (value) { + setState(() { + // Update result field visibility based on input + _showResultField = value.isNotEmpty; + }); + // Save the input value when it changes if (value.isNotEmpty) { _rememberedInputValues[_selectedCategory] = value; From dc8353a4f3514d1822eebf01fd7189f6c8560ffd Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 7 Sep 2025 15:32:29 +0300 Subject: [PATCH 169/197] =?UTF-8?q?=D7=97=D7=99=D7=A4=D7=95=D7=A9=20=D7=A2?= =?UTF-8?q?=D7=9D=20=D7=9E=D7=A8=D7=9B=D7=90=D7=95=D7=AA=20=D7=91=D7=9E?= =?UTF-8?q?=D7=A1=D7=9A=20=D7=A1=D7=A4=D7=A8=D7=99=D7=99=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/library/view/library_browser.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/library/view/library_browser.dart b/lib/library/view/library_browser.dart index 89f965f9c..7d22d8a8a 100644 --- a/lib/library/view/library_browser.dart +++ b/lib/library/view/library_browser.dart @@ -585,10 +585,12 @@ class _LibraryBrowserState extends State LibraryState state, SettingsState settingsState, ) { + final searchText = context.read().librarySearchController.text; + // Remove all quotation marks from the search query + final cleanSearchText = searchText.replaceAll('"', ''); + context.read().add( - UpdateSearchQuery( - context.read().librarySearchController.text, - ), + UpdateSearchQuery(cleanSearchText), ); context.read().add( SearchBooks( From f43a51092bcc18672eea91dad1791d517e8abc27 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 7 Sep 2025 16:14:20 +0300 Subject: [PATCH 170/197] =?UTF-8?q?=D7=94=D7=93=D7=A4=D7=A1=D7=94=20=D7=A2?= =?UTF-8?q?=D7=9D=20=D7=A9=D7=9D=20=D7=9E=D7=A7=D7=95=D7=A8=D7=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pdf_book/pdf_book_screen.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/pdf_book/pdf_book_screen.dart b/lib/pdf_book/pdf_book_screen.dart index 6585e5c09..0ffaa33d7 100644 --- a/lib/pdf_book/pdf_book_screen.dart +++ b/lib/pdf_book/pdf_book_screen.dart @@ -352,8 +352,11 @@ class _PdfBookScreenState extends State icon: const Icon(Icons.print), tooltip: 'הדפס', onPressed: () async { + final file = File(widget.tab.book.path); + final fileName = file.uri.pathSegments.last; await Printing.sharePdf( - bytes: File(widget.tab.book.path).readAsBytesSync(), + bytes: await file.readAsBytes(), + filename: fileName, ); }, ), From f0d5ace90d72d4a719d1dafd24aa971b770281e2 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 8 Sep 2025 01:54:35 +0300 Subject: [PATCH 171/197] =?UTF-8?q?=D7=9C=D7=97=D7=A6=D7=A0=D7=99=20=D7=92?= =?UTF-8?q?=D7=9C=D7=99=D7=9C=D7=94=20=D7=91=D7=98=D7=90=D7=91=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/tabs/reading_screen.dart | 4 +- lib/widgets/scrollable_tab_bar.dart | 146 ++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 lib/widgets/scrollable_tab_bar.dart diff --git a/lib/tabs/reading_screen.dart b/lib/tabs/reading_screen.dart index a1fc4cc25..524e8df11 100644 --- a/lib/tabs/reading_screen.dart +++ b/lib/tabs/reading_screen.dart @@ -22,6 +22,7 @@ import 'package:otzaria/history/history_dialog.dart'; import 'package:otzaria/bookmarks/bookmarks_dialog.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:otzaria/widgets/workspace_icon_button.dart'; +import 'package:otzaria/widgets/scrollable_tab_bar.dart'; class ReadingScreen extends StatefulWidget { const ReadingScreen({Key? key}) : super(key: key); @@ -196,9 +197,8 @@ class _ReadingScreenState extends State ), title: Container( constraints: const BoxConstraints(maxHeight: 50), - child: TabBar( + child: ScrollableTabBarWithArrows( controller: controller, - isScrollable: true, tabAlignment: TabAlignment.center, tabs: state.tabs .map((tab) => _buildTab(context, tab, state)) diff --git a/lib/widgets/scrollable_tab_bar.dart b/lib/widgets/scrollable_tab_bar.dart new file mode 100644 index 000000000..38adcaa25 --- /dev/null +++ b/lib/widgets/scrollable_tab_bar.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; + +/// ווידג'ט TabBar עם חיצי ניווט כשיש יותר מדי טאבים +class ScrollableTabBarWithArrows extends StatefulWidget { + final TabController controller; + final List tabs; + final TabAlignment? tabAlignment; + + const ScrollableTabBarWithArrows({ + super.key, + required this.controller, + required this.tabs, + this.tabAlignment, + }); + + @override + State createState() => + _ScrollableTabBarWithArrowsState(); +} + +class _ScrollableTabBarWithArrowsState + extends State { + final ScrollController _scrollController = ScrollController(); + bool _canScrollLeft = false; + bool _canScrollRight = false; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_updateArrowVisibility); + // עדכון ראשוני אחרי שהווידג'ט נבנה + WidgetsBinding.instance.addPostFrameCallback((_) { + _updateArrowVisibility(); + }); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _updateArrowVisibility() { + if (!_scrollController.hasClients) { + // אם אין עדיין ScrollController, נבדוק לפי מספר הטאבים + final needsScrolling = widget.tabs.length > 4; + if (mounted) { + setState(() { + _canScrollLeft = needsScrolling; + _canScrollRight = needsScrolling; + }); + } + return; + } + + if (mounted) { + setState(() { + _canScrollLeft = _scrollController.offset > 0; + _canScrollRight = _scrollController.offset < + _scrollController.position.maxScrollExtent; + }); + } + } + + void _scrollLeft() { + if (_scrollController.hasClients) { + final newOffset = (_scrollController.offset - 150).clamp( + 0.0, + _scrollController.position.maxScrollExtent, + ); + _scrollController.animateTo( + newOffset, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + } + + void _scrollRight() { + if (_scrollController.hasClients) { + final newOffset = (_scrollController.offset + 150).clamp( + 0.0, + _scrollController.position.maxScrollExtent, + ); + _scrollController.animateTo( + newOffset, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + // חץ שמאל + AnimatedOpacity( + opacity: _canScrollLeft ? 1.0 : 0.3, + duration: const Duration(milliseconds: 200), + child: IconButton( + onPressed: _canScrollLeft ? _scrollLeft : null, + icon: const Icon(Icons.chevron_left), + iconSize: 20, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + tooltip: 'גלול שמאלה', + ), + ), + // TabBar עם ScrollController מותאם אישית + Expanded( + child: SingleChildScrollView( + controller: _scrollController, + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: IntrinsicWidth( + child: TabBar( + controller: widget.controller, + isScrollable: false, // כבה את הגלילה הפנימית של TabBar + tabs: widget.tabs, + indicatorSize: TabBarIndicatorSize.tab, + ), + ), + ), + ), + // חץ ימין + AnimatedOpacity( + opacity: _canScrollRight ? 1.0 : 0.3, + duration: const Duration(milliseconds: 200), + child: IconButton( + onPressed: _canScrollRight ? _scrollRight : null, + icon: const Icon(Icons.chevron_right), + iconSize: 20, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + tooltip: 'גלול ימינה', + ), + ), + ], + ); + } +} From f4f1d11917b7a27bbe6865dee095d5d4834979ed Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 8 Sep 2025 21:25:21 +0300 Subject: [PATCH 172/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=9C?= =?UTF-8?q?=D7=97=D7=A6=D7=A0=D7=99=20=D7=92=D7=9C=D7=99=D7=9C=D7=94=20?= =?UTF-8?q?=D7=91=D7=98=D7=90=D7=91=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CALENDAR_SETTINGS_IMPLEMENTATION.md | 61 ----------- SEARCH_FIELD_IMPROVEMENTS.md | 80 -------------- lib/widgets/scrollable_tab_bar.dart | 160 +++++++++++++++++----------- 3 files changed, 98 insertions(+), 203 deletions(-) delete mode 100644 CALENDAR_SETTINGS_IMPLEMENTATION.md delete mode 100644 SEARCH_FIELD_IMPROVEMENTS.md diff --git a/CALENDAR_SETTINGS_IMPLEMENTATION.md b/CALENDAR_SETTINGS_IMPLEMENTATION.md deleted file mode 100644 index 8f342d048..000000000 --- a/CALENDAR_SETTINGS_IMPLEMENTATION.md +++ /dev/null @@ -1,61 +0,0 @@ -# יישום זכירת הגדרות לוח השנה - -## מה שהוסף - -### 1. הגדרות חדשות ב-SettingsRepository -- נוסף מפתח חדש: `keyCalendarType = 'key-calendar-type'` -- נוסף מפתח חדש: `keySelectedCity = 'key-selected-city'` -- נוספה פונקציה `updateCalendarType(String value)` לשמירת סוג לוח השנה -- נוספה פונקציה `updateSelectedCity(String value)` לשמירת העיר הנבחרת -- נוספה טעינת ההגדרות ב-`loadSettings()` עם ברירות מחדל -- נוספה אתחול ההגדרות ב-`_writeDefaultsToStorage()` - -### 2. עדכון CalendarCubit -- נוסף constructor parameter עבור `SettingsRepository` -- נוספה פונקציה `_initializeCalendar()` שטוענת את ההגדרות השמורות -- עודכנה `changeCalendarType()` כדי לשמור את הבחירה -- עודכנה `changeCity()` כדי לשמור את העיר הנבחרת -- נוספו פונקציות עזר להמרה בין String ל-CalendarType - -### 3. עדכון MoreScreen -- נוסף import עבור `SettingsRepository` -- נוסף instance של `SettingsRepository` -- עודכן יצירת ה-`CalendarCubit` להעביר את ה-repository - -## איך זה עובד - -1. כשהמשתמש פותח את האפליקציה, ה-`CalendarCubit` טוען את ההגדרות השמורות -2. כשהמשתמש משנה את סוג לוח השנה בדיאלוג ההגדרות, הבחירה נשמרת אוטומטית -3. כשהמשתמש משנה את העיר ב-dropdown, הבחירה נשמרת אוטומטית -4. בפעם הבאה שהמשתמש יפתח את האפליקציה, לוח השנה יוצג עם ההגדרות שנבחרו - -## הגדרות זמינות - -### סוגי לוח השנה -- `hebrew` - לוח עברי בלבד -- `gregorian` - לוח לועזי בלבד -- `combined` - לוח משולב (ברירת מחדל) - -### ערים זמינות -- ירושלים (ברירת מחדל) -- תל אביב -- חיפה -- באר שבע -- נתניה -- אשדוד -- פתח תקווה -- בני ברק -- מודיעין עילית -- צפת -- טבריה -- אילת -- רחובות -- הרצליה -- רמת גן -- חולון -- בת ים -- רמלה -- לוד -- אשקלון - -כל ההגדרות נשמרות ב-SharedPreferences ונטענות אוטומטית בכל הפעלה של האפליקציה. \ No newline at end of file diff --git a/SEARCH_FIELD_IMPROVEMENTS.md b/SEARCH_FIELD_IMPROVEMENTS.md deleted file mode 100644 index cb439f4ce..000000000 --- a/SEARCH_FIELD_IMPROVEMENTS.md +++ /dev/null @@ -1,80 +0,0 @@ -# שיפורים בשדה החיפוש המתקדם - -## בעיות שתוקנו: - -### 1. מחיקת אות אחת מביאה לביטול כל הסימונים -**הבעיה:** כשמוחקים אות אחת ממילה, כל אפשרויות החיפוש והמילים החלופיות נמחקות. - -**הפתרון:** -- הוספת זיהוי "שינוי קטן" vs "שינוי גדול" -- שינוי קטן (מחיקת/הוספת אות) שומר על כל הסימונים -- שינוי גדול (מחיקת/הוספת מילה שלמה) מבצע מיפוי חכם - -### 2. מחיקת מילה לפני מילה מסומנת מבטלת סימונים -**הבעיה:** כשמוחקים מילה לפני מילה שיש לה סימונים, הסימונים נעלמים כי האינדקס משתנה. - -**הפתרון:** -- מיפוי מילים לפי תוכן ודמיון, לא רק לפי אינדקס -- שמירה על סימונים של מילים שנשארו זהות -- מיפוי חכם של מילים דומות - -### 3. מילה חלופית "נדבקת" למילה הראשונה כשמוסיפים מילה לפניה -**הבעיה:** כשמוסיפים מילה לפני מילה עם מילה חלופית, המילה החלופית עוברת למילה החדשה. - -**הפתרון:** -- מיפוי מילים לפי דמיון תוכן -- שמירה על קשר בין מילה למילים החלופיות שלה -- עדכון אינדקסים בצורה חכמה - -## פונקציות חדשות שנוספו: - -### `_isMinorTextChange()` -בודקת אם השינוי הוא קטן (אות אחת) או גדול (מילה שלמה) - -### `_calculateWordSimilarity()` -מחשבת דמיון בין שתי מילים באמצעות אלגוריתם Levenshtein distance מפושט - -### `_mapOldWordsToNew()` -יוצרת מיפוי בין מילים ישנות למילים חדשות לפי דמיון - -### `_remapControllersAndOverlays()` -מעדכנת את כל ה-controllers והבועות לפי המיפוי החדש - -### `_remapSearchOptions()` -מעדכנת את אפשרויות החיפוש לפי המיפוי החדש - -### `_handleMinorTextChange()` & `_handleMajorTextChange()` -מטפלות בשינויים קטנים וגדולים בהתאמה - -## תוצאה: -עכשיו כשמשתמש: -- מוחק אות אחת - כל הסימונים נשמרים ✅ -- מוחק מילה שלמה - כל הסימונים מתאפסים (כמו שצריך) ✅ -- מוסיף מילה - כל הסימונים מתאפסים (כמו שצריך) ✅ -- מוסיף/מוחק מרווח - מרווחים בין מילים נשמרים רק בשינויים קטנים ✅ - -## התיקון הסופי: -הבעיה העיקרית הייתה שגם בשינויים קטנים (מחיקת אות אחת), הקוד יצר SearchQuery חדש שלא שמר על אפשרויות החיפוש הקיימות. - -הפתרון: -1. זיהוי נכון של שינויים קטנים vs גדולים -2. בשינויים קטנים - שמירה ועדכון של אפשרויות החיפוש הקיימות -3. בשינויים גדולים - איפוס מלא (כמו שצריך) - -## תיקון נוסף - בועות שארית: -הבעיה: כשמוחקים מילה, נשארו בועות "שארית" במיקום הקודם. - -הפתרון: -1. ניקוי מלא של כל ה-overlays לפני המיפוי בשינויים גדולים -2. בדיקות תקינות במיקומי הבועות -3. הסרה אוטומטית של controllers במיקומים לא תקינים - -## תיקון נוסף - בעיית הפוקוס: -הבעיה: אחרי שינוי טקסט בשדה החיפוש, הפוקוס עבר למילה החלופית וגרם למחיקה לא רצויה. - -הפתרון: -1. הוספת פרמטר `requestFocus` ל-`_AlternativeField` ו-`_SpacingField` -2. בועות חדשות (כשלוחצים על כפתור +) - מבקשות פוקוס -3. בועות קיימות (כשמציגים מחדש אחרי שינוי טקסט) - לא מבקשות פוקוס - -עכשיו המערכת עובדת מושלם ללא בעיות פוקוס! \ No newline at end of file diff --git a/lib/widgets/scrollable_tab_bar.dart b/lib/widgets/scrollable_tab_bar.dart index 38adcaa25..fd0477f19 100644 --- a/lib/widgets/scrollable_tab_bar.dart +++ b/lib/widgets/scrollable_tab_bar.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -/// ווידג'ט TabBar עם חיצי ניווט כשיש יותר מדי טאבים +/// TabBar הניתן לגלילה עם חיצים (בהשראת אקסל). class ScrollableTabBarWithArrows extends StatefulWidget { final TabController controller; final List tabs; @@ -20,76 +20,82 @@ class ScrollableTabBarWithArrows extends StatefulWidget { class _ScrollableTabBarWithArrowsState extends State { - final ScrollController _scrollController = ScrollController(); + // נשתמש ב־ScrollPosition הפנימי של ה-TabBar (isScrollable:true) + ScrollPosition? _tabBarPosition; + BuildContext? _scrollContext; bool _canScrollLeft = false; bool _canScrollRight = false; - @override - void initState() { - super.initState(); - _scrollController.addListener(_updateArrowVisibility); - // עדכון ראשוני אחרי שהווידג'ט נבנה - WidgetsBinding.instance.addPostFrameCallback((_) { - _updateArrowVisibility(); - }); - } - @override void dispose() { - _scrollController.dispose(); + _detachPositionListener(); super.dispose(); } - void _updateArrowVisibility() { - if (!_scrollController.hasClients) { - // אם אין עדיין ScrollController, נבדוק לפי מספר הטאבים - final needsScrolling = widget.tabs.length > 4; - if (mounted) { - setState(() { - _canScrollLeft = needsScrolling; - _canScrollRight = needsScrolling; - }); - } - return; + void _detachPositionListener() { + _tabBarPosition?.removeListener(_onPositionChanged); + } + + void _attachAndSyncPosition() { + if (!mounted || _scrollContext == null) return; + _adoptPositionFrom(_scrollContext!); + } + + void _adoptPositionFrom(BuildContext ctx) { + final state = Scrollable.of(ctx); + final newPos = state?.position; + if (newPos == null) return; + // נאמץ רק ScrollPosition אופקי, כדי לא לגלול את כל המסך בטעות + final isHorizontal = newPos.axisDirection == AxisDirection.left || + newPos.axisDirection == AxisDirection.right; + if (!isHorizontal) return; + if (!identical(newPos, _tabBarPosition)) { + _detachPositionListener(); + _tabBarPosition = newPos; + _tabBarPosition!.addListener(_onPositionChanged); } + _onPositionChanged(); + } - if (mounted) { + void _onPositionChanged() { + final pos = _tabBarPosition; + if (pos == null) return; + final canLeft = pos.pixels > pos.minScrollExtent + 0.5; + final canRight = pos.pixels < pos.maxScrollExtent - 0.5; + if (_canScrollLeft != canLeft || _canScrollRight != canRight) { setState(() { - _canScrollLeft = _scrollController.offset > 0; - _canScrollRight = _scrollController.offset < - _scrollController.position.maxScrollExtent; + _canScrollLeft = canLeft; + _canScrollRight = canRight; }); } } - void _scrollLeft() { - if (_scrollController.hasClients) { - final newOffset = (_scrollController.offset - 150).clamp( - 0.0, - _scrollController.position.maxScrollExtent, - ); - _scrollController.animateTo( - newOffset, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); + void _handleScrollMetrics(ScrollMetrics metrics) { + final canLeft = metrics.pixels > metrics.minScrollExtent + 0.5; + final canRight = metrics.pixels < metrics.maxScrollExtent - 0.5; + if (_canScrollLeft != canLeft || _canScrollRight != canRight) { + setState(() { + _canScrollLeft = canLeft; + _canScrollRight = canRight; + }); } } - void _scrollRight() { - if (_scrollController.hasClients) { - final newOffset = (_scrollController.offset + 150).clamp( - 0.0, - _scrollController.position.maxScrollExtent, - ); - _scrollController.animateTo( - newOffset, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } + void _scrollBy(double delta) { + final pos = _tabBarPosition; + if (pos == null) return; + final target = (pos.pixels + delta) + .clamp(pos.minScrollExtent, pos.maxScrollExtent); + pos.animateTo( + target, + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + ); } + void _scrollLeft() => _scrollBy(-150); + void _scrollRight() => _scrollBy(150); + @override Widget build(BuildContext context) { return Row( @@ -109,18 +115,48 @@ class _ScrollableTabBarWithArrowsState tooltip: 'גלול שמאלה', ), ), - // TabBar עם ScrollController מותאם אישית + // TabBar במצב isScrollable כדי לייצר גלילה רוחבית פנימית Expanded( - child: SingleChildScrollView( - controller: _scrollController, - scrollDirection: Axis.horizontal, - physics: const BouncingScrollPhysics(), - child: IntrinsicWidth( - child: TabBar( - controller: widget.controller, - isScrollable: false, // כבה את הגלילה הפנימית של TabBar - tabs: widget.tabs, - indicatorSize: TabBarIndicatorSize.tab, + child: NotificationListener( + onNotification: (metricsNotification) { + final metrics = metricsNotification.metrics; + // נאתחל אוטומטית מיד כשהמידות זמינות (גם בלי גלילה ידנית) + if (metrics.axis == Axis.horizontal) { + final ctx = metricsNotification.context; + if (ctx != null) _adoptPositionFrom(ctx); + _handleScrollMetrics(metrics); + } + return false; + }, + child: NotificationListener( + onNotification: (notification) { + if (notification.metrics.axis == Axis.horizontal) { + // לאמץ את ה-ScrollPosition האופקי מתוך ההודעה + final ctx = notification.context; + if (ctx != null) { + _adoptPositionFrom(ctx); + } + _handleScrollMetrics(notification.metrics); + } + return false; + }, + child: Builder( + builder: (scrollCtx) { + // נשמור הקשר פנימי כדי לאתחל את ה-ScrollPosition אחרי הפריים + if (!identical(_scrollContext, scrollCtx)) { + _scrollContext = scrollCtx; + WidgetsBinding.instance.addPostFrameCallback((_) { + _attachAndSyncPosition(); + }); + } + return TabBar( + controller: widget.controller, + isScrollable: true, + tabs: widget.tabs, + indicatorSize: TabBarIndicatorSize.tab, + tabAlignment: widget.tabAlignment, + ); + }, ), ), ), From 692f492c78fd2a81f9a3d9698341b0354ba58951 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 9 Sep 2025 19:54:25 +0300 Subject: [PATCH 173/197] =?UTF-8?q?=D7=94=D7=A8=D7=97=D7=91=D7=AA=20=D7=90?= =?UTF-8?q?=D7=96=D7=95=D7=A8=20=D7=94=D7=98=D7=90=D7=91=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/tabs/reading_screen.dart | 42 ++++++++---- lib/widgets/scrollable_tab_bar.dart | 99 +++++++++++++++++++---------- 2 files changed, 97 insertions(+), 44 deletions(-) diff --git a/lib/tabs/reading_screen.dart b/lib/tabs/reading_screen.dart index 524e8df11..201c8385d 100644 --- a/lib/tabs/reading_screen.dart +++ b/lib/tabs/reading_screen.dart @@ -21,7 +21,7 @@ import 'package:otzaria/workspaces/view/workspace_switcher_dialog.dart'; import 'package:otzaria/history/history_dialog.dart'; import 'package:otzaria/bookmarks/bookmarks_dialog.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; -import 'package:otzaria/widgets/workspace_icon_button.dart'; +// הוסר: WorkspaceIconButton (עברנו ל-IconButton פשוט) import 'package:otzaria/widgets/scrollable_tab_bar.dart'; class ReadingScreen extends StatefulWidget { @@ -31,10 +31,12 @@ class ReadingScreen extends StatefulWidget { State createState() => _ReadingScreenState(); } -const double _kAppBarControlsWidth = 280.0; +const double _kAppBarControlsWidth = 125.0; class _ReadingScreenState extends State with TickerProviderStateMixin, WidgetsBindingObserver { + // האם יש אוברפלואו בטאבים (גלילה)? משמש לקביעת placeholder לדינמיות מרכוז/התפרשות + bool _tabsOverflow = false; @override void initState() { super.initState(); @@ -187,30 +189,48 @@ class _ReadingScreenState extends State margin: const EdgeInsets.symmetric(horizontal: 2), ), // קבוצת שולחן עבודה עם אנימציה - SizedBox( - width: 180, - child: WorkspaceIconButton( - onPressed: () => _showSaveWorkspaceDialog(context), - ), + IconButton( + icon: const Icon(Icons.add_to_queue), + tooltip: 'החלף שולחן עבודה', + onPressed: () => _showSaveWorkspaceDialog(context), ), ], ), + titleSpacing: 0, + centerTitle: true, title: Container( constraints: const BoxConstraints(maxHeight: 50), child: ScrollableTabBarWithArrows( controller: controller, tabAlignment: TabAlignment.center, + onOverflowChanged: (overflow) { + if (mounted) { + setState(() => _tabsOverflow = overflow); + } + }, tabs: state.tabs .map((tab) => _buildTab(context, tab, state)) .toList(), ), ), - centerTitle: true, + flexibleSpace: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + ), + ), + // כאשר אין גלילה — מוסיפים placeholder משמאל כדי למרכז באמת ביחס למסך כולו + // כאשר יש גלילה — מבטלים אותו כדי לאפשר התפשטות גם לצד שמאל + // שומרים תמיד מקום קבוע לימין כדי למנוע שינויי רוחב פתאומיים + actions: const [SizedBox(width: _kAppBarControlsWidth)], + // centerTitle לא נדרש כאשר הטאבים נמצאים ב-bottom // 2. משתמשים באותו קבוע בדיוק עבור ווידג'ט הדמה - actions: const [ - SizedBox(width: _kAppBarControlsWidth), - ], + // הוסר הרווח המלאכותי מצד שמאל כדי לאפשר לטאבים לתפוס רוחב מלא בעת הצורך ), body: SizedBox.fromSize( size: MediaQuery.of(context).size, diff --git a/lib/widgets/scrollable_tab_bar.dart b/lib/widgets/scrollable_tab_bar.dart index fd0477f19..ce1be9182 100644 --- a/lib/widgets/scrollable_tab_bar.dart +++ b/lib/widgets/scrollable_tab_bar.dart @@ -1,16 +1,19 @@ import 'package:flutter/material.dart'; -/// TabBar הניתן לגלילה עם חיצים (בהשראת אקסל). +/// TabBar גלילה עם חיצים לשמאל/ימין. class ScrollableTabBarWithArrows extends StatefulWidget { final TabController controller; final List tabs; final TabAlignment? tabAlignment; + // מאפשר לדעת אם יש גלילה אופקית (יש Overflow) + final ValueChanged? onOverflowChanged; const ScrollableTabBarWithArrows({ super.key, required this.controller, required this.tabs, this.tabAlignment, + this.onOverflowChanged, }); @override @@ -20,11 +23,12 @@ class ScrollableTabBarWithArrows extends StatefulWidget { class _ScrollableTabBarWithArrowsState extends State { - // נשתמש ב־ScrollPosition הפנימי של ה-TabBar (isScrollable:true) + // נאתר את ה-ScrollPosition של ה-TabBar (isScrollable:true) ScrollPosition? _tabBarPosition; BuildContext? _scrollContext; bool _canScrollLeft = false; bool _canScrollRight = false; + bool? _lastOverflow; @override void dispose() { @@ -45,7 +49,7 @@ class _ScrollableTabBarWithArrowsState final state = Scrollable.of(ctx); final newPos = state?.position; if (newPos == null) return; - // נאמץ רק ScrollPosition אופקי, כדי לא לגלול את כל המסך בטעות + // וידוא שמדובר בציר אופקי final isHorizontal = newPos.axisDirection == AxisDirection.left || newPos.axisDirection == AxisDirection.right; if (!isHorizontal) return; @@ -67,6 +71,7 @@ class _ScrollableTabBarWithArrowsState _canScrollLeft = canLeft; _canScrollRight = canRight; }); + _emitOverflowIfChanged(); } } @@ -78,14 +83,23 @@ class _ScrollableTabBarWithArrowsState _canScrollLeft = canLeft; _canScrollRight = canRight; }); + _emitOverflowIfChanged(); + } + } + + void _emitOverflowIfChanged() { + final overflow = _canScrollLeft || _canScrollRight; + if (_lastOverflow != overflow) { + _lastOverflow = overflow; + widget.onOverflowChanged?.call(overflow); } } void _scrollBy(double delta) { final pos = _tabBarPosition; if (pos == null) return; - final target = (pos.pixels + delta) - .clamp(pos.minScrollExtent, pos.maxScrollExtent); + final target = + (pos.pixels + delta).clamp(pos.minScrollExtent, pos.maxScrollExtent); pos.animateTo( target, duration: const Duration(milliseconds: 250), @@ -100,27 +114,34 @@ class _ScrollableTabBarWithArrowsState Widget build(BuildContext context) { return Row( children: [ - // חץ שמאל - AnimatedOpacity( - opacity: _canScrollLeft ? 1.0 : 0.3, - duration: const Duration(milliseconds: 200), - child: IconButton( - onPressed: _canScrollLeft ? _scrollLeft : null, - icon: const Icon(Icons.chevron_left), - iconSize: 20, - constraints: const BoxConstraints( - minWidth: 32, - minHeight: 32, + // חץ שמאלי – משמרים מקום קבוע כדי למנוע קפיצות ברוחב + SizedBox( + width: 36, + height: 32, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _canScrollLeft ? 1.0 : 0.0, + child: IgnorePointer( + ignoring: !_canScrollLeft, + child: IconButton( + key: const ValueKey('left-arrow'), + onPressed: _scrollLeft, + icon: const Icon(Icons.chevron_left), + iconSize: 20, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + tooltip: 'גלול שמאלה', + ), ), - tooltip: 'גלול שמאלה', ), ), - // TabBar במצב isScrollable כדי לייצר גלילה רוחבית פנימית + // TabBar עם isScrollable – לוכדים נוטיפיקציות כדי לדעת אם יש Overflow Expanded( child: NotificationListener( onNotification: (metricsNotification) { final metrics = metricsNotification.metrics; - // נאתחל אוטומטית מיד כשהמידות זמינות (גם בלי גלילה ידנית) if (metrics.axis == Axis.horizontal) { final ctx = metricsNotification.context; if (ctx != null) _adoptPositionFrom(ctx); @@ -131,7 +152,6 @@ class _ScrollableTabBarWithArrowsState child: NotificationListener( onNotification: (notification) { if (notification.metrics.axis == Axis.horizontal) { - // לאמץ את ה-ScrollPosition האופקי מתוך ההודעה final ctx = notification.context; if (ctx != null) { _adoptPositionFrom(ctx); @@ -142,7 +162,7 @@ class _ScrollableTabBarWithArrowsState }, child: Builder( builder: (scrollCtx) { - // נשמור הקשר פנימי כדי לאתחל את ה-ScrollPosition אחרי הפריים + // נשמור context כדי לאמץ את ה-ScrollPosition לאחר הבניה if (!identical(_scrollContext, scrollCtx)) { _scrollContext = scrollCtx; WidgetsBinding.instance.addPostFrameCallback((_) { @@ -155,25 +175,38 @@ class _ScrollableTabBarWithArrowsState tabs: widget.tabs, indicatorSize: TabBarIndicatorSize.tab, tabAlignment: widget.tabAlignment, + padding: EdgeInsets.zero, + // לא רוצים קו מפריד מתחת ל-TabBar בתוך ה-AppBar + dividerColor: Colors.transparent, + // הזזת האינדיקטור מעט, כדי שייראה נקי ב-AppBar + indicatorPadding: const EdgeInsets.only(bottom: -0), ); }, ), ), ), ), - // חץ ימין - AnimatedOpacity( - opacity: _canScrollRight ? 1.0 : 0.3, - duration: const Duration(milliseconds: 200), - child: IconButton( - onPressed: _canScrollRight ? _scrollRight : null, - icon: const Icon(Icons.chevron_right), - iconSize: 20, - constraints: const BoxConstraints( - minWidth: 32, - minHeight: 32, + // חץ ימני – משמרים מקום קבוע כדי למנוע קפיצות ברוחב + SizedBox( + width: 36, + height: 32, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _canScrollRight ? 1.0 : 0.0, + child: IgnorePointer( + ignoring: !_canScrollRight, + child: IconButton( + key: const ValueKey('right-arrow'), + onPressed: _scrollRight, + icon: const Icon(Icons.chevron_right), + iconSize: 20, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + tooltip: 'גלול ימינה', + ), ), - tooltip: 'גלול ימינה', ), ), ], From b05d83ef7eb6fb94d8b5c904d7604e0104c37844 Mon Sep 17 00:00:00 2001 From: Sivan Ratson <89018301+Sivan22@users.noreply.github.com> Date: Tue, 9 Sep 2025 20:42:28 +0300 Subject: [PATCH 174/197] Update flutter.yml --- .github/workflows/flutter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index 1a3bb91ab..95cc066ba 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -20,7 +20,7 @@ on: jobs: build_windows: - runs-on: windows-latest + runs-on: windows-2022 steps: - name: Clone repository uses: actions/checkout@v4 From 30349abb0078c98d03c01131d849a08da3c4a103 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Tue, 9 Sep 2025 23:05:10 +0300 Subject: [PATCH 175/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=95?= =?UTF-8?q?=D7=A9=D7=99=D7=A4=D7=95=D7=A8=20=D7=93=D7=99=D7=95=D7=95=D7=97?= =?UTF-8?q?=20=D7=94=D7=A9=D7=92=D7=99=D7=90=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/text_book/view/text_book_screen.dart | 182 +++++++++++++++++++---- lib/widgets/phone_report_tab.dart | 81 +++++++--- 2 files changed, 211 insertions(+), 52 deletions(-) diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index 3282597fb..12b82825a 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -110,6 +110,62 @@ class _TextBookViewerBlocState extends State } } + // Build 4+4 words context around a selection range within fullText + String _buildContextAroundSelection( + String fullText, + int selectionStart, + int selectionEnd, { + int wordsBefore = 4, + int wordsAfter = 4, + }) { + if (selectionStart < 0 || selectionEnd <= selectionStart) { + return fullText; + } + final wordRegex = RegExp("\\S+", multiLine: true); + final matches = wordRegex.allMatches(fullText).toList(); + if (matches.isEmpty) return fullText; + + int startWordIndex = 0; + int endWordIndex = matches.length - 1; + + for (int i = 0; i < matches.length; i++) { + final m = matches[i]; + if (selectionStart >= m.start && selectionStart < m.end) { + startWordIndex = i; + break; + } + if (selectionStart < m.start) { + startWordIndex = i; + break; + } + } + + for (int i = matches.length - 1; i >= 0; i--) { + final m = matches[i]; + final selEndMinusOne = selectionEnd - 1; + if (selEndMinusOne >= m.start && selEndMinusOne < m.end) { + endWordIndex = i; + break; + } + if (selEndMinusOne > m.end) { + endWordIndex = i; + break; + } + } + + final ctxStart = (startWordIndex - wordsBefore) < 0 + ? 0 + : (startWordIndex - wordsBefore); + final ctxEnd = (endWordIndex + wordsAfter) >= matches.length + ? matches.length - 1 + : (endWordIndex + wordsAfter); + + final from = matches[ctxStart].start; + final to = matches[ctxEnd].end; + if (from < 0 || to <= from || to > fullText.length) return fullText; + return fullText.substring(from, to); + } + @override void initState() { super.initState(); @@ -613,9 +669,35 @@ class _TextBookViewerBlocState extends State // Get the heavy data that was loaded in background final heavyData = await _getPreloadedHeavyData(state); + // Compute accurate line number and 4+4 words context based on selection + final baseLineNumber = _getCurrentLineNumber(); + final selectionStart = visibleText.indexOf(result.selectedText); + int computedLineNumber = baseLineNumber; + if (selectionStart >= 0) { + final before = visibleText.substring(0, selectionStart); + final offset = '\n'.allMatches(before).length; + computedLineNumber = baseLineNumber + offset; + } + final safeStart = selectionStart >= 0 ? selectionStart : 0; + final safeEnd = safeStart + result.selectedText.length; + final contextText = _buildContextAroundSelection( + visibleText, + safeStart, + safeEnd, + wordsBefore: 4, + wordsAfter: 4, + ); + // Handle regular report actions - await _handleRegularReportAction(action, result, state, - heavyData['currentRef'], heavyData['bookDetails']); + await _handleRegularReportAction( + action, + result, + state, + heavyData['currentRef'], + heavyData['bookDetails'], + computedLineNumber, + contextText, + ); } else if (result is PhoneReportData) { // Phone report - handle directly await _handlePhoneReport(result); @@ -756,8 +838,15 @@ class _TextBookViewerBlocState extends State Map bookDetails, String selectedText, String errorDetails, + int lineNumber, + String contextText, ) { - final detailsSection = errorDetails.isEmpty ? '' : '\n$errorDetails'; + final detailsSection = (() { + final base = errorDetails.isEmpty ? '' : '\n$errorDetails'; + final extra = '\n\nמספר שורה: ' + lineNumber.toString() + + '\nהקשר (4 מילים לפני ואחרי):\n' + contextText; + return base + extra; + })(); return ''' שם הספר: $bookTitle @@ -781,6 +870,8 @@ $detailsSection TextBookLoaded state, String currentRef, Map bookDetails, + int lineNumber, + String contextText, ) async { final emailBody = _buildEmailBody( state.book.title, @@ -788,6 +879,8 @@ $detailsSection bookDetails, reportData.selectedText, reportData.errorDetails, + lineNumber, + contextText, ); if (action == ReportAction.sendEmail) { @@ -1585,6 +1678,8 @@ class _RegularReportTab extends StatefulWidget { class _RegularReportTabState extends State<_RegularReportTab> { String? _selectedContent; final TextEditingController _detailsController = TextEditingController(); + int? _selectionStart; + int? _selectionEnd; @override void initState() { @@ -1614,32 +1709,63 @@ class _RegularReportTabState extends State<_RegularReportTab> { child: Container( padding: const EdgeInsets.all(8), child: SingleChildScrollView( - child: SelectableText( - widget.visibleText, - style: TextStyle( - fontSize: widget.fontSize, - fontFamily: - Settings.getValue('key-font-family') ?? 'candara', + child: Builder( + builder: (context) => TextSelectionTheme( + data: const TextSelectionThemeData( + selectionColor: Colors.transparent, + ), + child: SelectableText.rich( + TextSpan( + children: () { + final text = widget.visibleText; + final start = _selectionStart ?? -1; + final end = _selectionEnd ?? -1; + final hasSel = start >= 0 && end > start && end <= text.length; + if (!hasSel) { + return [TextSpan(text: text)]; + } + final highlight = Theme.of(context) + .colorScheme + .primary + .withOpacity(0.25); + return [ + if (start > 0) TextSpan(text: text.substring(0, start)), + TextSpan( + text: text.substring(start, end), + style: TextStyle(backgroundColor: highlight), + ), + if (end < text.length) TextSpan(text: text.substring(end)), + ]; + }(), + style: TextStyle( + fontSize: widget.fontSize, + fontFamily: + Settings.getValue('key-font-family') ?? 'candara', + ), + ), + textAlign: TextAlign.right, + textDirection: TextDirection.rtl, + onSelectionChanged: (selection, cause) { + if (selection.start != selection.end) { + final newContent = widget.visibleText.substring( + selection.start, + selection.end, + ); + if (newContent.isNotEmpty) { + setState(() { + _selectedContent = newContent; + _selectionStart = selection.start; + _selectionEnd = selection.end; + }); + widget.onTextSelected(newContent); + } + } + }, + contextMenuBuilder: (context, editableTextState) { + return const SizedBox.shrink(); + }, + ), ), - textAlign: TextAlign.right, - textDirection: TextDirection.rtl, - onSelectionChanged: (selection, cause) { - if (selection.start != selection.end) { - final newContent = widget.visibleText.substring( - selection.start, - selection.end, - ); - if (newContent.isNotEmpty) { - setState(() { - _selectedContent = newContent; - }); - widget.onTextSelected(newContent); - } - } - }, - contextMenuBuilder: (context, editableTextState) { - return const SizedBox.shrink(); - }, ), ), ), diff --git a/lib/widgets/phone_report_tab.dart b/lib/widgets/phone_report_tab.dart index 9390aa4e2..c53785894 100644 --- a/lib/widgets/phone_report_tab.dart +++ b/lib/widgets/phone_report_tab.dart @@ -38,6 +38,8 @@ class _PhoneReportTabState extends State { bool _isSubmitting = false; late int _updatedLineNumber; + int? _selectionStart; + int? _selectionEnd; @override void initState() { @@ -159,20 +161,47 @@ class _PhoneReportTabState extends State { borderRadius: BorderRadius.circular(8), ), child: SingleChildScrollView( - child: SelectableText( - widget.visibleText, - style: TextStyle( - fontSize: widget.fontSize, - fontFamily: Settings.getValue('key-font-family') ?? 'candara', - ), - textAlign: TextAlign.right, - textDirection: TextDirection.rtl, - onSelectionChanged: (selection, cause) { - if (selection.start != selection.end) { - final selectedText = widget.visibleText.substring( - selection.start, - selection.end, - ); + child: Builder( + builder: (context) => TextSelectionTheme( + data: const TextSelectionThemeData( + selectionColor: Colors.transparent, + ), + child: SelectableText.rich( + TextSpan( + children: () { + final text = widget.visibleText; + final start = _selectionStart ?? -1; + final end = _selectionEnd ?? -1; + final hasSel = start >= 0 && end > start && end <= text.length; + if (!hasSel) { + return [TextSpan(text: text)]; + } + final highlight = Theme.of(context) + .colorScheme + .primary + .withOpacity(0.25); + return [ + if (start > 0) TextSpan(text: text.substring(0, start)), + TextSpan( + text: text.substring(start, end), + style: TextStyle(backgroundColor: highlight), + ), + if (end < text.length) TextSpan(text: text.substring(end)), + ]; + }(), + style: TextStyle( + fontSize: widget.fontSize, + fontFamily: Settings.getValue('key-font-family') ?? 'candara', + ), + ), + textAlign: TextAlign.right, + textDirection: TextDirection.rtl, + onSelectionChanged: (selection, cause) { + if (selection.start != selection.end) { + final selectedText = widget.visibleText.substring( + selection.start, + selection.end, + ); // חישוב מספר השורה על בסיס הטקסט הנבחר final textBeforeSelection = @@ -181,19 +210,23 @@ class _PhoneReportTabState extends State { '\n'.allMatches(textBeforeSelection).length; final newLineNumber = widget.lineNumber + lineOffset; - if (selectedText.isNotEmpty) { - setState(() { - _selectedText = selectedText; - _updatedLineNumber = newLineNumber; - }); - } + if (selectedText.isNotEmpty) { + setState(() { + _selectedText = selectedText; + _selectionStart = selection.start; + _selectionEnd = selection.end; + _updatedLineNumber = newLineNumber; + }); + } } - }, - contextMenuBuilder: (context, editableTextState) { - return const SizedBox.shrink(); - }, + }, + contextMenuBuilder: (context, editableTextState) { + return const SizedBox.shrink(); + }, + ), ), ), + ), ), if (_selectedText != null && _selectedText!.isNotEmpty) ...[ const SizedBox(height: 8), From fcad39036f414bb9ee706d6425658f09c04d76a5 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 10 Sep 2025 11:51:39 +0300 Subject: [PATCH 176/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=94?= =?UTF-8?q?=D7=9B=D7=95=D7=AA=D7=A8=D7=AA=20=D7=91=D7=A4=D7=AA=D7=99=D7=97?= =?UTF-8?q?=D7=AA=20=D7=A7=D7=95=D7=91=D7=A5=20=D7=98=D7=A7=D7=A1=D7=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/text_book/bloc/text_book_bloc.dart | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/text_book/bloc/text_book_bloc.dart b/lib/text_book/bloc/text_book_bloc.dart index d90d8b76f..5f387b026 100644 --- a/lib/text_book/bloc/text_book_bloc.dart +++ b/lib/text_book/bloc/text_book_bloc.dart @@ -61,6 +61,16 @@ class TextBookBloc extends Bloc { final content = await _repository.getBookContent(book); final links = await _repository.getBookLinks(book); final tableOfContents = await _repository.getTableOfContents(book); + // Pre-compute initial current title (location) so it appears immediately on open + String? initialTitle; + try { + initialTitle = await refFromIndex( + initial.index, + Future.value(tableOfContents), + ); + } catch (_) { + initialTitle = null; + } final availableCommentators = await _repository.getAvailableCommentators(links); // ממיינים את רשימת המפרשים לקבוצות לפי תקופה @@ -123,6 +133,7 @@ class TextBookBloc extends Bloc { searchText: searchText, scrollController: scrollController, positionsListener: positionsListener, + currentTitle: initialTitle, showNotesSidebar: false, selectedTextForNote: null, selectedTextStart: null, From dd0aa2e84c7036abfc4cd9c3f364f3ae5077806f Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 10 Sep 2025 12:52:32 +0300 Subject: [PATCH 177/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=9C?= =?UTF-8?q?=D7=95=D7=97=20=D7=94=D7=A9=D7=A0=D7=94=20=D7=9C=D7=9E=D7=A6?= =?UTF-8?q?=D7=91=20=D7=9B=D7=94=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/navigation/calendar_widget.dart | 125 ++++++++++++++++++++-------- 1 file changed, 92 insertions(+), 33 deletions(-) diff --git a/lib/navigation/calendar_widget.dart b/lib/navigation/calendar_widget.dart index 31ac8ca60..e93fd4d50 100644 --- a/lib/navigation/calendar_widget.dart +++ b/lib/navigation/calendar_widget.dart @@ -212,7 +212,7 @@ class CalendarWidget extends StatelessWidget { day, textAlign: TextAlign.center, style: TextStyle( - color: Colors.grey[600], + color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12, fontWeight: FontWeight.w500, ), @@ -239,7 +239,7 @@ class CalendarWidget extends StatelessWidget { day, textAlign: TextAlign.center, style: TextStyle( - color: Colors.grey[600], + color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12, fontWeight: FontWeight.w500, ), @@ -290,7 +290,7 @@ class CalendarWidget extends StatelessWidget { '${state.selectedGregorianDate.day} ${_getGregorianMonthName(state.selectedGregorianDate.month)} ${state.selectedGregorianDate.year}', style: TextStyle( fontSize: 16, - color: Colors.grey[600], + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], @@ -399,15 +399,23 @@ class CalendarWidget extends StatelessWidget { height: 80, decoration: BoxDecoration( color: isSelected - ? Theme.of(context).primaryColor + ? Theme.of(context).colorScheme.primaryContainer : isToday - ? Theme.of(context).primaryColor.withAlpha(76) - : Theme.of(context).primaryColor.withAlpha(10), + ? Theme.of(context) + .colorScheme + .primary + .withOpacity(0.25) + : Theme.of(context) + .colorScheme + .surfaceContainer + .withOpacity(0.2), borderRadius: BorderRadius.circular(8), border: Border.all( - color: isToday - ? Theme.of(context).primaryColor - : Theme.of(context).primaryColor.withAlpha(102), + color: isSelected + ? Theme.of(context).colorScheme.primary + : isToday + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outlineVariant, width: isToday ? 2 : 1, ), ), @@ -418,7 +426,9 @@ class CalendarWidget extends StatelessWidget { Text( '${dayDate.day}', style: TextStyle( - color: isSelected ? Colors.white : Colors.black, + color: isSelected + ? Theme.of(context).colorScheme.onPrimaryContainer + : Theme.of(context).colorScheme.onSurface, fontWeight: isSelected || isToday ? FontWeight.bold : FontWeight.normal, @@ -429,7 +439,12 @@ class CalendarWidget extends StatelessWidget { Text( _formatHebrewDay(jewishDate.getJewishDayOfMonth()), style: TextStyle( - color: isSelected ? Colors.white70 : Colors.grey[600], + color: isSelected + ? Theme.of(context) + .colorScheme + .onPrimaryContainer + .withOpacity(0.85) + : Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12, ), ), @@ -472,14 +487,23 @@ class CalendarWidget extends StatelessWidget { height: 50, decoration: BoxDecoration( color: isSelected - ? Theme.of(context).primaryColor + ? Theme.of(context).colorScheme.primaryContainer : isToday - ? Theme.of(context).primaryColor.withAlpha(76) - : Theme.of(context).primaryColor.withAlpha(10), + ? Theme.of(context) + .colorScheme + .primary + .withOpacity(0.25) + : Theme.of(context) + .colorScheme + .surfaceContainer + .withOpacity(0.2), borderRadius: BorderRadius.circular(8), border: Border.all( - color: - isToday ? Theme.of(context).primaryColor : Theme.of(context).primaryColor.withAlpha(102), + color: isSelected + ? Theme.of(context).colorScheme.primary + : isToday + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outlineVariant, width: isToday ? 2 : 1, ), ), @@ -490,7 +514,9 @@ class CalendarWidget extends StatelessWidget { Text( _formatHebrewDay(day), style: TextStyle( - color: isSelected ? Colors.white : Colors.black, + color: isSelected + ? Theme.of(context).colorScheme.onPrimaryContainer + : Theme.of(context).colorScheme.onSurface, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, fontSize: state.calendarType == CalendarType.combined ? 14 : 16, @@ -500,7 +526,12 @@ class CalendarWidget extends StatelessWidget { Text( '${gregorianDate.day}', style: TextStyle( - color: isSelected ? Colors.white70 : Colors.grey[600], + color: isSelected + ? Theme.of(context) + .colorScheme + .onPrimaryContainer + .withOpacity(0.85) + : Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 10, ), ), @@ -533,14 +564,23 @@ class CalendarWidget extends StatelessWidget { height: 50, decoration: BoxDecoration( color: isSelected - ? Theme.of(context).primaryColor + ? Theme.of(context).colorScheme.primaryContainer : isToday - ? Theme.of(context).primaryColor.withAlpha(76) - : Theme.of(context).primaryColor.withAlpha(10), + ? Theme.of(context) + .colorScheme + .primary + .withOpacity(0.25) + : Theme.of(context) + .colorScheme + .surfaceContainer + .withOpacity(0.2), borderRadius: BorderRadius.circular(8), border: Border.all( - color: - isToday ? Theme.of(context).primaryColor : Theme.of(context).primaryColor.withAlpha(102), + color: isSelected + ? Theme.of(context).colorScheme.primary + : isToday + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outlineVariant, width: isToday ? 2 : 1, ), ), @@ -551,7 +591,9 @@ class CalendarWidget extends StatelessWidget { Text( '$day', style: TextStyle( - color: isSelected ? Colors.white : Colors.black, + color: isSelected + ? Theme.of(context).colorScheme.onPrimaryContainer + : Theme.of(context).colorScheme.onSurface, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, fontSize: state.calendarType == CalendarType.combined ? 14 : 16, @@ -561,7 +603,12 @@ class CalendarWidget extends StatelessWidget { Text( _formatHebrewDay(jewishDate.getJewishDayOfMonth()), style: TextStyle( - color: isSelected ? Colors.white70 : Colors.grey[600], + color: isSelected + ? Theme.of(context) + .colorScheme + .onPrimaryContainer + .withOpacity(0.85) + : Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 10, ), ), @@ -611,7 +658,9 @@ class CalendarWidget extends StatelessWidget { const SizedBox(height: 4), Text( gregorianDateStr, - style: TextStyle(fontSize: 16, color: Colors.grey[600]), + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant), ), ], ), @@ -804,6 +853,9 @@ class CalendarWidget extends StatelessWidget { final filteredTimesList = timesList.where((timeData) => timeData['time'] != null).toList(); + final scheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + return GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), @@ -817,16 +869,23 @@ class CalendarWidget extends StatelessWidget { itemBuilder: (context, index) { final timeData = filteredTimesList[index]; final isSpecialTime = _isSpecialTime(timeData['name']!); + final bgColor = isSpecialTime + ? scheme.tertiaryContainer + : scheme.surfaceVariant; + final border = isSpecialTime + ? Border.all(color: scheme.tertiary, width: 1) + : null; + final titleColor = + isSpecialTime ? scheme.onTertiaryContainer : scheme.onSurfaceVariant; + final timeColor = + isSpecialTime ? scheme.onTertiaryContainer : scheme.onSurface; return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: - isSpecialTime ? Colors.orange.withAlpha(51) : Colors.grey[100], + color: bgColor, borderRadius: BorderRadius.circular(8), - border: isSpecialTime - ? Border.all(color: Colors.orange, width: 1) - : null, + border: border, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -837,7 +896,7 @@ class CalendarWidget extends StatelessWidget { style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, - color: isSpecialTime ? Colors.orange.shade800 : null, + color: titleColor, ), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -848,7 +907,7 @@ class CalendarWidget extends StatelessWidget { style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, - color: isSpecialTime ? Colors.orange.shade800 : null, + color: timeColor, ), ), ], From cfaf12e73c95e903bfa7274dde8194044148f1e9 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 11 Sep 2025 16:25:31 +0300 Subject: [PATCH 178/197] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20=D7=92?= =?UTF-8?q?=D7=93=D7=95=D7=9C=20=D7=9C=D7=9C=D7=95=D7=97=20=D7=94=D7=A9?= =?UTF-8?q?=D7=A0=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/navigation/calendar_cubit.dart | 241 +++++- lib/navigation/calendar_widget.dart | 1018 +++++++++++++++++-------- lib/settings/settings_repository.dart | 10 + 3 files changed, 945 insertions(+), 324 deletions(-) diff --git a/lib/navigation/calendar_cubit.dart b/lib/navigation/calendar_cubit.dart index fa5c09e7c..10b152168 100644 --- a/lib/navigation/calendar_cubit.dart +++ b/lib/navigation/calendar_cubit.dart @@ -2,6 +2,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:kosher_dart/kosher_dart.dart'; import 'package:otzaria/settings/settings_repository.dart'; +import 'dart:convert'; enum CalendarType { hebrew, gregorian, combined } @@ -17,6 +18,7 @@ class CalendarState extends Equatable { final DateTime currentGregorianDate; final CalendarType calendarType; final CalendarView calendarView; + final List events; const CalendarState({ required this.selectedJewishDate, @@ -27,6 +29,7 @@ class CalendarState extends Equatable { required this.currentGregorianDate, required this.calendarType, required this.calendarView, + this.events = const [], }); factory CalendarState.initial() { @@ -55,6 +58,7 @@ class CalendarState extends Equatable { DateTime? currentGregorianDate, CalendarType? calendarType, CalendarView? calendarView, + List? events, }) { return CalendarState( selectedJewishDate: selectedJewishDate ?? this.selectedJewishDate, @@ -66,6 +70,7 @@ class CalendarState extends Equatable { currentGregorianDate: currentGregorianDate ?? this.currentGregorianDate, calendarType: calendarType ?? this.calendarType, calendarView: calendarView ?? this.calendarView, + events: events ?? this.events, ); } @@ -78,6 +83,8 @@ class CalendarState extends Equatable { selectedGregorianDate, selectedCity, dailyTimes, + // events – ensure rebuild on changes + events, // "פירקנו" גם את התאריך של תצוגת החודש currentJewishDate.getJewishYear(), @@ -105,10 +112,22 @@ class CalendarCubit extends Cubit { final calendarTypeString = settings['calendarType'] as String; final calendarType = _stringToCalendarType(calendarTypeString); final selectedCity = settings['selectedCity'] as String; + final eventsJson = settings['calendarEvents'] as String; + + // טעינת אירועים מהאחסון + List events = []; + try { + final List eventsList = jsonDecode(eventsJson); + events = eventsList.map((eventMap) => CustomEvent.fromJson(eventMap)).toList(); + } catch (e) { + // אם יש שגיאה בטעינה, נתחיל עם רשימה ריקה + events = []; + } emit(state.copyWith( calendarType: calendarType, selectedCity: selectedCity, + events: events, )); _updateTimesForDate(state.selectedGregorianDate, selectedCity); } @@ -148,16 +167,12 @@ class CalendarCubit extends Cubit { final current = state.currentJewishDate; final newJewishDate = JewishDate(); if (current.getJewishMonth() == 1) { - // אם אנחנו בניסן (חודש 1), עוברים לאדר של השנה הקודמת - // אבל השנה לא משתנה כי השנה מתחלפת בתשרי newJewishDate.setJewishDate( current.getJewishYear(), 12, 1, ); } else if (current.getJewishMonth() == 7) { - // אם אנחנו בתשרי (חודש 7), עוברים לאלול של השנה הקודמת - // כאן כן משתנה השנה כי תשרי הוא תחילת השנה newJewishDate.setJewishDate( current.getJewishYear() - 1, 6, @@ -185,16 +200,12 @@ class CalendarCubit extends Cubit { final current = state.currentJewishDate; final newJewishDate = JewishDate(); if (current.getJewishMonth() == 12) { - // אם אנחנו באדר (חודש 12), עוברים לניסן של אותה שנה - // השנה לא משתנה כי השנה מתחלפת בתשרי newJewishDate.setJewishDate( current.getJewishYear(), 1, 1, ); } else if (current.getJewishMonth() == 6) { - // אם אנחנו באלול (חודש 6), עוברים לתשרי של השנה הבאה - // כאן כן משתנה השנה כי תשרי הוא תחילת השנה newJewishDate.setJewishDate( current.getJewishYear() + 1, 7, @@ -247,6 +258,220 @@ class CalendarCubit extends Cubit { dailyTimes: newTimes, )); } + + Map shortTimesFor(DateTime date) { + final full = _calculateDailyTimes(date, state.selectedCity); + return { + if (full['sunrise'] != null) 'sunrise': full['sunrise']!, + if (full['sunset'] != null) 'sunset': full['sunset']!, + }; + } + + // --- ניהול אירועים --- + + void addEvent({ + required String title, + String? description, + required DateTime baseGregorianDate, + required bool isRecurring, + required bool recurOnHebrew, + int? recurringYears, + }) { + final baseJewish = JewishDate.fromDateTime(baseGregorianDate); + final newEvent = CustomEvent( + id: DateTime.now().millisecondsSinceEpoch.toString(), // יצירת ID ייחודי + title: title, + description: description ?? '', + createdAt: DateTime.now(), + baseGregorianDate: DateTime( + baseGregorianDate.year, + baseGregorianDate.month, + baseGregorianDate.day, + ), + baseJewishYear: baseJewish.getJewishYear(), + baseJewishMonth: baseJewish.getJewishMonth(), + baseJewishDay: baseJewish.getJewishDayOfMonth(), + recurring: isRecurring, + recurOnHebrew: recurOnHebrew, + recurringYears: recurringYears, + ); + final updated = List.from(state.events)..add(newEvent); + emit(state.copyWith(events: updated)); + _saveEventsToStorage(updated); + } + + void updateEvent(CustomEvent updatedEvent) { + final events = List.from(state.events); + final index = events.indexWhere((e) => e.id == updatedEvent.id); + if (index != -1) { + events[index] = updatedEvent; + emit(state.copyWith(events: events)); + _saveEventsToStorage(events); + } + } + + void deleteEvent(String eventId) { + final events = List.from(state.events) + ..removeWhere((e) => e.id == eventId); + emit(state.copyWith(events: events)); + _saveEventsToStorage(events); + } + + List eventsForDate(DateTime date) { + final jd = JewishDate.fromDateTime(date); + final gY = date.year, gM = date.month, gD = date.day; + final hY = jd.getJewishYear(), + hM = jd.getJewishMonth(), + hD = jd.getJewishDayOfMonth(); + + return state.events.where((e) { + if (e.recurring) { + // בדוק אם האירוע החוזר עדיין בתוקף + if (e.recurringYears != null && e.recurringYears! > 0) { + if (e.recurOnHebrew) { + if (hY >= e.baseJewishYear + e.recurringYears!) { + return false; // עבר זמנו + } + } else { + if (gY >= e.baseGregorianDate.year + e.recurringYears!) { + return false; // עבר זמנו + } + } + } + // אם הוא בתוקף, בדוק אם התאריך מתאים + if (e.recurOnHebrew) { + return e.baseJewishMonth == hM && e.baseJewishDay == hD; + } else { + return e.baseGregorianDate.month == gM && + e.baseGregorianDate.day == gD; + } + } else { + // אירוע רגיל + return e.baseGregorianDate.year == gY && + e.baseGregorianDate.month == gM && + e.baseGregorianDate.day == gD; + } + }).toList() + ..sort((a, b) => a.title.compareTo(b.title)); + } + + // שמירת אירועים לאחסון קבוע + Future _saveEventsToStorage(List events) async { + try { + final eventsJson = jsonEncode(events.map((e) => e.toJson()).toList()); + await _settingsRepository.updateCalendarEvents(eventsJson); + } catch (e) { + // במקרה של שגיאה, נדפיס הודעה לקונסול + print('שגיאה בשמירת אירועים: $e'); + } + } +} + +// Simple event model kept here for scope +class CustomEvent extends Equatable { + final String id; // מזהה ייחודי + final String title; + final String description; + final DateTime createdAt; + final DateTime baseGregorianDate; + final int baseJewishYear; + final int baseJewishMonth; + final int baseJewishDay; + final bool recurring; + final bool recurOnHebrew; + final int? recurringYears; // כמה שנים האירוע יחזור + + const CustomEvent({ + required this.id, + required this.title, + required this.description, + required this.createdAt, + required this.baseGregorianDate, + required this.baseJewishYear, + required this.baseJewishMonth, + required this.baseJewishDay, + required this.recurring, + required this.recurOnHebrew, + this.recurringYears, + }); + + // פונקציה שמאפשרת ליצור עותק של אירוע עם שינויים + CustomEvent copyWith({ + String? id, + String? title, + String? description, + DateTime? createdAt, + DateTime? baseGregorianDate, + int? baseJewishYear, + int? baseJewishMonth, + int? baseJewishDay, + bool? recurring, + bool? recurOnHebrew, + int? recurringYears, + }) { + return CustomEvent( + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + createdAt: createdAt ?? this.createdAt, + baseGregorianDate: baseGregorianDate ?? this.baseGregorianDate, + baseJewishYear: baseJewishYear ?? this.baseJewishYear, + baseJewishMonth: baseJewishMonth ?? this.baseJewishMonth, + baseJewishDay: baseJewishDay ?? this.baseJewishDay, + recurring: recurring ?? this.recurring, + recurOnHebrew: recurOnHebrew ?? this.recurOnHebrew, + recurringYears: recurringYears ?? this.recurringYears, + ); + } + + // המרה ל-JSON לשמירה + Map toJson() { + return { + 'id': id, + 'title': title, + 'description': description, + 'createdAt': createdAt.millisecondsSinceEpoch, + 'baseGregorianDate': baseGregorianDate.millisecondsSinceEpoch, + 'baseJewishYear': baseJewishYear, + 'baseJewishMonth': baseJewishMonth, + 'baseJewishDay': baseJewishDay, + 'recurring': recurring, + 'recurOnHebrew': recurOnHebrew, + 'recurringYears': recurringYears, + }; + } + + // יצירה מ-JSON לטעינה + factory CustomEvent.fromJson(Map json) { + return CustomEvent( + id: json['id'] as String, + title: json['title'] as String, + description: json['description'] as String, + createdAt: DateTime.fromMillisecondsSinceEpoch(json['createdAt'] as int), + baseGregorianDate: DateTime.fromMillisecondsSinceEpoch(json['baseGregorianDate'] as int), + baseJewishYear: json['baseJewishYear'] as int, + baseJewishMonth: json['baseJewishMonth'] as int, + baseJewishDay: json['baseJewishDay'] as int, + recurring: json['recurring'] as bool, + recurOnHebrew: json['recurOnHebrew'] as bool, + recurringYears: json['recurringYears'] as int?, + ); + } + + @override + List get props => [ + id, + title, + description, + createdAt, + baseGregorianDate, + baseJewishYear, + baseJewishMonth, + baseJewishDay, + recurring, + recurOnHebrew, + recurringYears, + ]; } // City coordinates map - מסודר לפי מדינות ובסדר א-ב diff --git a/lib/navigation/calendar_widget.dart b/lib/navigation/calendar_widget.dart index e93fd4d50..a6878abb4 100644 --- a/lib/navigation/calendar_widget.dart +++ b/lib/navigation/calendar_widget.dart @@ -62,14 +62,17 @@ class CalendarWidget extends StatelessWidget { children: [ Expanded( flex: 2, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - _buildCalendar(context, state), - const SizedBox(height: 16), - Expanded(child: _buildEventsCard(context, state)), - ], + child: LayoutBuilder( + builder: (ctx, cons) => SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildCalendar(context, state), + const SizedBox(height: 16), + _buildEventsCard(context, state), + ], + ), ), ), ), @@ -115,13 +118,39 @@ class CalendarWidget extends StatelessWidget { } Widget _buildCalendarHeader(BuildContext context, CalendarState state) { + Widget buildViewButton(CalendarView view, IconData icon, String tooltip) { + final bool isSelected = state.calendarView == view; + return Tooltip( + message: tooltip, + child: IconButton( + isSelected: isSelected, + icon: Icon(icon), + onPressed: () => + context.read().changeCalendarView(view), + style: IconButton.styleFrom( + // כאן אנו מגדירים את הריבוע הצבעוני סביב הכפתור הנבחר + foregroundColor: + isSelected ? Theme.of(context).colorScheme.primary : null, + backgroundColor: isSelected + ? Theme.of(context).colorScheme.primary.withOpacity(0.12) + : null, + side: isSelected + ? BorderSide(color: Theme.of(context).colorScheme.primary) + : BorderSide.none, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ); + } + return Column( children: [ // שורה עליונה עם כפתורים וכותרת Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( + Wrap( children: [ ElevatedButton( onPressed: () => context.read().jumpToToday(), @@ -134,12 +163,34 @@ class CalendarWidget extends StatelessWidget { ), ], ), - Text( - _getCurrentMonthYearText(state), - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + Expanded( + child: Text( + _getCurrentMonthYearText(state), + style: + const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), ), - Row( + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, children: [ + // כפתורים עם סמלים בלבד + buildViewButton( + CalendarView.month, Icons.calendar_view_month, 'חודש'), + buildViewButton( + CalendarView.week, Icons.calendar_view_week, 'שבוע'), + buildViewButton( + CalendarView.day, Icons.calendar_view_day, 'יום'), + + // קו הפרדה קטן + Container( + height: 24, + width: 1, + color: Theme.of(context).dividerColor, + margin: const EdgeInsets.symmetric(horizontal: 4), + ), + + // מעבר בין חודשים IconButton( onPressed: () => context.read().previousMonth(), @@ -153,38 +204,6 @@ class CalendarWidget extends StatelessWidget { ), ], ), - const SizedBox(height: 8), - // שורה תחתונה עם בחירת תצוגה - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SegmentedButton( - segments: const [ - ButtonSegment( - value: CalendarView.month, - label: Text('חודש'), - icon: Icon(Icons.calendar_view_month), - ), - ButtonSegment( - value: CalendarView.week, - label: Text('שבוע'), - icon: Icon(Icons.calendar_view_week), - ), - ButtonSegment( - value: CalendarView.day, - label: Text('יום'), - icon: Icon(Icons.calendar_view_day), - ), - ], - selected: {state.calendarView}, - onSelectionChanged: (Set newSelection) { - context - .read() - .changeCalendarView(newSelection.first); - }, - ), - ], - ), ], ); } @@ -371,7 +390,6 @@ class CalendarWidget extends StatelessWidget { } Widget _buildWeekDays(BuildContext context, CalendarState state) { - // מחשב את תחילת השבוע (ראשון) final selectedDate = state.selectedGregorianDate; final startOfWeek = selectedDate.subtract(Duration(days: selectedDate.weekday % 7)); @@ -391,64 +409,77 @@ class CalendarWidget extends StatelessWidget { weekDays.add( Expanded( - child: GestureDetector( - onTap: () => - context.read().selectDate(jewishDate, dayDate), - child: Container( - margin: const EdgeInsets.all(2), - height: 80, - decoration: BoxDecoration( - color: isSelected - ? Theme.of(context).colorScheme.primaryContainer - : isToday - ? Theme.of(context) - .colorScheme - .primary - .withOpacity(0.25) - : Theme.of(context) - .colorScheme - .surfaceContainer - .withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - border: Border.all( + child: _HoverableDayCell( + onAdd: () { + // לפני פתיחת הדיאלוג, נבחר את התא שנלחץ + context.read().selectDate(jewishDate, dayDate); + _showCreateEventDialog(context, + context.read().state); // קבלת המצב המעודכן + }, + child: GestureDetector( + onTap: () => + context.read().selectDate(jewishDate, dayDate), + child: Container( + margin: const EdgeInsets.all(2), + height: 88, + decoration: BoxDecoration( color: isSelected - ? Theme.of(context).colorScheme.primary + ? Theme.of(context).colorScheme.primaryContainer : isToday - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.outlineVariant, - width: isToday ? 2 : 1, + ? Theme.of(context) + .colorScheme + .primary + .withOpacity(0.25) + : Theme.of(context) + .colorScheme + .surfaceContainer + .withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected + ? Theme.of(context).colorScheme.primary + : isToday + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outlineVariant, + width: isToday ? 2 : 1, + ), ), - ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '${dayDate.day}', - style: TextStyle( - color: isSelected - ? Theme.of(context).colorScheme.onPrimaryContainer - : Theme.of(context).colorScheme.onSurface, - fontWeight: isSelected || isToday - ? FontWeight.bold - : FontWeight.normal, - fontSize: 16, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${dayDate.day}', + style: TextStyle( + color: isSelected + ? Theme.of(context).colorScheme.onPrimaryContainer + : Theme.of(context).colorScheme.onSurface, + fontWeight: isSelected || isToday + ? FontWeight.bold + : FontWeight.normal, + fontSize: 16, + ), ), - ), - const SizedBox(height: 2), - Text( - _formatHebrewDay(jewishDate.getJewishDayOfMonth()), - style: TextStyle( - color: isSelected - ? Theme.of(context) - .colorScheme - .onPrimaryContainer - .withOpacity(0.85) - : Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 12, + const SizedBox(height: 2), + Text( + _formatHebrewDay(jewishDate.getJewishDayOfMonth()), + style: TextStyle( + color: isSelected + ? Theme.of(context) + .colorScheme + .onPrimaryContainer + .withOpacity(0.85) + : Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12, + ), ), - ), - ], + const SizedBox(height: 2), + _DayExtras( + date: dayDate, + jewishCalendar: JewishCalendar.fromDateTime(dayDate), + ), + ], + ), ), ), ), @@ -456,7 +487,6 @@ class CalendarWidget extends StatelessWidget { ), ); } - return Row(children: weekDays); } @@ -479,62 +509,77 @@ class CalendarWidget extends StatelessWidget { gregorianDate.month == DateTime.now().month && gregorianDate.year == DateTime.now().year; - return GestureDetector( - onTap: () => - context.read().selectDate(jewishDate, gregorianDate), - child: Container( - margin: const EdgeInsets.all(2), - height: 50, - decoration: BoxDecoration( - color: isSelected - ? Theme.of(context).colorScheme.primaryContainer - : isToday - ? Theme.of(context) - .colorScheme - .primary - .withOpacity(0.25) - : Theme.of(context) - .colorScheme - .surfaceContainer - .withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - border: Border.all( + return _HoverableDayCell( + onAdd: () => _showCreateEventDialog(context, state), + child: GestureDetector( + onTap: () => + context.read().selectDate(jewishDate, gregorianDate), + child: Container( + margin: const EdgeInsets.all(2), + height: 88, + decoration: BoxDecoration( color: isSelected - ? Theme.of(context).colorScheme.primary + ? Theme.of(context).colorScheme.primaryContainer : isToday - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.outlineVariant, - width: isToday ? 2 : 1, + ? Theme.of(context).colorScheme.primary.withOpacity(0.25) + : Theme.of(context) + .colorScheme + .surfaceContainer + .withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected + ? Theme.of(context).colorScheme.primary + : isToday + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outlineVariant, + width: isToday ? 2 : 1, + ), ), - ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + child: Stack( children: [ - Text( - _formatHebrewDay(day), - style: TextStyle( - color: isSelected - ? Theme.of(context).colorScheme.onPrimaryContainer - : Theme.of(context).colorScheme.onSurface, - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - fontSize: - state.calendarType == CalendarType.combined ? 14 : 16, + Positioned( + top: 4, + right: 4, + child: Text( + _formatHebrewDay(day), + style: TextStyle( + color: isSelected + ? Theme.of(context).colorScheme.onPrimaryContainer + : Theme.of(context).colorScheme.onSurface, + fontWeight: + isSelected ? FontWeight.bold : FontWeight.normal, + fontSize: + state.calendarType == CalendarType.combined ? 12 : 14, + ), ), ), if (state.calendarType == CalendarType.combined) - Text( - '${gregorianDate.day}', - style: TextStyle( - color: isSelected - ? Theme.of(context) - .colorScheme - .onPrimaryContainer - .withOpacity(0.85) - : Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 10, + Positioned( + top: 4, + left: 4, + child: Text( + '${gregorianDate.day}', + style: TextStyle( + color: isSelected + ? Theme.of(context) + .colorScheme + .onPrimaryContainer + .withOpacity(0.85) + : Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 10, + ), ), ), + Positioned( + top: 30, + left: 4, + right: 4, + child: _DayExtras( + date: gregorianDate, + jewishCalendar: JewishCalendar.fromDateTime(gregorianDate), + ), + ), ], ), ), @@ -556,62 +601,77 @@ class CalendarWidget extends StatelessWidget { gregorianDate.month == DateTime.now().month && gregorianDate.year == DateTime.now().year; - return GestureDetector( - onTap: () => - context.read().selectDate(jewishDate, gregorianDate), - child: Container( - margin: const EdgeInsets.all(2), - height: 50, - decoration: BoxDecoration( - color: isSelected - ? Theme.of(context).colorScheme.primaryContainer - : isToday - ? Theme.of(context) - .colorScheme - .primary - .withOpacity(0.25) - : Theme.of(context) - .colorScheme - .surfaceContainer - .withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - border: Border.all( + return _HoverableDayCell( + onAdd: () => _showCreateEventDialog(context, state), + child: GestureDetector( + onTap: () => + context.read().selectDate(jewishDate, gregorianDate), + child: Container( + margin: const EdgeInsets.all(2), + height: 88, + decoration: BoxDecoration( color: isSelected - ? Theme.of(context).colorScheme.primary + ? Theme.of(context).colorScheme.primaryContainer : isToday - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.outlineVariant, - width: isToday ? 2 : 1, + ? Theme.of(context).colorScheme.primary.withOpacity(0.25) + : Theme.of(context) + .colorScheme + .surfaceContainer + .withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected + ? Theme.of(context).colorScheme.primary + : isToday + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outlineVariant, + width: isToday ? 2 : 1, + ), ), - ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + child: Stack( children: [ - Text( - '$day', - style: TextStyle( - color: isSelected - ? Theme.of(context).colorScheme.onPrimaryContainer - : Theme.of(context).colorScheme.onSurface, - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - fontSize: - state.calendarType == CalendarType.combined ? 14 : 16, + Positioned( + top: 4, + right: 4, + child: Text( + '$day', + style: TextStyle( + color: isSelected + ? Theme.of(context).colorScheme.onPrimaryContainer + : Theme.of(context).colorScheme.onSurface, + fontWeight: + isSelected ? FontWeight.bold : FontWeight.normal, + fontSize: + state.calendarType == CalendarType.combined ? 12 : 14, + ), ), ), if (state.calendarType == CalendarType.combined) - Text( - _formatHebrewDay(jewishDate.getJewishDayOfMonth()), - style: TextStyle( - color: isSelected - ? Theme.of(context) - .colorScheme - .onPrimaryContainer - .withOpacity(0.85) - : Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 10, + Positioned( + top: 4, + left: 4, + child: Text( + _formatHebrewDay(jewishDate.getJewishDayOfMonth()), + style: TextStyle( + color: isSelected + ? Theme.of(context) + .colorScheme + .onPrimaryContainer + .withOpacity(0.85) + : Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 10, + ), ), ), + Positioned( + top: 30, + left: 4, + right: 4, + child: _DayExtras( + date: gregorianDate, + jewishCalendar: JewishCalendar.fromDateTime(gregorianDate), + ), + ), ], ), ), @@ -683,7 +743,6 @@ class CalendarWidget extends StatelessWidget { style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const Spacer(), - // החלפנו את הDropdownButton בCityDropdownWithSearch _buildCityDropdownWithSearch(context, state), ], ), @@ -698,7 +757,8 @@ class CalendarWidget extends StatelessWidget { decoration: BoxDecoration( color: Theme.of(context).primaryColor.withAlpha(76), borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).primaryColor, width: 1), + border: + Border.all(color: Theme.of(context).primaryColor, width: 1), ), child: Text( 'אין לסמוך על הזמנים!', @@ -869,14 +929,13 @@ class CalendarWidget extends StatelessWidget { itemBuilder: (context, index) { final timeData = filteredTimesList[index]; final isSpecialTime = _isSpecialTime(timeData['name']!); - final bgColor = isSpecialTime - ? scheme.tertiaryContainer - : scheme.surfaceVariant; - final border = isSpecialTime - ? Border.all(color: scheme.tertiary, width: 1) - : null; - final titleColor = - isSpecialTime ? scheme.onTertiaryContainer : scheme.onSurfaceVariant; + final bgColor = + isSpecialTime ? scheme.tertiaryContainer : scheme.surfaceVariant; + final border = + isSpecialTime ? Border.all(color: scheme.tertiary, width: 1) : null; + final titleColor = isSpecialTime + ? scheme.onTertiaryContainer + : scheme.onSurfaceVariant; final timeColor = isSpecialTime ? scheme.onTertiaryContainer : scheme.onSurface; @@ -1040,11 +1099,13 @@ class CalendarWidget extends StatelessWidget { // פונקציות העזר שלא תלויות במצב נשארות כאן String _getCurrentMonthYearText(CalendarState state) { - if (state.calendarType == CalendarType.gregorian) { - return '${_getGregorianMonthName(state.currentGregorianDate.month)} ${state.currentGregorianDate.year}'; - } else { - return '${hebrewMonths[state.currentJewishDate.getJewishMonth() - 1]} ${_formatHebrewYear(state.currentJewishDate.getJewishYear())}'; - } + final gregName = _getGregorianMonthName(state.currentGregorianDate.month); + final gregNum = state.currentGregorianDate.month; + final hebName = hebrewMonths[state.currentJewishDate.getJewishMonth() - 1]; + final hebYear = _formatHebrewYear(state.currentJewishDate.getJewishYear()); + + // Show both calendars for clarity + return '$hebName $hebYear • $gregName ($gregNum) ${state.currentGregorianDate.year}'; } String _formatHebrewYear(int year) { @@ -1125,6 +1186,59 @@ class CalendarWidget extends StatelessWidget { return months[month - 1]; } + // פונקציות עזר חדשות לפענוח תאריך עברי + int _hebrewNumberToInt(String hebrew) { + final Map hebrewValue = { + 'א': 1, 'ב': 2, 'ג': 3, 'ד': 4, 'ה': 5, 'ו': 6, 'ז': 7, 'ח': 8, 'ט': 9, + 'י': 10, 'כ': 20, 'ל': 30, 'מ': 40, 'נ': 50, 'ס': 60, 'ע': 70, 'פ': 80, 'צ': 90, + 'ק': 100, 'ר': 200, 'ש': 300, 'ת': 400 + }; + + String cleanHebrew = hebrew.replaceAll('"', '').replaceAll("'", ""); + if (cleanHebrew == 'טו') return 15; + if (cleanHebrew == 'טז') return 16; + + int sum = 0; + for (int i = 0; i < cleanHebrew.length; i++) { + sum += hebrewValue[cleanHebrew[i]] ?? 0; + } + return sum; + } + + int _hebrewMonthToInt(String monthName) { + final cleanMonth = monthName.trim(); + final monthIndex = hebrewMonths.indexOf(cleanMonth); + if (monthIndex != -1) return monthIndex + 1; + + // טיפול בשמות חלופיים + if (cleanMonth == 'חשוון' || cleanMonth == 'מרחשוון') return 8; + if (cleanMonth == 'סיוון') return 3; + + throw Exception('Invalid month name'); + } + + int _hebrewYearToInt(String hebrewYear) { + String cleanYear = hebrewYear.replaceAll('"', '').replaceAll("'", ""); + int baseYear = 0; + + // בדוק אם השנה מתחילה ב-'ה' + if (cleanYear.startsWith('ה')) { + baseYear = 5000; + cleanYear = cleanYear.substring(1); + } + + // המר את שאר האותיות למספר + int yearFromLetters = _hebrewNumberToInt(cleanYear); + + // אם לא היתה 'ה' בהתחלה, אבל קיבלנו מספר שנראה כמו שנה, + // נניח אוטומטית שהכוונה היא לאלף הנוכחי (5000) + if (baseYear == 0 && yearFromLetters > 0) { + baseYear = 5000; + } + + return baseYear + yearFromLetters; + } + void _showJumpToDateDialog(BuildContext context) { DateTime selectedDate = DateTime.now(); final TextEditingController dateController = TextEditingController(); @@ -1144,6 +1258,7 @@ class CalendarWidget extends StatelessWidget { // הזנת תאריך ידנית TextField( controller: dateController, + autofocus: true, decoration: const InputDecoration( labelText: 'הזן תאריך', hintText: 'דוגמאות: 15/3/2025, כ״ה אדר תשפ״ה', @@ -1192,13 +1307,13 @@ class CalendarWidget extends StatelessWidget { if (dateController.text.isNotEmpty) { // נסה לפרש את הטקסט שהוזן - dateToJump = _parseInputDate(dateController.text); + dateToJump = _parseInputDate(context, dateController.text); if (dateToJump == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( - 'לא הצלחתי לפרש את התאריך. נסה פורמט כמו: 15/3/2025'), + 'לא הצלחנו לפרש את התאריך.'), backgroundColor: Colors.red, ), ); @@ -1222,13 +1337,11 @@ class CalendarWidget extends StatelessWidget { ); } - DateTime? _parseInputDate(String input) { - // נקה את הטקסט + DateTime? _parseInputDate(BuildContext context, String input) { String cleanInput = input.trim(); - // נסה פורמט לועזי: יום/חודש/שנה או יום-חודש-שנה - RegExp gregorianPattern = - RegExp(r'^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$'); + // 1. נסה לפרש כתאריך לועזי (יום/חודש/שנה) + RegExp gregorianPattern = RegExp(r'^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$'); Match? match = gregorianPattern.firstMatch(cleanInput); if (match != null) { @@ -1236,62 +1349,54 @@ class CalendarWidget extends StatelessWidget { int day = int.parse(match.group(1)!); int month = int.parse(match.group(2)!); int year = int.parse(match.group(3)!); - - // בדוק שהתאריך תקין - if (month >= 1 && - month <= 12 && - day >= 1 && - day <= 31 && - year >= 1900 && - year <= 2100) { + if (year >= 1900 && year <= 2200) { return DateTime(year, month, day); } - } catch (e) { - // המשך לנסות פורמטים אחרים - } + } catch (e) { /* אם נכשל, נמשיך לנסות לפרש כעברי */ } } - // נסה פורמט עברי פשוט - רק מספרים - RegExp hebrewNumberPattern = RegExp(r'^(\d{1,2})\s*(\d{1,2})\s*(\d{4})$'); - match = hebrewNumberPattern.firstMatch(cleanInput); + // 2. נסה לפרש כתאריך עברי (למשל: י"ח אלול תשפ"ה) + try { + final parts = cleanInput.split(RegExp(r'\s+')); + if (parts.length < 2 || parts.length > 3) return null; - if (match != null) { - try { - int day = int.parse(match.group(1)!); - int month = int.parse(match.group(2)!); - int year = int.parse(match.group(3)!); + final day = _hebrewNumberToInt(parts[0]); + final month = _hebrewMonthToInt(parts[1]); + int year; - // נניח שזה תאריך עברי ונמיר לגרגוריאני - if (month >= 1 && - month <= 12 && - day >= 1 && - day <= 30 && - year >= 5700 && - year <= 6000) { - try { - final jewishDate = JewishDate(); - jewishDate.setJewishDate(year, month, day); - return jewishDate.getGregorianCalendar(); - } catch (e) { - // אם נכשל, נסה כתאריך גרגוריאני - if (year >= 1900 && year <= 2100) { - return DateTime(year, month, day); - } - } - } - } catch (e) { - // המשך + if (parts.length == 3) { + year = _hebrewYearToInt(parts[2]); + } else { + // אם השנה הושמטה, נשתמש בשנה העברית הנוכחית שמוצגת בלוח + year = context.read().state.currentJewishDate.getJewishYear(); + } + + if (day > 0 && month > 0 && year > 5000) { + final jewishDate = JewishDate(); + jewishDate.setJewishDate(year, month, day); + return jewishDate.getGregorianCalendar(); } + } catch (e) { + return null; // הפענוח נכשל } return null; } - void _showCreateEventDialog(BuildContext context, CalendarState state) { - final TextEditingController titleController = TextEditingController(); - final TextEditingController descriptionController = TextEditingController(); - bool isRecurring = false; - bool useHebrewCalendar = true; + void _showCreateEventDialog(BuildContext context, CalendarState state, + {CustomEvent? existingEvent}) { + final cubit = context.read(); + final isEditMode = existingEvent != null; + + final TextEditingController titleController = + TextEditingController(text: existingEvent?.title); + final TextEditingController descriptionController = + TextEditingController(text: existingEvent?.description); + final TextEditingController yearsController = TextEditingController( + text: existingEvent?.recurringYears?.toString() ?? '1'); + + bool isRecurring = existingEvent?.recurring ?? false; + bool useHebrewCalendar = existingEvent?.recurOnHebrew ?? true; showDialog( context: context, @@ -1299,10 +1404,9 @@ class CalendarWidget extends StatelessWidget { return StatefulBuilder( builder: (context, setState) { return AlertDialog( - title: const Text('צור אירוע חדש'), + title: Text(isEditMode ? 'ערוך אירוע' : 'צור אירוע חדש'), content: SizedBox( - width: 400, - height: 500, + width: 450, child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1352,53 +1456,53 @@ class CalendarWidget extends StatelessWidget { const SizedBox(height: 16), // אירוע חוזר - Row( - children: [ - Checkbox( - value: isRecurring, - onChanged: (value) { - setState(() { - isRecurring = value ?? false; - }); - }, - ), - const SizedBox(width: 8), - const Text( - 'אירוע חוזר', - style: TextStyle( - fontSize: 16, fontWeight: FontWeight.w500), - ), - const SizedBox(width: 16), - if (isRecurring) - Expanded( - child: DropdownButtonFormField( + SwitchListTile( + title: const Text('אירוע חוזר'), + value: isRecurring, + onChanged: (value) => + setState(() => isRecurring = value), + ), + if (isRecurring) ...[ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + DropdownButtonFormField( value: useHebrewCalendar, decoration: const InputDecoration( + labelText: 'חזור לפי', border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric( - horizontal: 12, vertical: 8), ), items: [ DropdownMenuItem( value: true, child: Text( - 'לפי הלוח העברי (${_formatHebrewDay(state.selectedJewishDate.getJewishDayOfMonth())} ${hebrewMonths[state.selectedJewishDate.getJewishMonth() - 1]})'), + 'לוח עברי (${_formatHebrewDay(state.selectedJewishDate.getJewishDayOfMonth())} ${hebrewMonths[state.selectedJewishDate.getJewishMonth() - 1]})'), ), DropdownMenuItem( value: false, child: Text( - 'לפי הלוח הלועזי (${state.selectedGregorianDate.day}/${state.selectedGregorianDate.month})'), + 'לוח לועזי (${state.selectedGregorianDate.day}/${state.selectedGregorianDate.month})'), ), ], - onChanged: (value) { - setState(() { - useHebrewCalendar = value ?? true; - }); - }, + onChanged: (value) => setState( + () => useHebrewCalendar = value ?? true), ), - ), - ], - ), + const SizedBox(height: 16), + TextField( + controller: yearsController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'חזור למשך (שנים)', + hintText: 'השאר ריק לחזרה ללא הגבלה', + border: OutlineInputBorder(), + ), + ), + ], + ), + ), + ], ], ), ), @@ -1410,31 +1514,40 @@ class CalendarWidget extends StatelessWidget { ), ElevatedButton( onPressed: () { - if (titleController.text.isNotEmpty) { - String eventDetails = titleController.text; - if (isRecurring) { - eventDetails += - ' (חוזר ${useHebrewCalendar ? "לפי לוח עברי" : "לפי לוח לועזי"})'; - } - + if (titleController.text.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('האירוע "$eventDetails" נוצר בהצלחה!'), - backgroundColor: Colors.green, - duration: const Duration(seconds: 3), - ), + const SnackBar( + content: Text('יש למלא כותרת לאירוע.'), + backgroundColor: Colors.red), + ); + return; + } + + final recurringYears = + int.tryParse(yearsController.text.trim()); + + if (isEditMode) { + final updatedEvent = existingEvent!.copyWith( + title: titleController.text.trim(), + description: descriptionController.text.trim(), + recurring: isRecurring, + recurOnHebrew: useHebrewCalendar, + recurringYears: recurringYears, ); - Navigator.of(dialogContext).pop(); + cubit.updateEvent(updatedEvent); } else { - ScaffoldMessenger.of(dialogContext).showSnackBar( - const SnackBar( - content: Text('אנא הכנס כותרת לאירוע'), - backgroundColor: Colors.red, - ), + cubit.addEvent( + title: titleController.text.trim(), + description: descriptionController.text.trim(), + baseGregorianDate: state.selectedGregorianDate, + isRecurring: isRecurring, + recurOnHebrew: useHebrewCalendar, + recurringYears: recurringYears, ); } + Navigator.of(dialogContext).pop(); }, - child: const Text('צור'), + child: Text(isEditMode ? 'שמור שינויים' : 'צור'), ), ], ); @@ -1444,7 +1557,6 @@ class CalendarWidget extends StatelessWidget { ); } - // החלק של האירועים עדיין לא עבר ריפקטורינג, הוא יישאר לא פעיל בינתיים // הוספת הוויג'ט החדש לבחירת עיר עם סינון Widget _buildCityDropdownWithSearch( BuildContext context, CalendarState state) { @@ -1481,6 +1593,7 @@ class CalendarWidget extends StatelessWidget { padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ Row( children: [ @@ -1504,12 +1617,235 @@ class CalendarWidget extends StatelessWidget { ], ), const SizedBox(height: 16), - const Center(child: Text('אין אירועים ליום זה')), + _buildEventsList(context, state), ], ), ), ); } + + Widget _buildEventsList(BuildContext context, CalendarState state) { + final events = context + .read() + .eventsForDate(state.selectedGregorianDate); + + if (events.isEmpty) { + return const Center(child: Text('אין אירועים ליום זה')); + } + + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: events.length, + itemBuilder: (context, index) { + final event = events[index]; + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withAlpha(25), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).primaryColor.withAlpha(76), + ), + ), + child: Row( + children: [ + // פרטי האירוע + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + event.title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + if (event.description.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + event.description, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + if (event.recurring) ...[ + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.repeat, + size: 12, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 4), + Text( + event.recurOnHebrew + ? 'חוזר לפי לוח עברי' + : 'חוזר לפי לוח לועזי', + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ], + ], + ), + ), + // לחצני פעולות + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit, size: 20), + tooltip: 'ערוך אירוע', + onPressed: () => _showCreateEventDialog(context, state, + existingEvent: event), + ), + IconButton( + icon: const Icon(Icons.delete, size: 20), + tooltip: 'מחק אירוע', + onPressed: () { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('אישור מחיקה'), + content: Text( + 'האם אתה בטוח שברצונך למחוק את האירוע "${event.title}"?'), + actions: [ + TextButton( + child: const Text('ביטול'), + onPressed: () => + Navigator.of(dialogContext).pop(), + ), + TextButton( + child: const Text('מחק'), + onPressed: () { + context + .read() + .deleteEvent(event.id); + Navigator.of(dialogContext).pop(); + }, + ), + ], + ), + ); + }, + ), + ], + ) + ], + ), + ); + }, + ); + } +} + +// מציג תוספות קטנות בכל יום: זמני זריחה/שקיעה, מועדים, וכמות אירועים מותאמים +class _DayExtras extends StatelessWidget { + final DateTime date; + final JewishCalendar jewishCalendar; + const _DayExtras({required this.date, required this.jewishCalendar}); + + @override + Widget build(BuildContext context) { + final cubit = context.read(); + final events = cubit.eventsForDate(date); + + final List lines = []; + + for (final e in _calcJewishEvents(jewishCalendar).take(2)) { + lines.add(Text( + e, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + )); + } + + for (final e in events.take(2)) { + lines.add(Text( + '• ${e.title}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + )); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: lines, + ); + } + + static List _calcJewishEvents(JewishCalendar jc) { + final List l = []; + if (jc.isRoshChodesh()) l.add('ר"ח'); + + // טיפול מיוחד בתעניות - הצגת השם המלא במקום "צום" + switch (jc.getYomTovIndex()) { + case JewishCalendar.ROSH_HASHANA: + l.add('ראש השנה'); + break; + case JewishCalendar.YOM_KIPPUR: + l.add('יום כיפור'); + break; + case JewishCalendar.SUCCOS: + l.add('סוכות'); + break; + case JewishCalendar.SHEMINI_ATZERES: + l.add('שמיני עצרת'); + break; + case JewishCalendar.SIMCHAS_TORAH: + l.add('שמחת תורה'); + break; + case JewishCalendar.PESACH: + l.add('פסח'); + break; + case JewishCalendar.SHAVUOS: + l.add('שבועות'); + break; + case JewishCalendar.CHANUKAH: + l.add('חנוכה'); + break; + + default: + // בדיקה נוספת לתעניות שלא מזוהות בYomTovIndex + if (jc.isTaanis()) { + // אם זה תענית שלא זוהתה למעלה, נציג שם כללי + final jewishMonth = jc.getJewishMonth(); + final jewishDay = jc.getJewishDayOfMonth(); + + if (jewishMonth == 7 && jewishDay == 3) { + l.add('צום גדליה'); + } else if (jewishMonth == 10 && jewishDay == 10) { + l.add('עשרה בטבת'); + } else if (jewishMonth == 4 && jewishDay == 17) { + l.add('שבעה עשר בתמוז'); + } else if (jewishMonth == 5 && jewishDay == 9) { + l.add('תשעה באב'); + } else { + l.add('תענית'); + } + } + break; + } + return l; + } } // דיאלוג לחיפוש ובחירת עיר @@ -1629,3 +1965,53 @@ class _CitySearchDialogState extends State<_CitySearchDialog> { ); } } + +// ווידג'ט עזר שמציג לחצן הוספה בריחוף +class _HoverableDayCell extends StatefulWidget { + final Widget child; + final VoidCallback onAdd; + + const _HoverableDayCell({required this.child, required this.onAdd}); + + @override + State<_HoverableDayCell> createState() => _HoverableDayCellState(); +} + +class _HoverableDayCellState extends State<_HoverableDayCell> { + bool _isHovering = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _isHovering = true), + onExit: (_) => setState(() => _isHovering = false), + child: Stack( + alignment: Alignment.center, + children: [ + widget.child, + // כפתור הוספה שמופיע בריחוף + AnimatedOpacity( + opacity: _isHovering ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: IgnorePointer( + ignoring: !_isHovering, // מונע מהכפתור לחסום קליקים כשהוא שקוף + child: Tooltip( + message: 'צור אירוע', + child: IconButton.filled( + icon: const Icon(Icons.add), + onPressed: widget.onAdd, + style: IconButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer, + visualDensity: + VisualDensity.compact, // הופך אותו לקצת יותר קטן + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/settings/settings_repository.dart b/lib/settings/settings_repository.dart index 711ef439a..05fb86eda 100644 --- a/lib/settings/settings_repository.dart +++ b/lib/settings/settings_repository.dart @@ -23,6 +23,7 @@ class SettingsRepository { static const String keyFacetFilteringWidth = 'key-facet-filtering-width'; static const String keyCalendarType = 'key-calendar-type'; static const String keySelectedCity = 'key-selected-city'; + static const String keyCalendarEvents = 'key-calendar-events'; final SettingsWrapper _settings; @@ -101,6 +102,10 @@ class SettingsRepository { keySelectedCity, defaultValue: 'ירושלים', ), + 'calendarEvents': _settings.getValue( + keyCalendarEvents, + defaultValue: '[]', + ), }; } @@ -184,6 +189,10 @@ class SettingsRepository { await _settings.setValue(keySelectedCity, value); } + Future updateCalendarEvents(String eventsJson) async { + await _settings.setValue(keyCalendarEvents, eventsJson); + } + /// Initialize default settings to disk if this is the first app launch Future _initializeDefaultsIfNeeded() async { if (await _checkIfDefaultsNeeded()) { @@ -220,6 +229,7 @@ class SettingsRepository { await _settings.setValue(keyFacetFilteringWidth, 235.0); await _settings.setValue(keyCalendarType, 'combined'); await _settings.setValue(keySelectedCity, 'ירושלים'); + await _settings.setValue(keyCalendarEvents, '[]'); // Mark as initialized await _settings.setValue('settings_initialized', true); From 57866f6634b63ba32db9f79c2a538f71ad059b44 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 11 Sep 2025 16:27:05 +0300 Subject: [PATCH 179/197] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20=D7=92?= =?UTF-8?q?=D7=A8=D7=A1=D7=94=20=D7=9C949?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++-- installer/otzaria.iss | 2 +- installer/otzaria_full.iss | 2 +- pubspec.yaml | 4 ++-- version.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 337377e81..ca8403f96 100644 --- a/.gitignore +++ b/.gitignore @@ -60,7 +60,7 @@ android/ build/ fonts/ -installer/otzaria-0.9.48-windows.exe -installer/otzaria-0.9.48-windows-full.exe +installer/otzaria-0.9.49-windows.exe +installer/otzaria-0.9.49-windows-full.exe pubspec.lock flutter/ diff --git a/installer/otzaria.iss b/installer/otzaria.iss index 12e751e13..ac3417104 100644 --- a/installer/otzaria.iss +++ b/installer/otzaria.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.48" +#define MyAppVersion "0.9.49" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/installer/otzaria_full.iss b/installer/otzaria_full.iss index efc94e975..4fea2a1df 100644 --- a/installer/otzaria_full.iss +++ b/installer/otzaria_full.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.48" +#define MyAppVersion "0.9.49" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/pubspec.yaml b/pubspec.yaml index 367beb8a8..2cc71e7a3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,7 @@ msix_config: publisher_display_name: sivan22 identity_name: sivan22.Otzaria description: "ספריית ספרים יהודיים עם אפשרויות חיפוש חכם" - msix_version: 0.9.48.0 + msix_version: 0.9.49.0 logo_path: assets/icon/icon.png publisher: CN=sivan22, O=sivan22, C=IL certificate_path: sivan22.pfx @@ -36,7 +36,7 @@ msix_config: # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.9.48 +version: 0.9.49 environment: sdk: ">=3.2.6 <4.0.0" diff --git a/version.json b/version.json index 8983ed199..e7445a3f8 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "0.9.48" + "version": "0.9.49" } \ No newline at end of file From 88e7a2ecc7067415b79de8e80f8b8d6fe8a97f15 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 11 Sep 2025 21:48:20 +0300 Subject: [PATCH 180/197] =?UTF-8?q?=D7=94=D7=95=D7=A1=D7=A4=D7=AA=20=D7=A2?= =?UTF-8?q?=D7=99=D7=A8,=20=D7=95=D7=90=D7=99=D7=A8=D7=95=D7=A2=20=D7=9C?= =?UTF-8?q?=D7=AA=D7=9E=D7=99=D7=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/navigation/calendar_cubit.dart | 9 ++- lib/navigation/calendar_widget.dart | 107 ++++++++++++++++++++++------ 2 files changed, 93 insertions(+), 23 deletions(-) diff --git a/lib/navigation/calendar_cubit.dart b/lib/navigation/calendar_cubit.dart index 10b152168..47edf7d35 100644 --- a/lib/navigation/calendar_cubit.dart +++ b/lib/navigation/calendar_cubit.dart @@ -113,12 +113,13 @@ class CalendarCubit extends Cubit { final calendarType = _stringToCalendarType(calendarTypeString); final selectedCity = settings['selectedCity'] as String; final eventsJson = settings['calendarEvents'] as String; - + // טעינת אירועים מהאחסון List events = []; try { final List eventsList = jsonDecode(eventsJson); - events = eventsList.map((eventMap) => CustomEvent.fromJson(eventMap)).toList(); + events = + eventsList.map((eventMap) => CustomEvent.fromJson(eventMap)).toList(); } catch (e) { // אם יש שגיאה בטעינה, נתחיל עם רשימה ריקה events = []; @@ -448,7 +449,8 @@ class CustomEvent extends Equatable { title: json['title'] as String, description: json['description'] as String, createdAt: DateTime.fromMillisecondsSinceEpoch(json['createdAt'] as int), - baseGregorianDate: DateTime.fromMillisecondsSinceEpoch(json['baseGregorianDate'] as int), + baseGregorianDate: + DateTime.fromMillisecondsSinceEpoch(json['baseGregorianDate'] as int), baseJewishYear: json['baseJewishYear'] as int, baseJewishMonth: json['baseJewishMonth'] as int, baseJewishDay: json['baseJewishDay'] as int, @@ -507,6 +509,7 @@ const Map>> cityCoordinates = { 'נתניה': {'lat': 32.3215, 'lng': 34.8532, 'elevation': 30.0}, 'נצרת עילית': {'lat': 32.6992, 'lng': 35.3289, 'elevation': 400.0}, 'עפולה': {'lat': 32.6078, 'lng': 35.2897, 'elevation': 60.0}, + 'ערד': {'lat': 31.2592, 'lng': 35.2124, 'elevation': 570.0}, 'פתח תקווה': {'lat': 32.0870, 'lng': 34.8873, 'elevation': 80.0}, 'צפת': {'lat': 32.9650, 'lng': 35.4951, 'elevation': 900.0}, 'קרית אונו': {'lat': 32.0539, 'lng': 34.8581, 'elevation': 75.0}, diff --git a/lib/navigation/calendar_widget.dart b/lib/navigation/calendar_widget.dart index a6878abb4..192a23a61 100644 --- a/lib/navigation/calendar_widget.dart +++ b/lib/navigation/calendar_widget.dart @@ -1189,9 +1189,28 @@ class CalendarWidget extends StatelessWidget { // פונקציות עזר חדשות לפענוח תאריך עברי int _hebrewNumberToInt(String hebrew) { final Map hebrewValue = { - 'א': 1, 'ב': 2, 'ג': 3, 'ד': 4, 'ה': 5, 'ו': 6, 'ז': 7, 'ח': 8, 'ט': 9, - 'י': 10, 'כ': 20, 'ל': 30, 'מ': 40, 'נ': 50, 'ס': 60, 'ע': 70, 'פ': 80, 'צ': 90, - 'ק': 100, 'ר': 200, 'ש': 300, 'ת': 400 + 'א': 1, + 'ב': 2, + 'ג': 3, + 'ד': 4, + 'ה': 5, + 'ו': 6, + 'ז': 7, + 'ח': 8, + 'ט': 9, + 'י': 10, + 'כ': 20, + 'ל': 30, + 'מ': 40, + 'נ': 50, + 'ס': 60, + 'ע': 70, + 'פ': 80, + 'צ': 90, + 'ק': 100, + 'ר': 200, + 'ש': 300, + 'ת': 400 }; String cleanHebrew = hebrew.replaceAll('"', '').replaceAll("'", ""); @@ -1226,19 +1245,19 @@ class CalendarWidget extends StatelessWidget { baseYear = 5000; cleanYear = cleanYear.substring(1); } - + // המר את שאר האותיות למספר int yearFromLetters = _hebrewNumberToInt(cleanYear); // אם לא היתה 'ה' בהתחלה, אבל קיבלנו מספר שנראה כמו שנה, // נניח אוטומטית שהכוונה היא לאלף הנוכחי (5000) if (baseYear == 0 && yearFromLetters > 0) { - baseYear = 5000; + baseYear = 5000; } return baseYear + yearFromLetters; } - + void _showJumpToDateDialog(BuildContext context) { DateTime selectedDate = DateTime.now(); final TextEditingController dateController = TextEditingController(); @@ -1307,13 +1326,13 @@ class CalendarWidget extends StatelessWidget { if (dateController.text.isNotEmpty) { // נסה לפרש את הטקסט שהוזן - dateToJump = _parseInputDate(context, dateController.text); + dateToJump = + _parseInputDate(context, dateController.text); if (dateToJump == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text( - 'לא הצלחנו לפרש את התאריך.'), + content: Text('לא הצלחנו לפרש את התאריך.'), backgroundColor: Colors.red, ), ); @@ -1341,7 +1360,8 @@ class CalendarWidget extends StatelessWidget { String cleanInput = input.trim(); // 1. נסה לפרש כתאריך לועזי (יום/חודש/שנה) - RegExp gregorianPattern = RegExp(r'^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$'); + RegExp gregorianPattern = + RegExp(r'^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$'); Match? match = gregorianPattern.firstMatch(cleanInput); if (match != null) { @@ -1352,7 +1372,7 @@ class CalendarWidget extends StatelessWidget { if (year >= 1900 && year <= 2200) { return DateTime(year, month, day); } - } catch (e) { /* אם נכשל, נמשיך לנסות לפרש כעברי */ } + } catch (e) {/* אם נכשל, נמשיך לנסות לפרש כעברי */} } // 2. נסה לפרש כתאריך עברי (למשל: י"ח אלול תשפ"ה) @@ -1368,7 +1388,11 @@ class CalendarWidget extends StatelessWidget { year = _hebrewYearToInt(parts[2]); } else { // אם השנה הושמטה, נשתמש בשנה העברית הנוכחית שמוצגת בלוח - year = context.read().state.currentJewishDate.getJewishYear(); + year = context + .read() + .state + .currentJewishDate + .getJewishYear(); } if (day > 0 && month > 0 && year > 5000) { @@ -1392,11 +1416,15 @@ class CalendarWidget extends StatelessWidget { TextEditingController(text: existingEvent?.title); final TextEditingController descriptionController = TextEditingController(text: existingEvent?.description); + + // בקר חדש שמטפל במספר השנים, יהיה ריק אם האירוע הוא "תמיד" final TextEditingController yearsController = TextEditingController( - text: existingEvent?.recurringYears?.toString() ?? '1'); + text: existingEvent?.recurringYears?.toString() ?? ''); bool isRecurring = existingEvent?.recurring ?? false; bool useHebrewCalendar = existingEvent?.recurOnHebrew ?? true; + // משתנה חדש שבודק אם האירוע מוגדר כ"תמיד" + bool recurForever = existingEvent?.recurringYears == null; showDialog( context: context, @@ -1490,13 +1518,42 @@ class CalendarWidget extends StatelessWidget { () => useHebrewCalendar = value ?? true), ), const SizedBox(height: 16), + + // --- כאן נמצא השינוי המרכזי --- + // הוספנו תיבת סימון לבחירת "תמיד" + CheckboxListTile( + title: const Text('חזרה ללא הגבלה (תמיד)'), + value: recurForever, + onChanged: (value) { + setState(() { + recurForever = value ?? true; + // אם המשתמש בחר "תמיד", ננקה את שדה מספר השנים + if (recurForever) { + yearsController.clear(); + } + }); + }, + controlAffinity: + ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + const SizedBox(height: 8), + + // שדה מספר השנים מושבת כעת אם "תמיד" מסומן TextField( controller: yearsController, keyboardType: TextInputType.number, - decoration: const InputDecoration( + enabled: !recurForever, // <-- החלק החשוב + decoration: InputDecoration( labelText: 'חזור למשך (שנים)', - hintText: 'השאר ריק לחזרה ללא הגבלה', - border: OutlineInputBorder(), + hintText: 'לדוגמה: 5', + border: const OutlineInputBorder(), + filled: !recurForever ? false : true, + fillColor: !recurForever + ? null + : Theme.of(context) + .disabledColor + .withOpacity(0.1), ), ), ], @@ -1523,8 +1580,16 @@ class CalendarWidget extends StatelessWidget { return; } - final recurringYears = - int.tryParse(yearsController.text.trim()); + // --- לוגיקת שמירה מעודכנת --- + final int? recurringYears; + // אם האירוע חוזר, אבל לא "תמיד", ננסה לקרוא את מספר השנים + if (isRecurring && !recurForever) { + recurringYears = + int.tryParse(yearsController.text.trim()); + } else { + // בכל מקרה אחר (לא חוזר, או חוזר "תמיד"), הערך יהיה ריק (null) + recurringYears = null; + } if (isEditMode) { final updatedEvent = existingEvent!.copyWith( @@ -2001,8 +2066,10 @@ class _HoverableDayCellState extends State<_HoverableDayCell> { icon: const Icon(Icons.add), onPressed: widget.onAdd, style: IconButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primaryContainer, - foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer, + backgroundColor: + Theme.of(context).colorScheme.primaryContainer, + foregroundColor: + Theme.of(context).colorScheme.onPrimaryContainer, visualDensity: VisualDensity.compact, // הופך אותו לקצת יותר קטן ), From e834d8b52f30fcc346732a8548ab90b415199b0e Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 11 Sep 2025 22:50:08 +0300 Subject: [PATCH 181/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=94?= =?UTF-8?q?=D7=91=D7=A0=D7=99=D7=99=D7=94=20=D7=9C=D7=95=D7=95=D7=99=D7=A0?= =?UTF-8?q?=D7=93=D7=95=D7=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/flutter.yml | 59 +++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index 1a3bb91ab..8d8d54967 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -20,25 +20,60 @@ on: jobs: build_windows: - runs-on: windows-latest + runs-on: windows-2022 steps: - name: Clone repository - uses: actions/checkout@v4 - + uses: actions/checkout@v4 + - name: Set up Flutter uses: subosito/flutter-action@v2 with: channel: stable cache: true - - name: Install Inno Setup + # נסה קודם את הסקריפט שלך (אם הוא מטפל בהתקנה/הוספת PATH) + - name: Install Inno Setup (project script + fallback) + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + + # 1) נסיון להריץ את הסקריפט המקומי אם קיים + if (Test-Path .\installer\install_inno_setup.ps1) { + Write-Host "Running local installer script..." + .\installer\install_inno_setup.ps1 + } else { + Write-Host "Local installer script not found, skipping." + } + + # 2) אם עדיין אין iscc במערכת - התקנה אמינה דרך winget/Chocolatey + if (-not (Get-Command iscc -ErrorAction SilentlyContinue)) { + if (Get-Command winget -ErrorAction SilentlyContinue) { + Write-Host "Installing Inno Setup via winget..." + winget install -e --id JRSoftware.InnoSetup --silent --accept-package-agreements --accept-source-agreements + } else { + Write-Host "winget not found, installing via Chocolatey..." + choco install innosetup -y --no-progress + } + } + + # 3) מציאת המיקום של ISCC.exe והזרקתו ל־ENV + $iscc = (Get-ChildItem "C:\Program Files*\Inno Setup*\ISCC.exe" -Recurse -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName) + if (-not $iscc) { throw "ISCC.exe not found after installation" } + + "ISCC=$iscc" | Out-File -FilePath $env:GITHUB_ENV -Append + Write-Host "ISCC resolved to: $iscc" + + - name: Verify Inno Setup (iscc) + shell: pwsh run: | - ./installer/install_inno_setup.ps1 + & "$env:ISCC" /? - name: Build Flutter Windows app + shell: pwsh run: | flutter build windows --release - + - name: Zip Windows build shell: pwsh run: | @@ -49,12 +84,16 @@ jobs: Compress-Archive -Path "$relDir\runner\Release\*" -DestinationPath otzaria-windows.zip - name: Build MSIX package - run: dart run msix:create --install-certificate false - + shell: pwsh + run: | + dart run msix:create --install-certificate false + - name: Build Inno Setup installer + shell: pwsh run: | - iscc installer\otzaria.iss - + # שימוש בנתיב המלא/ENV כדי לא להיות תלוי ב-PATH + & "$env:ISCC" installer\otzaria.iss + - name: Upload Windows installer uses: actions/upload-artifact@v4 with: From 1643423e76f641e97f14602186762856b7d1b7e1 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 11 Sep 2025 22:58:32 +0300 Subject: [PATCH 182/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/flutter.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index 8d8d54967..72abe36d9 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -63,11 +63,6 @@ jobs: "ISCC=$iscc" | Out-File -FilePath $env:GITHUB_ENV -Append Write-Host "ISCC resolved to: $iscc" - - - name: Verify Inno Setup (iscc) - shell: pwsh - run: | - & "$env:ISCC" /? - name: Build Flutter Windows app shell: pwsh From c067c6f08c65978836890f38978f88dd52d92afc Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 12 Sep 2025 00:58:30 +0300 Subject: [PATCH 183/197] =?UTF-8?q?=D7=94=D7=A2=D7=AA=D7=A7=D7=94=20=D7=A2?= =?UTF-8?q?=D7=9D=20=D7=9B=D7=95=D7=AA=D7=A8=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/settings/settings_bloc.dart | 20 ++ lib/settings/settings_event.dart | 18 + lib/settings/settings_repository.dart | 20 ++ lib/settings/settings_screen.dart | 44 +++ lib/settings/settings_state.dart | 12 + .../combined_view/combined_book_screen.dart | 118 ++++++- .../view/splited_view/simple_book_view.dart | 151 +++++++- lib/text_book/view/text_book_screen.dart | 29 +- lib/utils/copy_utils.dart | 334 ++++++++++++++++++ test/unit/settings/settings_bloc_test.dart | 4 + 10 files changed, 720 insertions(+), 30 deletions(-) create mode 100644 lib/utils/copy_utils.dart diff --git a/lib/settings/settings_bloc.dart b/lib/settings/settings_bloc.dart index 9849f9fc9..c8b829786 100644 --- a/lib/settings/settings_bloc.dart +++ b/lib/settings/settings_bloc.dart @@ -28,6 +28,8 @@ class SettingsBloc extends Bloc { on(_onUpdatePinSidebar); on(_onUpdateSidebarWidth); on(_onUpdateFacetFilteringWidth); + on(_onUpdateCopyWithHeaders); + on(_onUpdateCopyHeaderFormat); } Future _onLoadSettings( @@ -54,6 +56,8 @@ class SettingsBloc extends Bloc { pinSidebar: settings['pinSidebar'], sidebarWidth: settings['sidebarWidth'], facetFilteringWidth: settings['facetFilteringWidth'], + copyWithHeaders: settings['copyWithHeaders'], + copyHeaderFormat: settings['copyHeaderFormat'], )); } @@ -200,4 +204,20 @@ class SettingsBloc extends Bloc { await _repository.updateFacetFilteringWidth(event.facetFilteringWidth); emit(state.copyWith(facetFilteringWidth: event.facetFilteringWidth)); } + + Future _onUpdateCopyWithHeaders( + UpdateCopyWithHeaders event, + Emitter emit, + ) async { + await _repository.updateCopyWithHeaders(event.copyWithHeaders); + emit(state.copyWith(copyWithHeaders: event.copyWithHeaders)); + } + + Future _onUpdateCopyHeaderFormat( + UpdateCopyHeaderFormat event, + Emitter emit, + ) async { + await _repository.updateCopyHeaderFormat(event.copyHeaderFormat); + emit(state.copyWith(copyHeaderFormat: event.copyHeaderFormat)); + } } diff --git a/lib/settings/settings_event.dart b/lib/settings/settings_event.dart index 69d2f396f..a14ed13f8 100644 --- a/lib/settings/settings_event.dart +++ b/lib/settings/settings_event.dart @@ -171,3 +171,21 @@ class UpdateFacetFilteringWidth extends SettingsEvent { @override List get props => [facetFilteringWidth]; } + +class UpdateCopyWithHeaders extends SettingsEvent { + final String copyWithHeaders; + + const UpdateCopyWithHeaders(this.copyWithHeaders); + + @override + List get props => [copyWithHeaders]; +} + +class UpdateCopyHeaderFormat extends SettingsEvent { + final String copyHeaderFormat; + + const UpdateCopyHeaderFormat(this.copyHeaderFormat); + + @override + List get props => [copyHeaderFormat]; +} diff --git a/lib/settings/settings_repository.dart b/lib/settings/settings_repository.dart index 05fb86eda..d5c4a1965 100644 --- a/lib/settings/settings_repository.dart +++ b/lib/settings/settings_repository.dart @@ -24,6 +24,8 @@ class SettingsRepository { static const String keyCalendarType = 'key-calendar-type'; static const String keySelectedCity = 'key-selected-city'; static const String keyCalendarEvents = 'key-calendar-events'; + static const String keyCopyWithHeaders = 'key-copy-with-headers'; + static const String keyCopyHeaderFormat = 'key-copy-header-format'; final SettingsWrapper _settings; @@ -106,6 +108,14 @@ class SettingsRepository { keyCalendarEvents, defaultValue: '[]', ), + 'copyWithHeaders': _settings.getValue( + keyCopyWithHeaders, + defaultValue: 'none', + ), + 'copyHeaderFormat': _settings.getValue( + keyCopyHeaderFormat, + defaultValue: 'same_line_after_brackets', + ), }; } @@ -193,6 +203,14 @@ class SettingsRepository { await _settings.setValue(keyCalendarEvents, eventsJson); } + Future updateCopyWithHeaders(String value) async { + await _settings.setValue(keyCopyWithHeaders, value); + } + + Future updateCopyHeaderFormat(String value) async { + await _settings.setValue(keyCopyHeaderFormat, value); + } + /// Initialize default settings to disk if this is the first app launch Future _initializeDefaultsIfNeeded() async { if (await _checkIfDefaultsNeeded()) { @@ -230,6 +248,8 @@ class SettingsRepository { await _settings.setValue(keyCalendarType, 'combined'); await _settings.setValue(keySelectedCity, 'ירושלים'); await _settings.setValue(keyCalendarEvents, '[]'); + await _settings.setValue(keyCopyWithHeaders, 'none'); + await _settings.setValue(keyCopyHeaderFormat, 'same_line_after_brackets'); // Mark as initialized await _settings.setValue('settings_initialized', true); diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index 1eafb8bc2..7d2311ba9 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -436,6 +436,50 @@ class _MySettingsScreenState extends State ]), ], ), + SettingsGroup( + title: 'הגדרות העתקה', + titleAlignment: Alignment.centerRight, + titleTextStyle: const TextStyle(fontSize: 25), + children: [ + _buildColumns(2, [ + DropDownSettingsTile( + title: 'העתקה עם כותרות', + settingKey: 'key-copy-with-headers', + values: const { + 'none': 'ללא', + 'book_name': 'העתקה עם שם הספר בלבד', + 'book_and_path': 'העתקה עם שם הספר+הנתיב', + }, + selected: state.copyWithHeaders, + leading: const Icon(Icons.content_copy), + onChange: (value) { + context + .read() + .add(UpdateCopyWithHeaders(value)); + }, + ), + DropDownSettingsTile( + title: 'עיצוב ההעתקה', + settingKey: 'key-copy-header-format', + values: const { + 'same_line_after_brackets': 'באותה שורה אחרי הכיתוב (עם סוגריים)', + 'same_line_after_no_brackets': 'באותה שורה אחרי הכיתוב (בלי סוגריים)', + 'same_line_before_brackets': 'באותה שורה לפני הכיתוב (עם סוגריים)', + 'same_line_before_no_brackets': 'באותה שורה לפני הכיתוב (בלי סוגריים)', + 'separate_line_after': 'בפסקה בפני עצמה אחרי הכיתוב', + 'separate_line_before': 'בפסקה בפני עצמה לפני הכיתוב', + }, + selected: state.copyHeaderFormat, + leading: const Icon(Icons.format_align_right), + onChange: (value) { + context + .read() + .add(UpdateCopyHeaderFormat(value)); + }, + ), + ]), + ], + ), SettingsGroup( title: 'כללי', titleAlignment: Alignment.centerRight, diff --git a/lib/settings/settings_state.dart b/lib/settings/settings_state.dart index 6479306d5..a79275656 100644 --- a/lib/settings/settings_state.dart +++ b/lib/settings/settings_state.dart @@ -20,6 +20,8 @@ class SettingsState extends Equatable { final bool pinSidebar; final double sidebarWidth; final double facetFilteringWidth; + final String copyWithHeaders; + final String copyHeaderFormat; const SettingsState({ required this.isDarkMode, @@ -40,6 +42,8 @@ class SettingsState extends Equatable { required this.pinSidebar, required this.sidebarWidth, required this.facetFilteringWidth, + required this.copyWithHeaders, + required this.copyHeaderFormat, }); factory SettingsState.initial() { @@ -62,6 +66,8 @@ class SettingsState extends Equatable { pinSidebar: false, sidebarWidth: 300, facetFilteringWidth: 235, + copyWithHeaders: 'none', + copyHeaderFormat: 'same_line_after_brackets', ); } @@ -84,6 +90,8 @@ class SettingsState extends Equatable { bool? pinSidebar, double? sidebarWidth, double? facetFilteringWidth, + String? copyWithHeaders, + String? copyHeaderFormat, }) { return SettingsState( isDarkMode: isDarkMode ?? this.isDarkMode, @@ -105,6 +113,8 @@ class SettingsState extends Equatable { pinSidebar: pinSidebar ?? this.pinSidebar, sidebarWidth: sidebarWidth ?? this.sidebarWidth, facetFilteringWidth: facetFilteringWidth ?? this.facetFilteringWidth, + copyWithHeaders: copyWithHeaders ?? this.copyWithHeaders, + copyHeaderFormat: copyHeaderFormat ?? this.copyHeaderFormat, ); } @@ -128,5 +138,7 @@ class SettingsState extends Equatable { pinSidebar, sidebarWidth, facetFilteringWidth, + copyWithHeaders, + copyHeaderFormat, ]; } diff --git a/lib/text_book/view/combined_view/combined_book_screen.dart b/lib/text_book/view/combined_view/combined_book_screen.dart index c6e0f0e50..a381d7575 100644 --- a/lib/text_book/view/combined_view/combined_book_screen.dart +++ b/lib/text_book/view/combined_view/combined_book_screen.dart @@ -19,6 +19,7 @@ import 'package:otzaria/models/books.dart'; import 'package:otzaria/utils/text_manipulation.dart' as utils; import 'package:otzaria/text_book/bloc/text_book_event.dart'; import 'package:otzaria/notes/notes_system.dart'; +import 'package:otzaria/utils/copy_utils.dart'; import 'package:super_clipboard/super_clipboard.dart'; class CombinedView extends StatefulWidget { @@ -335,9 +336,38 @@ class _CombinedViewState extends State { final text = widget.data[index]; if (text.trim().isEmpty) return; + // קבלת ההגדרות הנוכחיות + final settingsState = context.read().state; + final textBookState = context.read().state; + + String finalText = text; + String finalHtmlText = text; + + // אם צריך להוסיף כותרות + if (settingsState.copyWithHeaders != 'none' && textBookState is TextBookLoaded) { + final bookName = CopyUtils.extractBookName(textBookState.book); + final currentPath = await CopyUtils.extractCurrentPath(textBookState.book, index); + + finalText = CopyUtils.formatTextWithHeaders( + originalText: text, + copyWithHeaders: settingsState.copyWithHeaders, + copyHeaderFormat: settingsState.copyHeaderFormat, + bookName: bookName, + currentPath: currentPath, + ); + + finalHtmlText = CopyUtils.formatTextWithHeaders( + originalText: text, + copyWithHeaders: settingsState.copyWithHeaders, + copyHeaderFormat: settingsState.copyHeaderFormat, + bookName: bookName, + currentPath: currentPath, + ); + } + final item = DataWriterItem(); - item.add(Formats.plainText(text)); - item.add(Formats.htmlText(_formatTextAsHtml(text))); + item.add(Formats.plainText(finalText)); + item.add(Formats.htmlText(_formatTextAsHtml(finalHtmlText))); await SystemClipboard.instance?.write([item]); } @@ -358,10 +388,31 @@ class _CombinedViewState extends State { if (visibleTexts.isEmpty) return; final combinedText = visibleTexts.join('\n\n'); - final combinedHtml = visibleTexts.map(_formatTextAsHtml).join('

'); + + // קבלת ההגדרות הנוכחיות + final settingsState = context.read().state; + + String finalText = combinedText; + + // אם צריך להוסיף כותרות + if (settingsState.copyWithHeaders != 'none') { + final bookName = CopyUtils.extractBookName(state.book); + final firstVisibleIndex = state.visibleIndices.first; + final currentPath = await CopyUtils.extractCurrentPath(state.book, firstVisibleIndex); + + finalText = CopyUtils.formatTextWithHeaders( + originalText: combinedText, + copyWithHeaders: settingsState.copyWithHeaders, + copyHeaderFormat: settingsState.copyHeaderFormat, + bookName: bookName, + currentPath: currentPath, + ); + } + + final combinedHtml = finalText.split('\n\n').map(_formatTextAsHtml).join('

'); final item = DataWriterItem(); - item.add(Formats.plainText(combinedText)); + item.add(Formats.plainText(finalText)); item.add(Formats.htmlText(combinedHtml)); await SystemClipboard.instance?.write([item]); @@ -370,9 +421,11 @@ class _CombinedViewState extends State { /// עיצוב טקסט כ-HTML עם הגדרות הגופן הנוכחיות String _formatTextAsHtml(String text) { final settingsState = context.read().state; + // ממיר \n ל-
ב-HTML + final textWithBreaks = text.replaceAll('\n', '
'); return '''
-$text +$textWithBreaks
'''; } @@ -393,8 +446,29 @@ $text try { final clipboard = SystemClipboard.instance; if (clipboard != null) { + // קבלת ההגדרות הנוכחיות + final settingsState = context.read().state; + final textBookState = context.read().state; + + String finalText = text; + + // אם צריך להוסיף כותרות + if (settingsState.copyWithHeaders != 'none' && textBookState is TextBookLoaded) { + final bookName = CopyUtils.extractBookName(textBookState.book); + final currentIndex = _currentSelectedIndex ?? 0; + final currentPath = await CopyUtils.extractCurrentPath(textBookState.book, currentIndex); + + finalText = CopyUtils.formatTextWithHeaders( + originalText: text, + copyWithHeaders: settingsState.copyWithHeaders, + copyHeaderFormat: settingsState.copyHeaderFormat, + bookName: bookName, + currentPath: currentPath, + ); + } + final item = DataWriterItem(); - item.add(Formats.plainText(text)); + item.add(Formats.plainText(finalText)); await clipboard.write([item]); if (mounted) { @@ -436,6 +510,7 @@ $text if (clipboard != null) { // קבלת ההגדרות הנוכחיות לעיצוב final settingsState = context.read().state; + final textBookState = context.read().state; // ניסיון למצוא את הטקסט המקורי עם תגי HTML String htmlContentToUse = plainText; @@ -461,15 +536,42 @@ $text } } + // הוספת כותרות אם נדרש + String finalPlainText = plainText; + if (settingsState.copyWithHeaders != 'none' && textBookState is TextBookLoaded) { + final bookName = CopyUtils.extractBookName(textBookState.book); + final currentIndex = _currentSelectedIndex ?? 0; + final currentPath = await CopyUtils.extractCurrentPath(textBookState.book, currentIndex); + + finalPlainText = CopyUtils.formatTextWithHeaders( + originalText: plainText, + copyWithHeaders: settingsState.copyWithHeaders, + copyHeaderFormat: settingsState.copyHeaderFormat, + bookName: bookName, + currentPath: currentPath, + ); + + // גם עדכון ה-HTML עם הכותרות + htmlContentToUse = CopyUtils.formatTextWithHeaders( + originalText: htmlContentToUse, + copyWithHeaders: settingsState.copyWithHeaders, + copyHeaderFormat: settingsState.copyHeaderFormat, + bookName: bookName, + currentPath: currentPath, + ); + } + // יצירת HTML מעוצב עם הגדרות הגופן והגודל + // ממיר \n ל-
ב-HTML + final htmlWithBreaks = htmlContentToUse.replaceAll('\n', '
'); final finalHtmlContent = '''
-$htmlContentToUse +$htmlWithBreaks
'''; final item = DataWriterItem(); - item.add(Formats.plainText(plainText)); // טקסט רגיל כגיבוי + item.add(Formats.plainText(finalPlainText)); // טקסט רגיל כגיבוי item.add(Formats.htmlText( finalHtmlContent)); // טקסט מעוצב עם תגי HTML מקוריים await clipboard.write([item]); diff --git a/lib/text_book/view/splited_view/simple_book_view.dart b/lib/text_book/view/splited_view/simple_book_view.dart index c7e1e565a..3dbcf2efd 100644 --- a/lib/text_book/view/splited_view/simple_book_view.dart +++ b/lib/text_book/view/splited_view/simple_book_view.dart @@ -16,6 +16,7 @@ import 'package:otzaria/text_book/view/links_screen.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:otzaria/models/books.dart'; import 'package:otzaria/notes/notes_system.dart'; +import 'package:otzaria/utils/copy_utils.dart'; import 'package:super_clipboard/super_clipboard.dart'; class SimpleBookView extends StatefulWidget { @@ -312,9 +313,39 @@ class _SimpleBookViewState extends State { final text = widget.data[_currentSelectedIndex!]; if (text.trim().isEmpty) return; + // קבלת ההגדרות הנוכחיות + final settingsState = context.read().state; + final textBookState = context.read().state; + + String finalText = text; + String finalHtmlText = text; + + // אם צריך להוסיף כותרות + if (settingsState.copyWithHeaders != 'none' && textBookState is TextBookLoaded) { + final bookName = CopyUtils.extractBookName(textBookState.book); + final currentIndex = _currentSelectedIndex!; + final currentPath = await CopyUtils.extractCurrentPath(textBookState.book, currentIndex); + + finalText = CopyUtils.formatTextWithHeaders( + originalText: text, + copyWithHeaders: settingsState.copyWithHeaders, + copyHeaderFormat: settingsState.copyHeaderFormat, + bookName: bookName, + currentPath: currentPath, + ); + + finalHtmlText = CopyUtils.formatTextWithHeaders( + originalText: text, + copyWithHeaders: settingsState.copyWithHeaders, + copyHeaderFormat: settingsState.copyHeaderFormat, + bookName: bookName, + currentPath: currentPath, + ); + } + final item = DataWriterItem(); - item.add(Formats.plainText(text)); - item.add(Formats.htmlText(_formatTextAsHtml(text))); + item.add(Formats.plainText(finalText)); + item.add(Formats.htmlText(_formatTextAsHtml(finalHtmlText))); await SystemClipboard.instance?.write([item]); } @@ -335,9 +366,37 @@ class _SimpleBookViewState extends State { final text = widget.data[indexToCopy]; if (text.trim().isEmpty) return; + // קבלת ההגדרות הנוכחיות + final settingsState = context.read().state; + + String finalText = text; + String finalHtmlText = text; + + // אם צריך להוסיף כותרות + if (settingsState.copyWithHeaders != 'none') { + final bookName = CopyUtils.extractBookName(state.book); + final currentPath = await CopyUtils.extractCurrentPath(state.book, indexToCopy); + + finalText = CopyUtils.formatTextWithHeaders( + originalText: text, + copyWithHeaders: settingsState.copyWithHeaders, + copyHeaderFormat: settingsState.copyHeaderFormat, + bookName: bookName, + currentPath: currentPath, + ); + + finalHtmlText = CopyUtils.formatTextWithHeaders( + originalText: text, + copyWithHeaders: settingsState.copyWithHeaders, + copyHeaderFormat: settingsState.copyHeaderFormat, + bookName: bookName, + currentPath: currentPath, + ); + } + final item = DataWriterItem(); - item.add(Formats.plainText(text)); - item.add(Formats.htmlText(_formatTextAsHtml(text))); + item.add(Formats.plainText(finalText)); + item.add(Formats.htmlText(_formatTextAsHtml(finalHtmlText))); await SystemClipboard.instance?.write([item]); } @@ -358,10 +417,31 @@ class _SimpleBookViewState extends State { if (visibleTexts.isEmpty) return; final combinedText = visibleTexts.join('\n\n'); - final combinedHtml = visibleTexts.map(_formatTextAsHtml).join('

'); + + // קבלת ההגדרות הנוכחיות + final settingsState = context.read().state; + + String finalText = combinedText; + + // אם צריך להוסיף כותרות + if (settingsState.copyWithHeaders != 'none') { + final bookName = CopyUtils.extractBookName(state.book); + final firstVisibleIndex = state.visibleIndices.first; + final currentPath = await CopyUtils.extractCurrentPath(state.book, firstVisibleIndex); + + finalText = CopyUtils.formatTextWithHeaders( + originalText: combinedText, + copyWithHeaders: settingsState.copyWithHeaders, + copyHeaderFormat: settingsState.copyHeaderFormat, + bookName: bookName, + currentPath: currentPath, + ); + } + + final combinedHtml = finalText.split('\n\n').map(_formatTextAsHtml).join('

'); final item = DataWriterItem(); - item.add(Formats.plainText(combinedText)); + item.add(Formats.plainText(finalText)); item.add(Formats.htmlText(combinedHtml)); await SystemClipboard.instance?.write([item]); @@ -370,9 +450,11 @@ class _SimpleBookViewState extends State { /// עיצוב טקסט כ-HTML עם הגדרות הגופן הנוכחיות String _formatTextAsHtml(String text) { final settingsState = context.read().state; + // ממיר \n ל-
ב-HTML + final textWithBreaks = text.replaceAll('\n', '
'); return '''
-$text +$textWithBreaks
'''; } @@ -403,8 +485,29 @@ $text try { final clipboard = SystemClipboard.instance; if (clipboard != null) { + // קבלת ההגדרות הנוכחיות + final settingsState = context.read().state; + final textBookState = context.read().state; + + String finalText = text; + + // אם צריך להוסיף כותרות + if (settingsState.copyWithHeaders != 'none' && textBookState is TextBookLoaded) { + final bookName = CopyUtils.extractBookName(textBookState.book); + final currentIndex = _currentSelectedIndex ?? 0; + final currentPath = await CopyUtils.extractCurrentPath(textBookState.book, currentIndex); + + finalText = CopyUtils.formatTextWithHeaders( + originalText: text, + copyWithHeaders: settingsState.copyWithHeaders, + copyHeaderFormat: settingsState.copyHeaderFormat, + bookName: bookName, + currentPath: currentPath, + ); + } + final item = DataWriterItem(); - item.add(Formats.plainText(text)); + item.add(Formats.plainText(finalText)); await clipboard.write([item]); if (mounted) { @@ -446,6 +549,7 @@ $text if (clipboard != null) { // קבלת ההגדרות הנוכחיות לעיצוב final settingsState = context.read().state; + final textBookState = context.read().state; // ניסיון למצוא את הטקסט המקורי עם תגי HTML String htmlContentToUse = plainText; @@ -471,15 +575,42 @@ $text } } + // הוספת כותרות אם נדרש + String finalPlainText = plainText; + if (settingsState.copyWithHeaders != 'none' && textBookState is TextBookLoaded) { + final bookName = CopyUtils.extractBookName(textBookState.book); + final currentIndex = _currentSelectedIndex ?? 0; + final currentPath = await CopyUtils.extractCurrentPath(textBookState.book, currentIndex); + + finalPlainText = CopyUtils.formatTextWithHeaders( + originalText: plainText, + copyWithHeaders: settingsState.copyWithHeaders, + copyHeaderFormat: settingsState.copyHeaderFormat, + bookName: bookName, + currentPath: currentPath, + ); + + // גם עדכון ה-HTML עם הכותרות + htmlContentToUse = CopyUtils.formatTextWithHeaders( + originalText: htmlContentToUse, + copyWithHeaders: settingsState.copyWithHeaders, + copyHeaderFormat: settingsState.copyHeaderFormat, + bookName: bookName, + currentPath: currentPath, + ); + } + // יצירת HTML מעוצב עם הגדרות הגופן והגודל + // ממיר \n ל-
ב-HTML + final htmlWithBreaks = htmlContentToUse.replaceAll('\n', '
'); final finalHtmlContent = '''
-$htmlContentToUse +$htmlWithBreaks
'''; final item = DataWriterItem(); - item.add(Formats.plainText(plainText)); // טקסט רגיל כגיבוי + item.add(Formats.plainText(finalPlainText)); // טקסט רגיל כגיבוי item.add(Formats.htmlText( finalHtmlContent)); // טקסט מעוצב עם תגי HTML מקוריים await clipboard.write([item]); diff --git a/lib/text_book/view/text_book_screen.dart b/lib/text_book/view/text_book_screen.dart index 12b82825a..a32a8f790 100644 --- a/lib/text_book/view/text_book_screen.dart +++ b/lib/text_book/view/text_book_screen.dart @@ -78,7 +78,7 @@ class _TextBookViewerBlocState extends State static const String _reportSeparator2 = '------------------------------'; static const String _fallbackMail = 'otzaria.200@gmail.com'; bool _isInitialFocusDone = false; - + // משתנים לשמירת נתונים כבדים שנטענים ברקע Future>? _preloadedHeavyData; bool _isLoadingHeavyData = false; @@ -153,9 +153,8 @@ class _TextBookViewerBlocState extends State } } - final ctxStart = (startWordIndex - wordsBefore) < 0 - ? 0 - : (startWordIndex - wordsBefore); + final ctxStart = + (startWordIndex - wordsBefore) < 0 ? 0 : (startWordIndex - wordsBefore); final ctxEnd = (endWordIndex + wordsAfter) >= matches.length ? matches.length - 1 : (endWordIndex + wordsAfter); @@ -724,7 +723,8 @@ class _TextBookViewerBlocState extends State } /// Get preloaded heavy data or load it if not ready - Future> _getPreloadedHeavyData(TextBookLoaded state) async { + Future> _getPreloadedHeavyData( + TextBookLoaded state) async { if (_preloadedHeavyData != null) { return await _preloadedHeavyData!; } else { @@ -768,9 +768,9 @@ class _TextBookViewerBlocState extends State /// Start loading heavy data in background immediately after dialog opens void _startLoadingHeavyDataInBackground(TextBookLoaded state) { if (_isLoadingHeavyData) return; // כבר טוען - + _isLoadingHeavyData = true; - + // התחל טעינה ברקע _preloadedHeavyData = _loadHeavyDataForRegularReport(state).then((data) { _isLoadingHeavyData = false; @@ -843,8 +843,10 @@ class _TextBookViewerBlocState extends State ) { final detailsSection = (() { final base = errorDetails.isEmpty ? '' : '\n$errorDetails'; - final extra = '\n\nמספר שורה: ' + lineNumber.toString() + - '\nהקשר (4 מילים לפני ואחרי):\n' + contextText; + final extra = '\n\nמספר שורה: ' + + lineNumber.toString() + + '\nהקשר (4 מילים לפני ואחרי):\n' + + contextText; return base + extra; })(); @@ -1720,7 +1722,8 @@ class _RegularReportTabState extends State<_RegularReportTab> { final text = widget.visibleText; final start = _selectionStart ?? -1; final end = _selectionEnd ?? -1; - final hasSel = start >= 0 && end > start && end <= text.length; + final hasSel = + start >= 0 && end > start && end <= text.length; if (!hasSel) { return [TextSpan(text: text)]; } @@ -1729,12 +1732,14 @@ class _RegularReportTabState extends State<_RegularReportTab> { .primary .withOpacity(0.25); return [ - if (start > 0) TextSpan(text: text.substring(0, start)), + if (start > 0) + TextSpan(text: text.substring(0, start)), TextSpan( text: text.substring(start, end), style: TextStyle(backgroundColor: highlight), ), - if (end < text.length) TextSpan(text: text.substring(end)), + if (end < text.length) + TextSpan(text: text.substring(end)), ]; }(), style: TextStyle( diff --git a/lib/utils/copy_utils.dart b/lib/utils/copy_utils.dart new file mode 100644 index 000000000..76cf88732 --- /dev/null +++ b/lib/utils/copy_utils.dart @@ -0,0 +1,334 @@ +import 'package:flutter/foundation.dart'; +import 'package:otzaria/models/books.dart'; + +class CopyUtils { + /// מחלץ את שם הספר מהכותרת או מהקובץ + static String extractBookName(TextBook book) { + // שם הספר הוא הכותרת של הספר + return book.title; + } + + /// מחלץ את הנתיב ההיררכי הנוכחי בספר + static Future extractCurrentPath( + TextBook book, + int currentIndex, + ) async { + try { + final toc = await book.tableOfContents; + if (toc.isEmpty) { + if (kDebugMode) { + print('CopyUtils: TOC is empty for book ${book.title}'); + } + return _tryExtractFromIndex(book, currentIndex); + } + + // מוצא את כל הכותרות הרלוונטיות לאינדקס הנוכחי + Map levelHeaders = {}; // רמה -> כותרת + + // אם יש רק כותרת אחת, נבדוק אם היא רלוונטית + if (toc.length == 1) { + final entry = toc[0]; + if (currentIndex >= entry.index) { + String cleanText = + entry.text.replaceAll(RegExp(r'<[^>]*>'), '').trim(); + if (cleanText.isNotEmpty && cleanText != book.title) { + levelHeaders[entry.level] = cleanText; + if (kDebugMode) { + print( + 'CopyUtils: Single TOC entry found: level=${entry.level}, text="$cleanText"'); + } + } + } + } + + if (kDebugMode) { + print( + 'CopyUtils: Looking for headers for index $currentIndex in book ${book.title}'); + print('CopyUtils: TOC has ${toc.length} entries'); + for (int i = 0; i < toc.length; i++) { + final entry = toc[i]; + print( + 'CopyUtils: TOC[$i]: index=${entry.index}, level=${entry.level}, text="${entry.text}"'); + } + } + + // עובר על כל הכותרות ומוצא את אלו שהאינדקס הנוכחי נמצא אחריהן + for (int i = 0; i < toc.length; i++) { + final entry = toc[i]; + + if (kDebugMode) { + print( + 'CopyUtils: Checking entry $i: index=${entry.index}, currentIndex=$currentIndex'); + } + + // בודק אם האינדקס הנוכחי נמצא אחרי הכותרת הזו + if (currentIndex >= entry.index) { + if (kDebugMode) { + print( + 'CopyUtils: Current index >= entry index, checking if active...'); + } + + // בודק אם יש כותרת אחרת באותה רמה או נמוכה יותר שמגיעה אחרי האינדקס הנוכחי + bool isActive = true; + + for (int j = i + 1; j < toc.length; j++) { + final nextEntry = toc[j]; + if (nextEntry.index > currentIndex && + nextEntry.level <= entry.level) { + if (kDebugMode) { + print( + 'CopyUtils: Found blocking entry at $j: index=${nextEntry.index}, level=${nextEntry.level}'); + } + isActive = false; + break; + } + } + + if (kDebugMode) { + print('CopyUtils: Entry $i is active: $isActive'); + } + + if (isActive) { + // מנקה את הטקסט מתגי HTML + String cleanText = + entry.text.replaceAll(RegExp(r'<[^>]*>'), '').trim(); + + if (kDebugMode) { + print( + 'CopyUtils: Clean text: "$cleanText", book title: "${book.title}"'); + } + + if (cleanText.isNotEmpty && cleanText != book.title) { + levelHeaders[entry.level] = cleanText; + if (kDebugMode) { + print( + 'CopyUtils: Found active header at level ${entry.level}: "$cleanText"'); + } + } else if (kDebugMode) { + print('CopyUtils: Skipping header (empty or matches book title)'); + } + } + } else if (kDebugMode) { + print('CopyUtils: Current index < entry index, skipping'); + } + } + + // בונה את הנתיב מהרמות בסדר עולה + List pathParts = []; + final sortedLevels = levelHeaders.keys.toList()..sort(); + + for (final level in sortedLevels) { + final header = levelHeaders[level]; + if (header != null) { + pathParts.add(header); + } + } + + final result = pathParts.join(' '); + if (kDebugMode) { + print('CopyUtils: Final path: "$result"'); + } + + // אם לא מצאנו נתיב מה-TOC, ננסה לחלץ מהאינדקס + if (result.isEmpty) { + if (kDebugMode) { + print( + 'CopyUtils: No path found in TOC, trying to extract from index'); + } + return _tryExtractFromIndex(book, currentIndex); + } + + return result; + } catch (e) { + if (kDebugMode) { + print('CopyUtils: Error extracting path: $e'); + } + return ''; + } + } + + /// מעצב את הטקסט עם הכותרות לפי ההגדרות + static String formatTextWithHeaders({ + required String originalText, + required String copyWithHeaders, + required String copyHeaderFormat, + required String bookName, + required String currentPath, + }) { + if (kDebugMode) { + print('CopyUtils: formatTextWithHeaders called with:'); + print(' copyWithHeaders: $copyWithHeaders'); + print(' copyHeaderFormat: $copyHeaderFormat'); + print(' bookName: "$bookName"'); + print(' currentPath: "$currentPath"'); + } + + // אם לא רוצים כותרות, מחזירים את הטקסט המקורי + if (copyWithHeaders == 'none') { + return originalText; + } + + // בונים את הכותרת + String header = ''; + if (copyWithHeaders == 'book_name') { + header = bookName; + } else if (copyWithHeaders == 'book_and_path') { + if (currentPath.isNotEmpty) { + // בודקים אם הנתיב כבר מכיל את שם הספר + if (currentPath.startsWith(bookName)) { + // הנתיב כבר מכיל את שם הספר, לא נוסיף אותו שוב + header = currentPath; + } else { + // הנתיב לא מכיל את שם הספר, נוסיף אותו + header = '$bookName $currentPath'; + } + } else { + header = bookName; + } + } + + if (kDebugMode) { + print('CopyUtils: Generated header: "$header"'); + } + + if (header.isEmpty) { + return originalText; + } + + // מעצבים לפי סוג העיצוב + String result; + switch (copyHeaderFormat) { + case 'same_line_after_brackets': + result = '$originalText ($header)'; + break; + case 'same_line_after_no_brackets': + result = '$originalText $header'; + break; + case 'same_line_before_brackets': + result = '($header) $originalText'; + break; + case 'same_line_before_no_brackets': + result = '$header $originalText'; + break; + case 'separate_line_after': + result = '$originalText\n$header'; + break; + case 'separate_line_before': + result = '$header\n$originalText'; + break; + default: + result = '$originalText ($header)'; + break; + } + + if (kDebugMode) { + print( + 'CopyUtils: Final formatted text: "${result.replaceAll('\n', '\\n')}"'); + } + + return result; + } + + /// מנסה לחלץ מידע מהאינדקס כשאין TOC מפורט + static String _tryExtractFromIndex(TextBook book, int currentIndex) { + if (kDebugMode) { + print( + 'CopyUtils: Trying to extract from index $currentIndex for book ${book.title}'); + } + + // לספרי תלמוד - ננסה לחשב דף לפי אינדקס + if (book.title.contains('ברכות') || + book.title.contains('שבת') || + book.title.contains('עירובין') || + _isTalmudBook(book.title)) { + // הנחה: כל דף מכיל בערך 20-30 שורות טקסט + // זה רק הערכה גסה, אבל יכול לעזור + final estimatedPage = (currentIndex ~/ 25) + 2; // מתחילים מדף ב' + final pageText = 'דף ${_numberToHebrew(estimatedPage)}.'; + + if (kDebugMode) { + print('CopyUtils: Estimated page for Talmud: $pageText'); + } + + return pageText; + } + + // לספרים אחרים - אם האינדקס גדול מ-0, ננסה לתת מידע כללי + if (currentIndex > 0) { + return 'פסקה ${currentIndex + 1}'; + } + + return ''; + } + + /// בודק אם זה ספר תלמוד + static bool _isTalmudBook(String title) { + final talmudBooks = [ + 'ברכות', + 'שבת', + 'עירובין', + 'פסחים', + 'שקלים', + 'יומא', + 'סוכה', + 'ביצה', + 'ראש השנה', + 'תענית', + 'מגילה', + 'מועד קטן', + 'חגיגה', + 'יבמות', + 'כתובות', + 'נדרים', + 'נזיר', + 'סוטה', + 'גיטין', + 'קידושין', + 'בבא קמא', + 'בבא מציעא', + 'בבא בתרא', + 'סנהדרין', + 'מכות', + 'שבועות', + 'עבודה זרה', + 'הוריות', + 'זבחים', + 'מנחות', + 'חולין', + 'בכורות', + 'ערכין', + 'תמורה', + 'כריתות', + 'מעילה', + 'תמיד', + 'מדות', + 'קינים', + 'נדה' + ]; + + return talmudBooks.any((book) => title.contains(book)); + } + + /// ממיר מספר לעברית (פשוט) + static String _numberToHebrew(int number) { + if (number <= 0) return ''; + + final ones = ['', 'א', 'ב', 'ג', 'ד', 'ה', 'ו', 'ז', 'ח', 'ט']; + final tens = ['', '', 'כ', 'ל', 'מ', 'נ', 'ס', 'ע', 'פ', 'צ']; + final hundreds = ['', 'ק', 'ר', 'ש', 'ת']; + + if (number < 10) { + return ones[number]; + } else if (number < 100) { + final ten = number ~/ 10; + final one = number % 10; + return tens[ten] + ones[one]; + } else if (number < 400) { + final hundred = number ~/ 100; + final remainder = number % 100; + return hundreds[hundred] + _numberToHebrew(remainder); + } + + return number.toString(); // fallback למספרים גדולים + } +} diff --git a/test/unit/settings/settings_bloc_test.dart b/test/unit/settings/settings_bloc_test.dart index c75bd4380..02b4d1cf6 100644 --- a/test/unit/settings/settings_bloc_test.dart +++ b/test/unit/settings/settings_bloc_test.dart @@ -45,6 +45,8 @@ void main() { 'pinSidebar': true, 'sidebarWidth': 300.0, 'facetFilteringWidth': 235.0, + 'copyWithHeaders': 'none', + 'copyHeaderFormat': 'same_line_after_brackets', }; blocTest( @@ -76,6 +78,8 @@ void main() { pinSidebar: mockSettings['pinSidebar'] as bool, sidebarWidth: mockSettings['sidebarWidth'] as double, facetFilteringWidth: mockSettings['facetFilteringWidth'] as double, + copyWithHeaders: mockSettings['copyWithHeaders'] as String, + copyHeaderFormat: mockSettings['copyHeaderFormat'] as String, ), ], verify: (_) { From c78e41a284066c48dbb9c2d3dc407c50adc606b7 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Fri, 12 Sep 2025 00:59:08 +0300 Subject: [PATCH 184/197] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20=D7=92?= =?UTF-8?q?=D7=A8=D7=A1=D7=94=20=D7=9C950?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++-- installer/otzaria.iss | 2 +- installer/otzaria_full.iss | 2 +- pubspec.yaml | 4 ++-- version.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index ca8403f96..bee6ea046 100644 --- a/.gitignore +++ b/.gitignore @@ -60,7 +60,7 @@ android/ build/ fonts/ -installer/otzaria-0.9.49-windows.exe -installer/otzaria-0.9.49-windows-full.exe +installer/otzaria-0.9.50-windows.exe +installer/otzaria-0.9.50-windows-full.exe pubspec.lock flutter/ diff --git a/installer/otzaria.iss b/installer/otzaria.iss index ac3417104..f8198fa3f 100644 --- a/installer/otzaria.iss +++ b/installer/otzaria.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.49" +#define MyAppVersion "0.9.50" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/installer/otzaria_full.iss b/installer/otzaria_full.iss index 4fea2a1df..b484ffe2a 100644 --- a/installer/otzaria_full.iss +++ b/installer/otzaria_full.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.49" +#define MyAppVersion "0.9.50" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/pubspec.yaml b/pubspec.yaml index 2cc71e7a3..45b7b3087 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,7 @@ msix_config: publisher_display_name: sivan22 identity_name: sivan22.Otzaria description: "ספריית ספרים יהודיים עם אפשרויות חיפוש חכם" - msix_version: 0.9.49.0 + msix_version: 0.9.50.0 logo_path: assets/icon/icon.png publisher: CN=sivan22, O=sivan22, C=IL certificate_path: sivan22.pfx @@ -36,7 +36,7 @@ msix_config: # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.9.49 +version: 0.9.50 environment: sdk: ">=3.2.6 <4.0.0" diff --git a/version.json b/version.json index e7445a3f8..ba872941b 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "0.9.49" + "version": "0.9.50" } \ No newline at end of file From 92800fa56b955dc5f7541e943bc1993d77abd040 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sat, 13 Sep 2025 23:20:33 +0300 Subject: [PATCH 185/197] =?UTF-8?q?=D7=97=D7=99=D7=A4=D7=95=D7=A9=20=D7=90?= =?UTF-8?q?=D7=99=D7=A8=D7=95=D7=A2=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/navigation/calendar_cubit.dart | 32 +++++++++ lib/navigation/calendar_widget.dart | 106 ++++++++++++++++++++++++---- 2 files changed, 125 insertions(+), 13 deletions(-) diff --git a/lib/navigation/calendar_cubit.dart b/lib/navigation/calendar_cubit.dart index 47edf7d35..a00ac7391 100644 --- a/lib/navigation/calendar_cubit.dart +++ b/lib/navigation/calendar_cubit.dart @@ -19,6 +19,8 @@ class CalendarState extends Equatable { final CalendarType calendarType; final CalendarView calendarView; final List events; + final String eventSearchQuery; + final bool searchInDescriptions; const CalendarState({ required this.selectedJewishDate, @@ -30,6 +32,8 @@ class CalendarState extends Equatable { required this.calendarType, required this.calendarView, this.events = const [], + this.eventSearchQuery = '', + this.searchInDescriptions = false, }); factory CalendarState.initial() { @@ -46,6 +50,7 @@ class CalendarState extends Equatable { calendarType: CalendarType.combined, // ברירת מחדל, יעודכן ב-_initializeCalendar calendarView: CalendarView.month, + searchInDescriptions: false, // ברירת מחדל: חיפוש רק בכותרת ); } @@ -59,6 +64,8 @@ class CalendarState extends Equatable { CalendarType? calendarType, CalendarView? calendarView, List? events, + String? eventSearchQuery, + bool? searchInDescriptions, }) { return CalendarState( selectedJewishDate: selectedJewishDate ?? this.selectedJewishDate, @@ -71,6 +78,8 @@ class CalendarState extends Equatable { calendarType: calendarType ?? this.calendarType, calendarView: calendarView ?? this.calendarView, events: events ?? this.events, + eventSearchQuery: eventSearchQuery ?? this.eventSearchQuery, + searchInDescriptions: searchInDescriptions ?? this.searchInDescriptions, ); } @@ -86,6 +95,9 @@ class CalendarState extends Equatable { // events – ensure rebuild on changes events, + eventSearchQuery, + searchInDescriptions, + // "פירקנו" גם את התאריך של תצוגת החודש currentJewishDate.getJewishYear(), currentJewishDate.getJewishMonth(), @@ -260,6 +272,14 @@ class CalendarCubit extends Cubit { )); } + void setEventSearchQuery(String query) { + emit(state.copyWith(eventSearchQuery: query)); + } + + void toggleSearchInDescriptions(bool value) { + emit(state.copyWith(searchInDescriptions: value)); + } + Map shortTimesFor(DateTime date) { final full = _calculateDailyTimes(date, state.selectedCity); return { @@ -356,6 +376,18 @@ class CalendarCubit extends Cubit { ..sort((a, b) => a.title.compareTo(b.title)); } + List getFilteredEvents(String query) { + if (query.isEmpty) { + return []; + } + return state.events + .where((e) => + e.title.contains(query) || + (state.searchInDescriptions && e.description.contains(query))) + .toList() + ..sort((a, b) => a.title.compareTo(b.title)); + } + // שמירת אירועים לאחסון קבוע Future _saveEventsToStorage(List events) async { try { diff --git a/lib/navigation/calendar_widget.dart b/lib/navigation/calendar_widget.dart index 192a23a61..bb2b4f62b 100644 --- a/lib/navigation/calendar_widget.dart +++ b/lib/navigation/calendar_widget.dart @@ -1186,6 +1186,23 @@ class CalendarWidget extends StatelessWidget { return months[month - 1]; } + String _truncateDescription(String description) { + const int maxLength = 50; // Adjust as needed + if (description.length <= maxLength) { + return description; + } + return '${description.substring(0, maxLength)}...'; + } + + String _formatEventDate(DateTime date) { + final jewishDate = JewishDate.fromDateTime(date); + final hebrewStr = + '${_formatHebrewDay(jewishDate.getJewishDayOfMonth())} ${hebrewMonths[jewishDate.getJewishMonth() - 1]}'; + final gregorianStr = + '${date.day} ${_getGregorianMonthName(date.month)} ${date.year}'; + return '$hebrewStr • $gregorianStr'; + } + // פונקציות עזר חדשות לפענוח תאריך עברי int _hebrewNumberToInt(String hebrew) { final Map hebrewValue = { @@ -1426,6 +1443,14 @@ class CalendarWidget extends StatelessWidget { // משתנה חדש שבודק אם האירוע מוגדר כ"תמיד" bool recurForever = existingEvent?.recurringYears == null; + // קביעת התאריכים המוצגים - לפי האירוע אם עריכה, אחרת לפי הנבחר + final displayedGregorianDate = existingEvent != null + ? existingEvent.baseGregorianDate + : state.selectedGregorianDate; + final displayedJewishDate = existingEvent != null + ? JewishDate.fromDateTime(existingEvent.baseGregorianDate) + : state.selectedJewishDate; + showDialog( context: context, builder: (dialogContext) { @@ -1457,7 +1482,7 @@ class CalendarWidget extends StatelessWidget { ), const SizedBox(height: 16), - // תאריך נבחר + // תאריך נבחר - השתמש בתאריכים המוצגים Container( width: double.infinity, padding: const EdgeInsets.all(12), @@ -1469,12 +1494,12 @@ class CalendarWidget extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'תאריך לועזי: ${state.selectedGregorianDate.day}/${state.selectedGregorianDate.month}/${state.selectedGregorianDate.year}', + 'תאריך לועזי: ${displayedGregorianDate.day}/${displayedGregorianDate.month}/${displayedGregorianDate.year}', style: const TextStyle(fontWeight: FontWeight.bold), ), Text( - 'תאריך עברי: ${_formatHebrewDay(state.selectedJewishDate.getJewishDayOfMonth())} ${hebrewMonths[state.selectedJewishDate.getJewishMonth() - 1]} ${_formatHebrewYear(state.selectedJewishDate.getJewishYear())}', + 'תאריך עברי: ${_formatHebrewDay(displayedJewishDate.getJewishDayOfMonth())} ${hebrewMonths[displayedJewishDate.getJewishMonth() - 1]} ${_formatHebrewYear(displayedJewishDate.getJewishYear())}', style: const TextStyle(fontWeight: FontWeight.bold), ), @@ -1506,12 +1531,12 @@ class CalendarWidget extends StatelessWidget { DropdownMenuItem( value: true, child: Text( - 'לוח עברי (${_formatHebrewDay(state.selectedJewishDate.getJewishDayOfMonth())} ${hebrewMonths[state.selectedJewishDate.getJewishMonth() - 1]})'), + 'לוח עברי (${_formatHebrewDay(displayedJewishDate.getJewishDayOfMonth())} ${hebrewMonths[displayedJewishDate.getJewishMonth() - 1]})'), ), DropdownMenuItem( value: false, child: Text( - 'לוח לועזי (${state.selectedGregorianDate.day}/${state.selectedGregorianDate.month})'), + 'לוח לועזי (${displayedGregorianDate.day}/${displayedGregorianDate.month})'), ), ], onChanged: (value) => setState( @@ -1682,20 +1707,66 @@ class CalendarWidget extends StatelessWidget { ], ), const SizedBox(height: 16), - _buildEventsList(context, state), + TextField( + onChanged: (query) => + context.read().setEventSearchQuery(query), + decoration: InputDecoration( + hintText: 'חפש אירועים...', + prefixIcon: const Icon(Icons.search), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (state.eventSearchQuery.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + tooltip: 'נקה חיפוש', + onPressed: () { + context.read().setEventSearchQuery(''); + }, + ), + IconButton( + icon: Icon(state.searchInDescriptions + ? Icons.description_outlined + : Icons.title), + tooltip: state.searchInDescriptions + ? 'חפש גם בתיאור' + : 'חפש רק בכותרת', + onPressed: () => context + .read() + .toggleSearchInDescriptions( + !state.searchInDescriptions), + ), + ], + ), + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 4), + _buildEventsList(context, state, + isSearch: state.eventSearchQuery.isNotEmpty), ], ), ), ); } - Widget _buildEventsList(BuildContext context, CalendarState state) { - final events = context - .read() - .eventsForDate(state.selectedGregorianDate); + Widget _buildEventsList(BuildContext context, CalendarState state, + {bool isSearch = false}) { + final cubit = context.read(); + final List events; + + if (state.eventSearchQuery.isNotEmpty) { + events = cubit.getFilteredEvents(state.eventSearchQuery); + } else { + events = []; // Show nothing when search is empty + } if (events.isEmpty) { - return const Center(child: Text('אין אירועים ליום זה')); + if (state.eventSearchQuery.isNotEmpty) { + return const Center(child: Text('לא נמצאו אירועים מתאימים')); + } else { + return const SizedBox(); // Empty when no search + } } return ListView.builder( @@ -1728,16 +1799,25 @@ class CalendarWidget extends StatelessWidget { fontSize: 14, ), ), + const SizedBox(height: 4), if (event.description.isNotEmpty) ...[ - const SizedBox(height: 4), Text( - event.description, + _truncateDescription(event.description), style: TextStyle( fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), + const SizedBox(height: 4), ], + Text( + _formatEventDate(event.baseGregorianDate), + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), if (event.recurring) ...[ const SizedBox(height: 4), Row( From d99564b3e08615bcdc653fd19db383af3e21cdeb Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sat, 13 Sep 2025 23:30:49 +0300 Subject: [PATCH 186/197] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20=D7=9C?= =?UTF-8?q?951?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++-- installer/otzaria.iss | 2 +- installer/otzaria_full.iss | 2 +- pubspec.yaml | 4 ++-- version.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index bee6ea046..0ba9938ef 100644 --- a/.gitignore +++ b/.gitignore @@ -60,7 +60,7 @@ android/ build/ fonts/ -installer/otzaria-0.9.50-windows.exe -installer/otzaria-0.9.50-windows-full.exe +installer/otzaria-0.9.51-windows.exe +installer/otzaria-0.9.51-windows-full.exe pubspec.lock flutter/ diff --git a/installer/otzaria.iss b/installer/otzaria.iss index f8198fa3f..1afb525d8 100644 --- a/installer/otzaria.iss +++ b/installer/otzaria.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.50" +#define MyAppVersion "0.9.51" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/installer/otzaria_full.iss b/installer/otzaria_full.iss index b484ffe2a..5ab6b83b7 100644 --- a/installer/otzaria_full.iss +++ b/installer/otzaria_full.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.50" +#define MyAppVersion "0.9.51" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/pubspec.yaml b/pubspec.yaml index 45b7b3087..6da721d50 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,7 @@ msix_config: publisher_display_name: sivan22 identity_name: sivan22.Otzaria description: "ספריית ספרים יהודיים עם אפשרויות חיפוש חכם" - msix_version: 0.9.50.0 + msix_version: 0.9.51.0 logo_path: assets/icon/icon.png publisher: CN=sivan22, O=sivan22, C=IL certificate_path: sivan22.pfx @@ -36,7 +36,7 @@ msix_config: # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.9.50 +version: 0.9.51 environment: sdk: ">=3.2.6 <4.0.0" diff --git a/version.json b/version.json index ba872941b..d4df9cf73 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "0.9.50" + "version": "0.9.51" } \ No newline at end of file From 7a8476d68f40a99229e83338ab3ccfb6ff78799b Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sat, 13 Sep 2025 23:54:16 +0300 Subject: [PATCH 187/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=9B?= =?UTF-8?q?=D7=A4=D7=AA=D7=95=D7=A8=D7=99=20=D7=94=D7=97=D7=99=D7=A6=D7=99?= =?UTF-8?q?=D7=9D=20=D7=91=D7=9C=D7=95=D7=97=20=D7=94=D7=A9=D7=A0=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/navigation/calendar_cubit.dart | 133 ++++++++++++++++++++++++---- lib/navigation/calendar_widget.dart | 47 +++++++--- 2 files changed, 151 insertions(+), 29 deletions(-) diff --git a/lib/navigation/calendar_cubit.dart b/lib/navigation/calendar_cubit.dart index a00ac7391..8d5e94c3f 100644 --- a/lib/navigation/calendar_cubit.dart +++ b/lib/navigation/calendar_cubit.dart @@ -169,13 +169,19 @@ class CalendarCubit extends Cubit { _settingsRepository.updateSelectedCity(newCity); } - void previousMonth() { + void _previousMonth() { if (state.calendarType == CalendarType.gregorian) { final current = state.currentGregorianDate; final newDate = current.month == 1 ? DateTime(current.year - 1, 12, 1) : DateTime(current.year, current.month - 1, 1); - emit(state.copyWith(currentGregorianDate: newDate)); + final newTimes = _calculateDailyTimes(newDate, state.selectedCity); + emit(state.copyWith( + currentGregorianDate: newDate, + selectedGregorianDate: newDate, + selectedJewishDate: JewishDate.fromDateTime(newDate), + dailyTimes: newTimes, + )); } else { final current = state.currentJewishDate; final newJewishDate = JewishDate(); @@ -198,17 +204,30 @@ class CalendarCubit extends Cubit { 1, ); } - emit(state.copyWith(currentJewishDate: newJewishDate)); + final newGregorian = newJewishDate.getGregorianCalendar(); + final newTimes = _calculateDailyTimes(newGregorian, state.selectedCity); + emit(state.copyWith( + currentJewishDate: newJewishDate, + selectedGregorianDate: newGregorian, + selectedJewishDate: newJewishDate, + dailyTimes: newTimes, + )); } } - void nextMonth() { + void _nextMonth() { if (state.calendarType == CalendarType.gregorian) { final current = state.currentGregorianDate; final newDate = current.month == 12 ? DateTime(current.year + 1, 1, 1) : DateTime(current.year, current.month + 1, 1); - emit(state.copyWith(currentGregorianDate: newDate)); + final newTimes = _calculateDailyTimes(newDate, state.selectedCity); + emit(state.copyWith( + currentGregorianDate: newDate, + selectedGregorianDate: newDate, + selectedJewishDate: JewishDate.fromDateTime(newDate), + dailyTimes: newTimes, + )); } else { final current = state.currentJewishDate; final newJewishDate = JewishDate(); @@ -231,10 +250,61 @@ class CalendarCubit extends Cubit { 1, ); } - emit(state.copyWith(currentJewishDate: newJewishDate)); + final newGregorian = newJewishDate.getGregorianCalendar(); + final newTimes = _calculateDailyTimes(newGregorian, state.selectedCity); + emit(state.copyWith( + currentJewishDate: newJewishDate, + selectedGregorianDate: newGregorian, + selectedJewishDate: newJewishDate, + dailyTimes: newTimes, + )); } } + void _previousWeek() { + final newDate = state.selectedGregorianDate.subtract(Duration(days: 7)); + final newJewishDate = JewishDate.fromDateTime(newDate); + final newTimes = _calculateDailyTimes(newDate, state.selectedCity); + emit(state.copyWith( + selectedGregorianDate: newDate, + selectedJewishDate: newJewishDate, + dailyTimes: newTimes, + )); + } + + void _nextWeek() { + final newDate = state.selectedGregorianDate.add(Duration(days: 7)); + final newJewishDate = JewishDate.fromDateTime(newDate); + final newTimes = _calculateDailyTimes(newDate, state.selectedCity); + emit(state.copyWith( + selectedGregorianDate: newDate, + selectedJewishDate: newJewishDate, + dailyTimes: newTimes, + )); + } + + void _previousDay() { + final newDate = state.selectedGregorianDate.subtract(Duration(days: 1)); + final newJewishDate = JewishDate.fromDateTime(newDate); + final newTimes = _calculateDailyTimes(newDate, state.selectedCity); + emit(state.copyWith( + selectedGregorianDate: newDate, + selectedJewishDate: newJewishDate, + dailyTimes: newTimes, + )); + } + + void _nextDay() { + final newDate = state.selectedGregorianDate.add(Duration(days: 1)); + final newJewishDate = JewishDate.fromDateTime(newDate); + final newTimes = _calculateDailyTimes(newDate, state.selectedCity); + emit(state.copyWith( + selectedGregorianDate: newDate, + selectedJewishDate: newJewishDate, + dailyTimes: newTimes, + )); + } + void changeCalendarType(CalendarType type) { emit(state.copyWith(calendarType: type)); // שמור את הבחירה בהגדרות @@ -245,6 +315,34 @@ class CalendarCubit extends Cubit { emit(state.copyWith(calendarView: view)); } + void previous() { + switch (state.calendarView) { + case CalendarView.month: + _previousMonth(); + break; + case CalendarView.week: + _previousWeek(); + break; + case CalendarView.day: + _previousDay(); + break; + } + } + + void next() { + switch (state.calendarView) { + case CalendarView.month: + _nextMonth(); + break; + case CalendarView.week: + _nextWeek(); + break; + case CalendarView.day: + _nextDay(); + break; + } + } + void jumpToToday() { final now = DateTime.now(); final jewishNow = JewishDate(); @@ -888,15 +986,20 @@ void _addSpecialTimes(Map times, JewishCalendar jewishCalendar, } // זמני קידוש לבנה - final tchilasKidushLevana = zmanimCalendar.getTchilasZmanKidushLevana3Days(); - final sofZmanKidushLevana = - zmanimCalendar.getSofZmanKidushLevanaBetweenMoldos(); - - if (tchilasKidushLevana != null) { - times['tchilasKidushLevana'] = _formatTime(tchilasKidushLevana); - } - if (sofZmanKidushLevana != null) { - times['sofZmanKidushLevana'] = _formatTime(sofZmanKidushLevana); + try { + final tchilasKidushLevana = + zmanimCalendar.getTchilasZmanKidushLevana3Days(); + final sofZmanKidushLevana = + zmanimCalendar.getSofZmanKidushLevanaBetweenMoldos(); + + if (tchilasKidushLevana != null) { + times['tchilasKidushLevana'] = _formatTime(tchilasKidushLevana); + } + if (sofZmanKidushLevana != null) { + times['sofZmanKidushLevana'] = _formatTime(sofZmanKidushLevana); + } + } catch (e) { + // Ignore errors in calculating moon times for certain dates } } diff --git a/lib/navigation/calendar_widget.dart b/lib/navigation/calendar_widget.dart index bb2b4f62b..59fa275b1 100644 --- a/lib/navigation/calendar_widget.dart +++ b/lib/navigation/calendar_widget.dart @@ -190,14 +190,13 @@ class CalendarWidget extends StatelessWidget { margin: const EdgeInsets.symmetric(horizontal: 4), ), - // מעבר בין חודשים + // מעבר בין תקופות IconButton( - onPressed: () => - context.read().previousMonth(), + onPressed: () => context.read().previous(), icon: const Icon(Icons.chevron_left), ), IconButton( - onPressed: () => context.read().nextMonth(), + onPressed: () => context.read().next(), icon: const Icon(Icons.chevron_right), ), ], @@ -1099,23 +1098,36 @@ class CalendarWidget extends StatelessWidget { // פונקציות העזר שלא תלויות במצב נשארות כאן String _getCurrentMonthYearText(CalendarState state) { - final gregName = _getGregorianMonthName(state.currentGregorianDate.month); - final gregNum = state.currentGregorianDate.month; - final hebName = hebrewMonths[state.currentJewishDate.getJewishMonth() - 1]; - final hebYear = _formatHebrewYear(state.currentJewishDate.getJewishYear()); + DateTime gregorianDate; + JewishDate jewishDate; + + // For month view, use current dates (month reference) + // For week/day views, use selected dates (what's being viewed) + if (state.calendarView == CalendarView.month) { + gregorianDate = state.currentGregorianDate; + jewishDate = state.currentJewishDate; + } else { + gregorianDate = state.selectedGregorianDate; + jewishDate = state.selectedJewishDate; + } + + final gregName = _getGregorianMonthName(gregorianDate.month); + final gregNum = gregorianDate.month; + final hebName = hebrewMonths[jewishDate.getJewishMonth() - 1]; + final hebYear = _formatHebrewYear(jewishDate.getJewishYear()); // Show both calendars for clarity - return '$hebName $hebYear • $gregName ($gregNum) ${state.currentGregorianDate.year}'; + return '$hebName $hebYear • $gregName ($gregNum) ${gregorianDate.year}'; } String _formatHebrewYear(int year) { final thousands = year ~/ 1000; final remainder = year % 1000; if (thousands == 5) { - final hebrewNumber = _numberToHebrewWithoutQuotes(remainder); - return 'ה\'$hebrewNumber'; + final hebrewRemainder = _numberToHebrewWithQuotes(remainder); + return 'ה\'$hebrewRemainder'; } else { - return _numberToHebrewWithoutQuotes(year); + return _numberToHebrewWithQuotes(year); } } @@ -1168,6 +1180,13 @@ class CalendarWidget extends StatelessWidget { return result; } + String _numberToHebrewWithQuotes(int number) { + if (number <= 0) return ''; + if (number < 10) return _numberToHebrewWithoutQuotes(number); + if (number < 100) return '${_numberToHebrewWithoutQuotes(number)}׳'; + return '${_numberToHebrewWithoutQuotes(number)}״'; + } + String _getGregorianMonthName(int month) { const months = [ 'ינואר', @@ -1729,8 +1748,8 @@ class CalendarWidget extends StatelessWidget { ? Icons.description_outlined : Icons.title), tooltip: state.searchInDescriptions - ? 'חפש גם בתיאור' - : 'חפש רק בכותרת', + ? 'חפש רק בכותרת' + : 'חפש גם בתיאור', onPressed: () => context .read() .toggleSearchInDescriptions( From 833ec4aba811c6676092a8106f10c172cc5f417f Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 14 Sep 2025 01:58:27 +0300 Subject: [PATCH 188/197] =?UTF-8?q?=D7=94=D7=93=D7=92=D7=A9=D7=AA=20=D7=9E?= =?UTF-8?q?=D7=95=D7=A2=D7=93=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/navigation/calendar_widget.dart | 99 +++++++++++++++++++++-------- 1 file changed, 72 insertions(+), 27 deletions(-) diff --git a/lib/navigation/calendar_widget.dart b/lib/navigation/calendar_widget.dart index 59fa275b1..ac484ef26 100644 --- a/lib/navigation/calendar_widget.dart +++ b/lib/navigation/calendar_widget.dart @@ -102,6 +102,39 @@ class CalendarWidget extends StatelessWidget { ); } + // פונקציה עזר שמחזירה צבע רקע עדין לשבתות ומועדים + Color? _getBackgroundColor( + BuildContext context, DateTime date, bool isSelected, bool isToday) { + // אין צורך להדגיש תא שכבר נבחר או שהוא 'היום' + if (isSelected || isToday) return null; + + final jewishCalendar = JewishCalendar.fromDateTime(date); + + // נבדוק אם מדובר בשבת, יום טוב, או חול המועד + final bool isShabbat = jewishCalendar.getDayOfWeek() == 7; + final bool isYomTov = jewishCalendar.isYomTov(); + final bool isCholHamoed = jewishCalendar.isCholHamoed(); + final bool isTaanis = jewishCalendar.isTaanis(); + final bool isRoshChodesh = jewishCalendar.isRoshChodesh(); + final bool isChanukah = jewishCalendar.isChanukah(); + final int yomTovIndex = jewishCalendar.getYomTovIndex(); + final bool isPurim = yomTovIndex == JewishCalendar.PURIM || + yomTovIndex == JewishCalendar.SHUSHAN_PURIM; + + if (isShabbat || + isYomTov || + isCholHamoed || + isTaanis || + isRoshChodesh || + isChanukah || + isPurim) { + return Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.4); + } + + // אם זה יום רגיל, נחזיר null כדי שישתמש בצבע ברירת המחדל + return null; + } + Widget _buildCalendar(BuildContext context, CalendarState state) { return Card( child: Padding( @@ -422,17 +455,19 @@ class CalendarWidget extends StatelessWidget { margin: const EdgeInsets.all(2), height: 88, decoration: BoxDecoration( - color: isSelected - ? Theme.of(context).colorScheme.primaryContainer - : isToday - ? Theme.of(context) - .colorScheme - .primary - .withOpacity(0.25) - : Theme.of(context) - .colorScheme - .surfaceContainer - .withOpacity(0.2), + color: _getBackgroundColor( + context, dayDate, isSelected, isToday) ?? + (isSelected + ? Theme.of(context).colorScheme.primaryContainer + : isToday + ? Theme.of(context) + .colorScheme + .primary + .withOpacity(0.25) + : Theme.of(context) + .colorScheme + .surfaceContainer + .withOpacity(0.2)), borderRadius: BorderRadius.circular(8), border: Border.all( color: isSelected @@ -517,14 +552,19 @@ class CalendarWidget extends StatelessWidget { margin: const EdgeInsets.all(2), height: 88, decoration: BoxDecoration( - color: isSelected - ? Theme.of(context).colorScheme.primaryContainer - : isToday - ? Theme.of(context).colorScheme.primary.withOpacity(0.25) - : Theme.of(context) - .colorScheme - .surfaceContainer - .withOpacity(0.2), + color: _getBackgroundColor( + context, gregorianDate, isSelected, isToday) ?? + (isSelected + ? Theme.of(context).colorScheme.primaryContainer + : isToday + ? Theme.of(context) + .colorScheme + .primary + .withOpacity(0.25) + : Theme.of(context) + .colorScheme + .surfaceContainer + .withOpacity(0.2)), borderRadius: BorderRadius.circular(8), border: Border.all( color: isSelected @@ -609,14 +649,19 @@ class CalendarWidget extends StatelessWidget { margin: const EdgeInsets.all(2), height: 88, decoration: BoxDecoration( - color: isSelected - ? Theme.of(context).colorScheme.primaryContainer - : isToday - ? Theme.of(context).colorScheme.primary.withOpacity(0.25) - : Theme.of(context) - .colorScheme - .surfaceContainer - .withOpacity(0.2), + color: _getBackgroundColor( + context, gregorianDate, isSelected, isToday) ?? + (isSelected + ? Theme.of(context).colorScheme.primaryContainer + : isToday + ? Theme.of(context) + .colorScheme + .primary + .withOpacity(0.25) + : Theme.of(context) + .colorScheme + .surfaceContainer + .withOpacity(0.2)), borderRadius: BorderRadius.circular(8), border: Border.all( color: isSelected From 5dbb39fdec7ecc05aa90f383078d10fd4dc80aed Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 14 Sep 2025 12:33:46 +0300 Subject: [PATCH 189/197] =?UTF-8?q?=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20=D7=94?= =?UTF-8?q?=D7=A2=D7=AA=D7=A7=D7=94=20=D7=A2=D7=9D=20=D7=9B=D7=95=D7=AA?= =?UTF-8?q?=D7=A8=D7=95=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/settings/settings_screen.dart | 266 +++++----- .../combined_view/combined_book_screen.dart | 24 +- .../view/splited_view/simple_book_view.dart | 30 +- lib/utils/copy_utils.dart | 474 +++++++++++------- 4 files changed, 482 insertions(+), 312 deletions(-) diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index 7d2311ba9..778594aef 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -462,10 +462,14 @@ class _MySettingsScreenState extends State title: 'עיצוב ההעתקה', settingKey: 'key-copy-header-format', values: const { - 'same_line_after_brackets': 'באותה שורה אחרי הכיתוב (עם סוגריים)', - 'same_line_after_no_brackets': 'באותה שורה אחרי הכיתוב (בלי סוגריים)', - 'same_line_before_brackets': 'באותה שורה לפני הכיתוב (עם סוגריים)', - 'same_line_before_no_brackets': 'באותה שורה לפני הכיתוב (בלי סוגריים)', + 'same_line_after_brackets': + 'באותה שורה אחרי הכיתוב (עם סוגריים)', + 'same_line_after_no_brackets': + 'באותה שורה אחרי הכיתוב (בלי סוגריים)', + 'same_line_before_brackets': + 'באותה שורה לפני הכיתוב (עם סוגריים)', + 'same_line_before_no_brackets': + 'באותה שורה לפני הכיתוב (בלי סוגריים)', 'separate_line_after': 'בפסקה בפני עצמה אחרי הכיתוב', 'separate_line_before': 'בפסקה בפני עצמה לפני הכיתוב', }, @@ -486,13 +490,12 @@ class _MySettingsScreenState extends State titleTextStyle: const TextStyle(fontSize: 25), children: [ SwitchSettingsTile( - title: 'סינכרון הספרייה באופן אוטומטי', leading: Icon(Icons.sync), - settingKey: 'key-auto-sync', defaultValue: true, - enabledLabel: 'מאגר הספרים המובנה יתעדכן אוטומטית מאתר אוצריא', + enabledLabel: + 'מאגר הספרים המובנה יתעדכן אוטומטית מאתר אוצריא', disabledLabel: 'מאגר הספרים לא יתעדכן אוטומטית.', activeColor: Theme.of(context).cardColor, ), @@ -775,38 +778,40 @@ class _MarginSliderPreviewState extends State { return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( - onPanUpdate: (details) { - setState(() { - double newMargin = - isLeft ? _margin + details.delta.dx : _margin - details.delta.dx; - - // מגבילים את המרחב לפי רוחב הווידג'ט והגדרות המשתמש - final maxWidth = (context.findRenderObject() as RenderBox).size.width; - _margin = newMargin - .clamp(widget.min, maxWidth / 2) - .clamp(widget.min, widget.max); - }); - widget.onChanged(_margin); - }, - onPanStart: (_) => _handleDragStart(), - onPanEnd: (_) => _handleDragEnd(), - child: Container( - width: thumbSize * 2, // אזור לחיצה גדול יותר מהנראות - height: thumbSize * 2, - color: Colors.transparent, // אזור הלחיצה שקוף - alignment: Alignment.center, + onPanUpdate: (details) { + setState(() { + double newMargin = isLeft + ? _margin + details.delta.dx + : _margin - details.delta.dx; + + // מגבילים את המרחב לפי רוחב הווידג'ט והגדרות המשתמש + final maxWidth = + (context.findRenderObject() as RenderBox).size.width; + _margin = newMargin + .clamp(widget.min, maxWidth / 2) + .clamp(widget.min, widget.max); + }); + widget.onChanged(_margin); + }, + onPanStart: (_) => _handleDragStart(), + onPanEnd: (_) => _handleDragEnd(), child: Container( - // --- שינוי 1: עיצוב הידית מחדש --- - width: thumbSize, - height: thumbSize, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, // צבע ראשי - shape: BoxShape.circle, - boxShadow: kElevationToShadow[1], // הצללה סטנדרטית של פלאטר + width: thumbSize * 2, // אזור לחיצה גדול יותר מהנראות + height: thumbSize * 2, + color: Colors.transparent, // אזור הלחיצה שקוף + alignment: Alignment.center, + child: Container( + // --- שינוי 1: עיצוב הידית מחדש --- + width: thumbSize, + height: thumbSize, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, // צבע ראשי + shape: BoxShape.circle, + boxShadow: kElevationToShadow[1], // הצללה סטנדרטית של פלאטר + ), ), ), ), - ), ); } @@ -828,121 +833,125 @@ class _MarginSliderPreviewState extends State { cursor: SystemMouseCursors.click, child: GestureDetector( onTapDown: (details) { - // חישוב המיקום החדש לפי הלחיצה - final RenderBox renderBox = - context.findRenderObject() as RenderBox; - final localPosition = - renderBox.globalToLocal(details.globalPosition); - final tapX = localPosition.dx; - - // חישוב השוליים החדשים - לוגיקה נכונה - double newMargin; - - // אם לחצנו במרכז - השוליים יהיו מקסימליים - // אם לחצנו בקצוות - השוליים יהיו מינימליים - double distanceFromCenter = (tapX - fullWidth / 2).abs(); - newMargin = (fullWidth / 2) - distanceFromCenter; - - // הגבלת הערכים - newMargin = newMargin - .clamp(widget.min, widget.max) - .clamp(widget.min, fullWidth / 2); - - setState(() { - _margin = newMargin; - }); - - widget.onChanged(_margin); - _handleDragStart(); - _handleDragEnd(); - }, - child: Stack( - alignment: Alignment.center, - children: [ - // אזור לחיצה מורחב - שקוף וגדול יותר מהפס - Container( - height: thumbSize * 2, // גובה כמו הידיות - color: Colors.transparent, - ), - - // קו הרקע - Container( - height: trackHeight, - decoration: BoxDecoration( - color: Theme.of(context).dividerColor.withOpacity(0.5), - borderRadius: BorderRadius.circular(trackHeight / 2), + // חישוב המיקום החדש לפי הלחיצה + final RenderBox renderBox = + context.findRenderObject() as RenderBox; + final localPosition = + renderBox.globalToLocal(details.globalPosition); + final tapX = localPosition.dx; + + // חישוב השוליים החדשים - לוגיקה נכונה + double newMargin; + + // אם לחצנו במרכז - השוליים יהיו מקסימליים + // אם לחצנו בקצוות - השוליים יהיו מינימליים + double distanceFromCenter = (tapX - fullWidth / 2).abs(); + newMargin = (fullWidth / 2) - distanceFromCenter; + + // הגבלת הערכים + newMargin = newMargin + .clamp(widget.min, widget.max) + .clamp(widget.min, fullWidth / 2); + + setState(() { + _margin = newMargin; + }); + + widget.onChanged(_margin); + _handleDragStart(); + _handleDragEnd(); + }, + child: Stack( + alignment: Alignment.center, + children: [ + // אזור לחיצה מורחב - שקוף וגדול יותר מהפס + Container( + height: thumbSize * 2, // גובה כמו הידיות + color: Colors.transparent, ), - ), - // הקו הפעיל (מייצג את רוחב הטקסט) - Padding( - padding: EdgeInsets.symmetric(horizontal: _margin), - child: Container( + // קו הרקע + Container( height: trackHeight, decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, + color: + Theme.of(context).dividerColor.withOpacity(0.5), borderRadius: BorderRadius.circular(trackHeight / 2), ), ), - ), - // הצגת הערך מעל הידית (רק בזמן תצוגה) - if (_showPreview) - Positioned( - left: _margin - 10, - top: 0, + // הקו הפעיל (מייצג את רוחב הטקסט) + Padding( + padding: EdgeInsets.symmetric(horizontal: _margin), child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), + height: trackHeight, decoration: BoxDecoration( color: Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - _margin.toStringAsFixed(0), - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimary, - fontSize: 12), + borderRadius: + BorderRadius.circular(trackHeight / 2), ), ), ), - if (_showPreview) - Positioned( - right: _margin - 10, - top: 0, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(8), + // הצגת הערך מעל הידית (רק בזמן תצוגה) + if (_showPreview) + Positioned( + left: _margin - 10, + top: 0, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _margin.toStringAsFixed(0), + style: TextStyle( + color: + Theme.of(context).colorScheme.onPrimary, + fontSize: 12), + ), ), - child: Text( - _margin.toStringAsFixed(0), - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimary, - fontSize: 12), + ), + + if (_showPreview) + Positioned( + right: _margin - 10, + top: 0, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _margin.toStringAsFixed(0), + style: TextStyle( + color: + Theme.of(context).colorScheme.onPrimary, + fontSize: 12), + ), ), ), - ), - // הכפתור השמאלי - Positioned( - left: _margin - (thumbSize), - child: _buildThumb(isLeft: true), - ), + // הכפתור השמאלי + Positioned( + left: _margin - (thumbSize), + child: _buildThumb(isLeft: true), + ), - // הכפתור הימני - Positioned( - right: _margin - (thumbSize), - child: _buildThumb(isLeft: false), - ), - ], + // הכפתור הימני + Positioned( + right: _margin - (thumbSize), + child: _buildThumb(isLeft: false), + ), + ], + ), ), ), ), - ), const SizedBox(height: 8), @@ -979,10 +988,9 @@ class _MarginSliderPreviewState extends State { ), ), ), - ], ); }, ); } -} \ No newline at end of file +} diff --git a/lib/text_book/view/combined_view/combined_book_screen.dart b/lib/text_book/view/combined_view/combined_book_screen.dart index a381d7575..3ec65630c 100644 --- a/lib/text_book/view/combined_view/combined_book_screen.dart +++ b/lib/text_book/view/combined_view/combined_book_screen.dart @@ -346,7 +346,11 @@ class _CombinedViewState extends State { // אם צריך להוסיף כותרות if (settingsState.copyWithHeaders != 'none' && textBookState is TextBookLoaded) { final bookName = CopyUtils.extractBookName(textBookState.book); - final currentPath = await CopyUtils.extractCurrentPath(textBookState.book, index); + final currentPath = await CopyUtils.extractCurrentPath( + textBookState.book, + index, + bookContent: textBookState.content, + ); finalText = CopyUtils.formatTextWithHeaders( originalText: text, @@ -398,7 +402,11 @@ class _CombinedViewState extends State { if (settingsState.copyWithHeaders != 'none') { final bookName = CopyUtils.extractBookName(state.book); final firstVisibleIndex = state.visibleIndices.first; - final currentPath = await CopyUtils.extractCurrentPath(state.book, firstVisibleIndex); + final currentPath = await CopyUtils.extractCurrentPath( + state.book, + firstVisibleIndex, + bookContent: state.content, + ); finalText = CopyUtils.formatTextWithHeaders( originalText: combinedText, @@ -456,7 +464,11 @@ $textWithBreaks if (settingsState.copyWithHeaders != 'none' && textBookState is TextBookLoaded) { final bookName = CopyUtils.extractBookName(textBookState.book); final currentIndex = _currentSelectedIndex ?? 0; - final currentPath = await CopyUtils.extractCurrentPath(textBookState.book, currentIndex); + final currentPath = await CopyUtils.extractCurrentPath( + textBookState.book, + currentIndex, + bookContent: textBookState.content, + ); finalText = CopyUtils.formatTextWithHeaders( originalText: text, @@ -541,7 +553,11 @@ $textWithBreaks if (settingsState.copyWithHeaders != 'none' && textBookState is TextBookLoaded) { final bookName = CopyUtils.extractBookName(textBookState.book); final currentIndex = _currentSelectedIndex ?? 0; - final currentPath = await CopyUtils.extractCurrentPath(textBookState.book, currentIndex); + final currentPath = await CopyUtils.extractCurrentPath( + textBookState.book, + currentIndex, + bookContent: textBookState.content, + ); finalPlainText = CopyUtils.formatTextWithHeaders( originalText: plainText, diff --git a/lib/text_book/view/splited_view/simple_book_view.dart b/lib/text_book/view/splited_view/simple_book_view.dart index 3dbcf2efd..9dfb12769 100644 --- a/lib/text_book/view/splited_view/simple_book_view.dart +++ b/lib/text_book/view/splited_view/simple_book_view.dart @@ -324,7 +324,11 @@ class _SimpleBookViewState extends State { if (settingsState.copyWithHeaders != 'none' && textBookState is TextBookLoaded) { final bookName = CopyUtils.extractBookName(textBookState.book); final currentIndex = _currentSelectedIndex!; - final currentPath = await CopyUtils.extractCurrentPath(textBookState.book, currentIndex); + final currentPath = await CopyUtils.extractCurrentPath( + textBookState.book, + currentIndex, + bookContent: textBookState.content, + ); finalText = CopyUtils.formatTextWithHeaders( originalText: text, @@ -375,7 +379,11 @@ class _SimpleBookViewState extends State { // אם צריך להוסיף כותרות if (settingsState.copyWithHeaders != 'none') { final bookName = CopyUtils.extractBookName(state.book); - final currentPath = await CopyUtils.extractCurrentPath(state.book, indexToCopy); + final currentPath = await CopyUtils.extractCurrentPath( + state.book, + indexToCopy, + bookContent: state.content, + ); finalText = CopyUtils.formatTextWithHeaders( originalText: text, @@ -427,7 +435,11 @@ class _SimpleBookViewState extends State { if (settingsState.copyWithHeaders != 'none') { final bookName = CopyUtils.extractBookName(state.book); final firstVisibleIndex = state.visibleIndices.first; - final currentPath = await CopyUtils.extractCurrentPath(state.book, firstVisibleIndex); + final currentPath = await CopyUtils.extractCurrentPath( + state.book, + firstVisibleIndex, + bookContent: state.content, + ); finalText = CopyUtils.formatTextWithHeaders( originalText: combinedText, @@ -495,7 +507,11 @@ $textWithBreaks if (settingsState.copyWithHeaders != 'none' && textBookState is TextBookLoaded) { final bookName = CopyUtils.extractBookName(textBookState.book); final currentIndex = _currentSelectedIndex ?? 0; - final currentPath = await CopyUtils.extractCurrentPath(textBookState.book, currentIndex); + final currentPath = await CopyUtils.extractCurrentPath( + textBookState.book, + currentIndex, + bookContent: textBookState.content, + ); finalText = CopyUtils.formatTextWithHeaders( originalText: text, @@ -580,7 +596,11 @@ $textWithBreaks if (settingsState.copyWithHeaders != 'none' && textBookState is TextBookLoaded) { final bookName = CopyUtils.extractBookName(textBookState.book); final currentIndex = _currentSelectedIndex ?? 0; - final currentPath = await CopyUtils.extractCurrentPath(textBookState.book, currentIndex); + final currentPath = await CopyUtils.extractCurrentPath( + textBookState.book, + currentIndex, + bookContent: textBookState.content, + ); finalPlainText = CopyUtils.formatTextWithHeaders( originalText: plainText, diff --git a/lib/utils/copy_utils.dart b/lib/utils/copy_utils.dart index 76cf88732..caf30f928 100644 --- a/lib/utils/copy_utils.dart +++ b/lib/utils/copy_utils.dart @@ -1,3 +1,4 @@ +import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:otzaria/models/books.dart'; @@ -11,133 +12,137 @@ class CopyUtils { /// מחלץ את הנתיב ההיררכי הנוכחי בספר static Future extractCurrentPath( TextBook book, - int currentIndex, - ) async { + int currentIndex, { + List? bookContent, + }) async { try { + if (kDebugMode) { + print('CopyUtils: *** NEW VERSION *** Looking for headers for index $currentIndex in book ${book.title}'); + print('CopyUtils: bookContent is ${bookContent == null ? 'null' : 'available with ${bookContent.length} entries'}'); + } + + // קודם ננסה לחלץ מהתוכן עצמו + if (kDebugMode) { + print('CopyUtils: Trying to extract from content first...'); + } + String contentPath = await _extractPathFromContent(book, currentIndex, bookContent); + if (contentPath.isNotEmpty) { + if (kDebugMode) { + print('CopyUtils: Found path from content: "$contentPath"'); + } + return contentPath; + } else if (kDebugMode) { + print('CopyUtils: No path found from content, trying TOC...'); + } + + // אם לא מצאנו בתוכן, ננסה מה-TOC final toc = await book.tableOfContents; if (toc.isEmpty) { if (kDebugMode) { print('CopyUtils: TOC is empty for book ${book.title}'); } - return _tryExtractFromIndex(book, currentIndex); + return ''; } // מוצא את כל הכותרות הרלוונטיות לאינדקס הנוכחי Map levelHeaders = {}; // רמה -> כותרת - - // אם יש רק כותרת אחת, נבדוק אם היא רלוונטית - if (toc.length == 1) { - final entry = toc[0]; - if (currentIndex >= entry.index) { - String cleanText = - entry.text.replaceAll(RegExp(r'<[^>]*>'), '').trim(); - if (cleanText.isNotEmpty && cleanText != book.title) { - levelHeaders[entry.level] = cleanText; - if (kDebugMode) { - print( - 'CopyUtils: Single TOC entry found: level=${entry.level}, text="$cleanText"'); - } - } - } - } - + if (kDebugMode) { - print( - 'CopyUtils: Looking for headers for index $currentIndex in book ${book.title}'); print('CopyUtils: TOC has ${toc.length} entries'); for (int i = 0; i < toc.length; i++) { final entry = toc[i]; - print( - 'CopyUtils: TOC[$i]: index=${entry.index}, level=${entry.level}, text="${entry.text}"'); + print('CopyUtils: TOC[$i]: index=${entry.index}, level=${entry.level}, text="${entry.text}"'); } } - + // עובר על כל הכותרות ומוצא את אלו שהאינדקס הנוכחי נמצא אחריהן for (int i = 0; i < toc.length; i++) { final entry = toc[i]; - + if (kDebugMode) { - print( - 'CopyUtils: Checking entry $i: index=${entry.index}, currentIndex=$currentIndex'); + print('CopyUtils: Checking entry $i: index=${entry.index}, currentIndex=$currentIndex'); } - + // בודק אם האינדקס הנוכחי נמצא אחרי הכותרת הזו if (currentIndex >= entry.index) { if (kDebugMode) { - print( - 'CopyUtils: Current index >= entry index, checking if active...'); + print('CopyUtils: Current index >= entry index, checking if active...'); } - + // בודק אם יש כותרת אחרת באותה רמה או נמוכה יותר שמגיעה אחרי האינדקס הנוכחי bool isActive = true; - + + // עבור כותרות רמה גבוהה (2, 3, 4...), נבדוק רק כותרות באותה רמה או נמוכה יותר + // עבור כותרת רמה 1, נבדוק רק כותרות רמה 1 for (int j = i + 1; j < toc.length; j++) { final nextEntry = toc[j]; - if (nextEntry.index > currentIndex && - nextEntry.level <= entry.level) { - if (kDebugMode) { - print( - 'CopyUtils: Found blocking entry at $j: index=${nextEntry.index}, level=${nextEntry.level}'); + + // אם הכותרת הבאה מגיעה אחרי האינדקס הנוכחי + if (nextEntry.index > currentIndex) { + // אם זו כותרת באותה רמה או נמוכה יותר, היא חוסמת + if (nextEntry.level <= entry.level) { + if (kDebugMode) { + print('CopyUtils: Found blocking entry at $j: index=${nextEntry.index}, level=${nextEntry.level} (blocks level ${entry.level})'); + } + isActive = false; + break; } - isActive = false; - break; } } - + if (kDebugMode) { print('CopyUtils: Entry $i is active: $isActive'); } - + if (isActive) { // מנקה את הטקסט מתגי HTML - String cleanText = - entry.text.replaceAll(RegExp(r'<[^>]*>'), '').trim(); - + String cleanText = entry.text + .replaceAll(RegExp(r'<[^>]*>'), '') + .trim(); + if (kDebugMode) { - print( - 'CopyUtils: Clean text: "$cleanText", book title: "${book.title}"'); + print('CopyUtils: Clean text: "$cleanText", book title: "${book.title}"'); } - - if (cleanText.isNotEmpty && cleanText != book.title) { + + if (cleanText.isNotEmpty) { + // נכלול את כל הכותרות, גם אם הן זהות לשם הספר levelHeaders[entry.level] = cleanText; if (kDebugMode) { - print( - 'CopyUtils: Found active header at level ${entry.level}: "$cleanText"'); + print('CopyUtils: Found active header at level ${entry.level}: "$cleanText"'); } } else if (kDebugMode) { - print('CopyUtils: Skipping header (empty or matches book title)'); + print('CopyUtils: Skipping empty header'); } } } else if (kDebugMode) { print('CopyUtils: Current index < entry index, skipping'); } } - + // בונה את הנתיב מהרמות בסדר עולה List pathParts = []; final sortedLevels = levelHeaders.keys.toList()..sort(); - + + if (kDebugMode) { + print('CopyUtils: Found ${levelHeaders.length} active headers:'); + for (final level in sortedLevels) { + print('CopyUtils: Level $level: "${levelHeaders[level]}"'); + } + } + for (final level in sortedLevels) { final header = levelHeaders[level]; if (header != null) { pathParts.add(header); } } - - final result = pathParts.join(' '); + + // מחבר עם פסיקים לנתיב מסודר + final result = pathParts.join(', '); if (kDebugMode) { - print('CopyUtils: Final path: "$result"'); - } - - // אם לא מצאנו נתיב מה-TOC, ננסה לחלץ מהאינדקס - if (result.isEmpty) { - if (kDebugMode) { - print( - 'CopyUtils: No path found in TOC, trying to extract from index'); - } - return _tryExtractFromIndex(book, currentIndex); + print('CopyUtils: Final path from TOC: "$result"'); } - + return result; } catch (e) { if (kDebugMode) { @@ -168,23 +173,14 @@ class CopyUtils { return originalText; } - // בונים את הכותרת + // בונים את הכותרת - רק מהנתיב, בלי שם הקובץ String header = ''; if (copyWithHeaders == 'book_name') { - header = bookName; + // גם כאן נשתמש רק בנתיב אם יש, אחרת בשם הספר + header = currentPath.isNotEmpty ? currentPath : bookName; } else if (copyWithHeaders == 'book_and_path') { - if (currentPath.isNotEmpty) { - // בודקים אם הנתיב כבר מכיל את שם הספר - if (currentPath.startsWith(bookName)) { - // הנתיב כבר מכיל את שם הספר, לא נוסיף אותו שוב - header = currentPath; - } else { - // הנתיב לא מכיל את שם הספר, נוסיף אותו - header = '$bookName $currentPath'; - } - } else { - header = bookName; - } + // רק הנתיב, בלי שם הקובץ + header = currentPath.isNotEmpty ? currentPath : ''; } if (kDebugMode) { @@ -222,113 +218,243 @@ class CopyUtils { } if (kDebugMode) { - print( - 'CopyUtils: Final formatted text: "${result.replaceAll('\n', '\\n')}"'); + print('CopyUtils: Final formatted text: "${result.replaceAll('\n', '\\n')}"'); } return result; } - /// מנסה לחלץ מידע מהאינדקס כשאין TOC מפורט - static String _tryExtractFromIndex(TextBook book, int currentIndex) { - if (kDebugMode) { - print( - 'CopyUtils: Trying to extract from index $currentIndex for book ${book.title}'); + /// מנסה לחלץ נתיב מהתוכן עצמו כשאין TOC מפורט + static Future _extractPathFromContent(TextBook book, int currentIndex, List? bookContent) async { + try { + if (kDebugMode) { + print('CopyUtils: Trying to extract path from content at index $currentIndex'); + } + + if (bookContent == null) { + if (kDebugMode) { + print('CopyUtils: bookContent is null'); + } + return ''; + } + + if (currentIndex >= bookContent.length) { + if (kDebugMode) { + print('CopyUtils: currentIndex ($currentIndex) >= bookContent.length (${bookContent.length})'); + } + return ''; + } + + if (kDebugMode) { + print('CopyUtils: bookContent available with ${bookContent.length} entries, currentIndex: $currentIndex'); + } + + // לספר השרשים - ננסה לחלץ את האות מהתוכן + if (book.title.contains('שרשים') || book.title.contains('רדק') || book.title.contains('רד"ק')) { + if (kDebugMode) { + print('CopyUtils: Detected Radak book, extracting from Radak content'); + } + return await _extractFromRadakContent(book, currentIndex, bookContent); + } + + // לספרים אחרים - ננסה לחלץ כותרות כלליות + return await _extractGeneralHeaders(book, currentIndex, bookContent); + } catch (e) { + if (kDebugMode) { + print('CopyUtils: Error extracting from content: $e'); + } + return ''; } + } - // לספרי תלמוד - ננסה לחשב דף לפי אינדקס - if (book.title.contains('ברכות') || - book.title.contains('שבת') || - book.title.contains('עירובין') || - _isTalmudBook(book.title)) { - // הנחה: כל דף מכיל בערך 20-30 שורות טקסט - // זה רק הערכה גסה, אבל יכול לעזור - final estimatedPage = (currentIndex ~/ 25) + 2; // מתחילים מדף ב' - final pageText = 'דף ${_numberToHebrew(estimatedPage)}.'; - + /// מחלץ מידע מתוכן ספר השרשים לרד"ק + static Future _extractFromRadakContent(TextBook book, int currentIndex, List? bookContent) async { + try { + if (bookContent == null || currentIndex >= bookContent.length) { + if (kDebugMode) { + print('CopyUtils: No content available or index out of range'); + } + return ''; + } + if (kDebugMode) { - print('CopyUtils: Estimated page for Talmud: $pageText'); + print('CopyUtils: Analyzing Radak content at index $currentIndex'); } - - return pageText; + + // נחפש כותרת HTML באינדקסים הקודמים (עד 10 אינדקסים אחורה) + String? foundHeader; + for (int i = currentIndex; i >= math.max(0, currentIndex - 10); i--) { + if (i < bookContent.length) { + final text = bookContent[i]; + final headerPattern = RegExp(r']*>([^<]+)'); + final headerMatch = headerPattern.firstMatch(text); + + if (headerMatch != null) { + foundHeader = headerMatch.group(1)!.trim(); + if (kDebugMode) { + print('CopyUtils: Found header "$foundHeader" at index $i'); + } + break; + } + } + } + + if (foundHeader != null && foundHeader.isNotEmpty) { + final firstLetter = foundHeader.substring(0, 1); + final letterName = _getHebrewLetterName(firstLetter); + final path = '${book.title}, $letterName, $foundHeader'; + + if (kDebugMode) { + print('CopyUtils: Generated Radak path from header: "$path"'); + } + + return path; + } + + if (kDebugMode) { + print('CopyUtils: No header found in nearby indices, trying current text analysis...'); + } + + // אם לא מצאנו כותרת, ננתח את הטקסט הנוכחי + final currentText = bookContent[currentIndex]; + + // נחפש את המילה הראשונה המודגשת שמופיעה בתחילת הטקסט + final boldWordPattern = RegExp(r'([א-ת]+)'); + final firstMatch = boldWordPattern.firstMatch(currentText); + + if (firstMatch != null) { + final word = firstMatch.group(1)!; + final matchStart = firstMatch.start; + + // נבדוק אם המילה מופיעה בתחילת הטקסט (עד 50 תווים מההתחלה) + if (matchStart < 50) { + if (word.isNotEmpty) { + final firstLetter = word.substring(0, 1); + final letterName = _getHebrewLetterName(firstLetter); + final path = '${book.title}, $letterName, $word'; + + if (kDebugMode) { + print('CopyUtils: Generated Radak path from first bold word: "$path"'); + } + + return path; + } + } + } + + if (kDebugMode) { + print('CopyUtils: Could not extract meaningful path from content'); + } + + return ''; + } catch (e) { + if (kDebugMode) { + print('CopyUtils: Error in _extractFromRadakContent: $e'); + } + return ''; } + } - // לספרים אחרים - אם האינדקס גדול מ-0, ננסה לתת מידע כללי - if (currentIndex > 0) { - return 'פסקה ${currentIndex + 1}'; + /// מחלץ כותרות כלליות מתוכן הספר + static Future _extractGeneralHeaders(TextBook book, int currentIndex, List bookContent) async { + try { + if (kDebugMode) { + print('CopyUtils: Extracting general headers for index $currentIndex'); + } + + List headers = []; + + // נוסיף את שם הספר כרמה 1 + headers.add(book.title); + + // נחפש כותרות בתוכן הנוכחי ובתוכן הקודם + for (int i = math.max(0, currentIndex - 10); i <= currentIndex; i++) { + if (i < bookContent.length) { + final text = bookContent[i]; + + // נחפש דפוסים של כותרות (טקסט מודגש, גדול, וכו') + final headerPatterns = [ + RegExp(r']*>([^<]+)'), // כותרות HTML + RegExp(r'([^<]{2,30})'), // טקסט מודגש קצר + RegExp(r'([^<]{2,30})'), // טקסט חזק + ]; + + for (final pattern in headerPatterns) { + final matches = pattern.allMatches(text); + for (final match in matches) { + final headerText = match.group(1)?.trim(); + if (headerText != null && headerText.isNotEmpty && headerText.length > 1) { + // נוודא שזו לא מילה רגילה בתוך הטקסט + if (!headers.contains(headerText) && _isLikelyHeader(headerText)) { + headers.add(headerText); + if (kDebugMode) { + print('CopyUtils: Found potential header: "$headerText"'); + } + } + } + } + } + } + } + + // נחזיר את הכותרות המצטברות + final result = headers.join(', '); + if (kDebugMode) { + print('CopyUtils: Generated general path: "$result"'); + } + + return result; + } catch (e) { + if (kDebugMode) { + print('CopyUtils: Error in _extractGeneralHeaders: $e'); + } + return ''; } - - return ''; } - /// בודק אם זה ספר תלמוד - static bool _isTalmudBook(String title) { - final talmudBooks = [ - 'ברכות', - 'שבת', - 'עירובין', - 'פסחים', - 'שקלים', - 'יומא', - 'סוכה', - 'ביצה', - 'ראש השנה', - 'תענית', - 'מגילה', - 'מועד קטן', - 'חגיגה', - 'יבמות', - 'כתובות', - 'נדרים', - 'נזיר', - 'סוטה', - 'גיטין', - 'קידושין', - 'בבא קמא', - 'בבא מציעא', - 'בבא בתרא', - 'סנהדרין', - 'מכות', - 'שבועות', - 'עבודה זרה', - 'הוריות', - 'זבחים', - 'מנחות', - 'חולין', - 'בכורות', - 'ערכין', - 'תמורה', - 'כריתות', - 'מעילה', - 'תמיד', - 'מדות', - 'קינים', - 'נדה' - ]; - - return talmudBooks.any((book) => title.contains(book)); + /// בודק אם טקסט נראה כמו כותרת + static bool _isLikelyHeader(String text) { + // כותרת צריכה להיות קצרה יחסית + if (text.length > 50) return false; + + // לא צריכה להכיל סימני פיסוק רבים + final punctuationCount = RegExp(r'[.,;:!?]').allMatches(text).length; + if (punctuationCount > 2) return false; + + // לא צריכה להכיל מספרים רבים + final numberCount = RegExp(r'\d').allMatches(text).length; + if (numberCount > 3) return false; + + return true; } - /// ממיר מספר לעברית (פשוט) - static String _numberToHebrew(int number) { - if (number <= 0) return ''; - - final ones = ['', 'א', 'ב', 'ג', 'ד', 'ה', 'ו', 'ז', 'ח', 'ט']; - final tens = ['', '', 'כ', 'ל', 'מ', 'נ', 'ס', 'ע', 'פ', 'צ']; - final hundreds = ['', 'ק', 'ר', 'ש', 'ת']; - - if (number < 10) { - return ones[number]; - } else if (number < 100) { - final ten = number ~/ 10; - final one = number % 10; - return tens[ten] + ones[one]; - } else if (number < 400) { - final hundred = number ~/ 100; - final remainder = number % 100; - return hundreds[hundred] + _numberToHebrew(remainder); - } - - return number.toString(); // fallback למספרים גדולים + /// מחזיר שם של אות עברית + static String _getHebrewLetterName(String letter) { + const letterNames = { + 'א': 'אות הא\'', + 'ב': 'אות הב\'', + 'ג': 'אות הג\'', + 'ד': 'אות הד\'', + 'ה': 'אות הה\'', + 'ו': 'אות הו\'', + 'ז': 'אות הז\'', + 'ח': 'אות הח\'', + 'ט': 'אות הט\'', + 'י': 'אות הי\'', + 'כ': 'אות הכ\'', + 'ל': 'אות הל\'', + 'מ': 'אות המ\'', + 'נ': 'אות הנ\'', + 'ס': 'אות הס\'', + 'ע': 'אות הע\'', + 'פ': 'אות הפ\'', + 'צ': 'אות הצ\'', + 'ק': 'אות הק\'', + 'ר': 'אות הר\'', + 'ש': 'אות הש\'', + 'ת': 'אות הת\'', + }; + + return letterNames[letter] ?? 'אות ה$letter'; } -} +} \ No newline at end of file From efa58287c38b5b5c0821caf6d17aae29584f9ad2 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 14 Sep 2025 16:44:33 +0300 Subject: [PATCH 190/197] =?UTF-8?q?=D7=A9=D7=99=D7=A4=D7=95=D7=A8=D7=99=20?= =?UTF-8?q?=D7=9C=D7=95=D7=97=20=D7=A9=D7=A0=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/navigation/calendar_cubit.dart | 24 +++- lib/navigation/calendar_widget.dart | 198 ++++++++++++++++------------ 2 files changed, 135 insertions(+), 87 deletions(-) diff --git a/lib/navigation/calendar_cubit.dart b/lib/navigation/calendar_cubit.dart index 8d5e94c3f..03dd2d36e 100644 --- a/lib/navigation/calendar_cubit.dart +++ b/lib/navigation/calendar_cubit.dart @@ -21,6 +21,7 @@ class CalendarState extends Equatable { final List events; final String eventSearchQuery; final bool searchInDescriptions; + final bool inIsrael; const CalendarState({ required this.selectedJewishDate, @@ -31,6 +32,7 @@ class CalendarState extends Equatable { required this.currentGregorianDate, required this.calendarType, required this.calendarView, + required this.inIsrael, this.events = const [], this.eventSearchQuery = '', this.searchInDescriptions = false, @@ -47,10 +49,10 @@ class CalendarState extends Equatable { dailyTimes: const {}, currentJewishDate: jewishNow, currentGregorianDate: now, - calendarType: - CalendarType.combined, // ברירת מחדל, יעודכן ב-_initializeCalendar + calendarType: CalendarType.combined, calendarView: CalendarView.month, - searchInDescriptions: false, // ברירת מחדל: חיפוש רק בכותרת + searchInDescriptions: false, + inIsrael: true, ); } @@ -66,6 +68,7 @@ class CalendarState extends Equatable { List? events, String? eventSearchQuery, bool? searchInDescriptions, + bool? inIsrael, }) { return CalendarState( selectedJewishDate: selectedJewishDate ?? this.selectedJewishDate, @@ -80,6 +83,7 @@ class CalendarState extends Equatable { events: events ?? this.events, eventSearchQuery: eventSearchQuery ?? this.eventSearchQuery, searchInDescriptions: searchInDescriptions ?? this.searchInDescriptions, + inIsrael: inIsrael ?? this.inIsrael, ); } @@ -105,7 +109,8 @@ class CalendarState extends Equatable { currentGregorianDate, calendarType, - calendarView + calendarView, + inIsrael, ]; } @@ -125,6 +130,7 @@ class CalendarCubit extends Cubit { final calendarType = _stringToCalendarType(calendarTypeString); final selectedCity = settings['selectedCity'] as String; final eventsJson = settings['calendarEvents'] as String; + final bool inIsrael = _isCityInIsrael(selectedCity); // טעינת אירועים מהאחסון List events = []; @@ -141,6 +147,7 @@ class CalendarCubit extends Cubit { calendarType: calendarType, selectedCity: selectedCity, events: events, + inIsrael: inIsrael, )); _updateTimesForDate(state.selectedGregorianDate, selectedCity); } @@ -161,9 +168,11 @@ class CalendarCubit extends Cubit { void changeCity(String newCity) { final newTimes = _calculateDailyTimes(state.selectedGregorianDate, newCity); + final bool inIsrael = _isCityInIsrael(newCity); emit(state.copyWith( selectedCity: newCity, dailyTimes: newTimes, + inIsrael: inIsrael, )); // שמור את הבחירה בהגדרות _settingsRepository.updateSelectedCity(newCity); @@ -809,6 +818,10 @@ const Map>> cityCoordinates = { }, }; +bool _isCityInIsrael(String cityName) { + return cityCoordinates['ארץ ישראל']!.containsKey(cityName); +} + Map? _getCityData(String cityName) { for (var country in cityCoordinates.values) { if (country.containsKey(cityName)) { @@ -839,7 +852,10 @@ Map _calculateDailyTimes(DateTime date, String city) { final zmanimCalendar = ComplexZmanimCalendar.intGeoLocation(location); + final bool isInIsrael = _isCityInIsrael(city); final jewishCalendar = JewishCalendar.fromDateTime(date); + jewishCalendar.inIsrael = isInIsrael; + final Map times = { 'alos': _formatTime(zmanimCalendar.getAlosHashachar()!), 'alos16point1Degrees': diff --git a/lib/navigation/calendar_widget.dart b/lib/navigation/calendar_widget.dart index ac484ef26..0a7e09d40 100644 --- a/lib/navigation/calendar_widget.dart +++ b/lib/navigation/calendar_widget.dart @@ -103,35 +103,22 @@ class CalendarWidget extends StatelessWidget { } // פונקציה עזר שמחזירה צבע רקע עדין לשבתות ומועדים - Color? _getBackgroundColor( - BuildContext context, DateTime date, bool isSelected, bool isToday) { - // אין צורך להדגיש תא שכבר נבחר או שהוא 'היום' + Color? _getBackgroundColor(BuildContext context, DateTime date, + bool isSelected, bool isToday, bool inIsrael) { if (isSelected || isToday) return null; - final jewishCalendar = JewishCalendar.fromDateTime(date); + final jewishCalendar = JewishCalendar.fromDateTime(date) + ..inIsrael = inIsrael; - // נבדוק אם מדובר בשבת, יום טוב, או חול המועד final bool isShabbat = jewishCalendar.getDayOfWeek() == 7; final bool isYomTov = jewishCalendar.isYomTov(); - final bool isCholHamoed = jewishCalendar.isCholHamoed(); final bool isTaanis = jewishCalendar.isTaanis(); final bool isRoshChodesh = jewishCalendar.isRoshChodesh(); - final bool isChanukah = jewishCalendar.isChanukah(); - final int yomTovIndex = jewishCalendar.getYomTovIndex(); - final bool isPurim = yomTovIndex == JewishCalendar.PURIM || - yomTovIndex == JewishCalendar.SHUSHAN_PURIM; - - if (isShabbat || - isYomTov || - isCholHamoed || - isTaanis || - isRoshChodesh || - isChanukah || - isPurim) { + + if (isShabbat || isYomTov || isTaanis || isRoshChodesh) { return Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.4); } - // אם זה יום רגיל, נחזיר null כדי שישתמש בצבע ברירת המחדל return null; } @@ -455,8 +442,8 @@ class CalendarWidget extends StatelessWidget { margin: const EdgeInsets.all(2), height: 88, decoration: BoxDecoration( - color: _getBackgroundColor( - context, dayDate, isSelected, isToday) ?? + color: _getBackgroundColor(context, dayDate, isSelected, + isToday, state.inIsrael) ?? (isSelected ? Theme.of(context).colorScheme.primaryContainer : isToday @@ -510,7 +497,7 @@ class CalendarWidget extends StatelessWidget { const SizedBox(height: 2), _DayExtras( date: dayDate, - jewishCalendar: JewishCalendar.fromDateTime(dayDate), + inIsrael: state.inIsrael, ), ], ), @@ -552,8 +539,8 @@ class CalendarWidget extends StatelessWidget { margin: const EdgeInsets.all(2), height: 88, decoration: BoxDecoration( - color: _getBackgroundColor( - context, gregorianDate, isSelected, isToday) ?? + color: _getBackgroundColor(context, gregorianDate, isSelected, + isToday, state.inIsrael) ?? (isSelected ? Theme.of(context).colorScheme.primaryContainer : isToday @@ -616,7 +603,7 @@ class CalendarWidget extends StatelessWidget { right: 4, child: _DayExtras( date: gregorianDate, - jewishCalendar: JewishCalendar.fromDateTime(gregorianDate), + inIsrael: state.inIsrael, ), ), ], @@ -649,8 +636,8 @@ class CalendarWidget extends StatelessWidget { margin: const EdgeInsets.all(2), height: 88, decoration: BoxDecoration( - color: _getBackgroundColor( - context, gregorianDate, isSelected, isToday) ?? + color: _getBackgroundColor(context, gregorianDate, isSelected, + isToday, state.inIsrael) ?? (isSelected ? Theme.of(context).colorScheme.primaryContainer : isToday @@ -713,7 +700,7 @@ class CalendarWidget extends StatelessWidget { right: 4, child: _DayExtras( date: gregorianDate, - jewishCalendar: JewishCalendar.fromDateTime(gregorianDate), + inIsrael: state.inIsrael, ), ), ], @@ -1166,14 +1153,34 @@ class CalendarWidget extends StatelessWidget { } String _formatHebrewYear(int year) { + final hdf = HebrewDateFormatter(); + hdf.hebrewFormat = true; + final thousands = year ~/ 1000; final remainder = year % 1000; - if (thousands == 5) { - final hebrewRemainder = _numberToHebrewWithQuotes(remainder); - return 'ה\'$hebrewRemainder'; + + String remainderStr = hdf.formatHebrewNumber(remainder); + + String cleanRemainderStr = remainderStr + .replaceAll('"', '') + .replaceAll("'", "") + .replaceAll('׳', '') + .replaceAll('״', ''); + + String formattedRemainder; + if (cleanRemainderStr.length > 1) { + formattedRemainder = + '${cleanRemainderStr.substring(0, cleanRemainderStr.length - 1)}״${cleanRemainderStr.substring(cleanRemainderStr.length - 1)}'; + } else if (cleanRemainderStr.length == 1) { + formattedRemainder = '$cleanRemainderStr׳'; } else { - return _numberToHebrewWithQuotes(year); + formattedRemainder = cleanRemainderStr; } + if (thousands == 5) { + return 'ה׳$formattedRemainder'; + } + + return formattedRemainder; } String _formatHebrewDay(int day) { @@ -1957,19 +1964,25 @@ class CalendarWidget extends StatelessWidget { } } -// מציג תוספות קטנות בכל יום: זמני זריחה/שקיעה, מועדים, וכמות אירועים מותאמים +// מציג תוספות קטנות בכל יום: מועדים ואירועים מותאמים class _DayExtras extends StatelessWidget { final DateTime date; - final JewishCalendar jewishCalendar; - const _DayExtras({required this.date, required this.jewishCalendar}); + final bool inIsrael; + + const _DayExtras({ + required this.date, + required this.inIsrael, + }); @override Widget build(BuildContext context) { final cubit = context.read(); final events = cubit.eventsForDate(date); - final List lines = []; + final jewishCalendar = JewishCalendar.fromDateTime(date) + ..inIsrael = inIsrael; + for (final e in _calcJewishEvents(jewishCalendar).take(2)) { lines.add(Text( e, @@ -2001,59 +2014,78 @@ class _DayExtras extends StatelessWidget { ); } + static String _numberToHebrewLetter(int n) { + const letters = ['א', 'ב', 'ג', 'ד', 'ה', 'ו', 'ז', 'ח']; + if (n > 0 && n <= letters.length) { + return letters[n - 1]; + } + return ''; + } + static List _calcJewishEvents(JewishCalendar jc) { final List l = []; - if (jc.isRoshChodesh()) l.add('ר"ח'); - // טיפול מיוחד בתעניות - הצגת השם המלא במקום "צום" - switch (jc.getYomTovIndex()) { - case JewishCalendar.ROSH_HASHANA: - l.add('ראש השנה'); - break; - case JewishCalendar.YOM_KIPPUR: - l.add('יום כיפור'); - break; - case JewishCalendar.SUCCOS: - l.add('סוכות'); - break; - case JewishCalendar.SHEMINI_ATZERES: - l.add('שמיני עצרת'); - break; - case JewishCalendar.SIMCHAS_TORAH: - l.add('שמחת תורה'); - break; - case JewishCalendar.PESACH: - l.add('פסח'); - break; - case JewishCalendar.SHAVUOS: - l.add('שבועות'); - break; - case JewishCalendar.CHANUKAH: - l.add('חנוכה'); - break; + // 1. שימוש ב-Formatter הייעודי של החבילה כדי לקבל את כל שמות המועדים + final hdf = HebrewDateFormatter(); + hdf.hebrewFormat = true; // כדי לקבל שמות בעברית - default: - // בדיקה נוספת לתעניות שלא מזוהות בYomTovIndex - if (jc.isTaanis()) { - // אם זה תענית שלא זוהתה למעלה, נציג שם כללי - final jewishMonth = jc.getJewishMonth(); - final jewishDay = jc.getJewishDayOfMonth(); - - if (jewishMonth == 7 && jewishDay == 3) { - l.add('צום גדליה'); - } else if (jewishMonth == 10 && jewishDay == 10) { - l.add('עשרה בטבת'); - } else if (jewishMonth == 4 && jewishDay == 17) { - l.add('שבעה עשר בתמוז'); - } else if (jewishMonth == 5 && jewishDay == 9) { - l.add('תשעה באב'); - } else { - l.add('תענית'); - } + // הפונקציה formatYomTov מחזירה את שם החג, המועד, התענית או היום המיוחד + final yomTov = hdf.formatYomTov(jc); + if (yomTov != null && yomTov.isNotEmpty) { + // הפונקציה יכולה להחזיר מספר אירועים מופרדים בפסיק, למשל "ערב שבת, ערב ראש חודש" + // לכן אנחנו מפצלים אותם ומוסיפים כל אחד בנפרד + l.addAll(yomTov.split(',').map((e) => e.trim())); + } + + // 2. ה-Formatter לא תמיד מתייחס לר"ח כאל "יום טוב", אז נוסיף אותו ידנית אם צריך + if (jc.isRoshChodesh() && !l.contains('ראש חודש')) { + l.add('ר"ח'); + } + + // 3. שיפורים והתאמות אישיות שלנו על המידע מהחבילה + final yomTovIndex = jc.getYomTovIndex(); + + // פירוט ימי חול המועד (דורס את הטקסט הכללי "חול המועד") + if (yomTovIndex == JewishCalendar.CHOL_HAMOED_SUCCOS || + yomTovIndex == JewishCalendar.CHOL_HAMOED_PESACH) { + l.removeWhere((e) => e.contains('חול המועד')); // הסרת הטקסט הכללי + final dayOfCholHamoed = jc.getJewishDayOfMonth() - 15; + l.add('${_numberToHebrewLetter(dayOfCholHamoed)} דחוה"מ'); + } + + // פירוט ימי חנוכה (דורס את הטקסט הכללי "חנוכה") + if (yomTovIndex == JewishCalendar.CHANUKAH) { + // החלפנו את l.remove ל-l.removeWhere כדי לתפוס כל טקסט עם המילה "חנוכה" + l.removeWhere((e) => e.contains('חנוכה')); + + // והוספנו את הטקסט המדויק שלנו + final dayOfChanukah = jc.getDayOfChanukah(); + if (dayOfChanukah != -1) { + l.add('נר ${_numberToHebrewLetter(dayOfChanukah)} דחנוכה'); + } + } + + // הוספת פירוט להושענא רבה + if (yomTovIndex == JewishCalendar.HOSHANA_RABBA) { + l.add("ו' דחוה\"מ"); + } + + // וידוא שהלוגיקה של שמיני עצרת ושמחת תורה נשמרת + if (jc.getJewishMonth() == 7) { + if (jc.getJewishDayOfMonth() == 22) { + // כ"ב בתשרי + if (!l.contains('שמיני עצרת')) l.add('שמיני עצרת'); + if (jc.inIsrael && !l.contains('שמחת תורה')) { + l.add('שמחת תורה'); } - break; + } + if (jc.getJewishDayOfMonth() == 23 && !jc.inIsrael) { + if (!l.contains('שמחת תורה')) l.add('שמחת תורה'); + } } - return l; + + // מסיר כפילויות אפשריות (למשל אם הוספנו משהו שכבר היה קיים) + return l.toSet().toList(); } } From f21dc02afe9cae36eaa4cb1693414e6a9d068fa1 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 14 Sep 2025 19:44:43 +0300 Subject: [PATCH 191/197] =?UTF-8?q?=D7=97=D7=99=D7=A4=D7=95=D7=A9=20=D7=91?= =?UTF-8?q?=D7=94=D7=99=D7=A1=D7=98=D7=95=D7=A8=D7=99=D7=94=20=D7=95=D7=9E?= =?UTF-8?q?=D7=95=D7=A2=D7=93=D7=A4=D7=99=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bookmarks/bookmark_screen.dart | 162 +++++++++++++++++++++-------- lib/history/history_screen.dart | 77 +++++++++++++- 2 files changed, 189 insertions(+), 50 deletions(-) diff --git a/lib/bookmarks/bookmark_screen.dart b/lib/bookmarks/bookmark_screen.dart index bd0e98576..d229ba491 100644 --- a/lib/bookmarks/bookmark_screen.dart +++ b/lib/bookmarks/bookmark_screen.dart @@ -12,9 +12,40 @@ import 'package:otzaria/tabs/models/text_tab.dart'; import 'package:otzaria/models/books.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; -class BookmarkView extends StatelessWidget { +class BookmarkView extends StatefulWidget { const BookmarkView({Key? key}) : super(key: key); + @override + State createState() => _BookmarkViewState(); +} + +class _BookmarkViewState extends State { + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + String _searchQuery = ''; + + @override + void initState() { + super.initState(); + _searchController.addListener(() { + setState(() { + _searchQuery = _searchController.text; + }); + }); + + // Auto-focus the search field when the screen opens + WidgetsBinding.instance.addPostFrameCallback((_) { + _searchFocusNode.requestFocus(); + }); + } + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + void _openBook( BuildContext context, Book book, int index, List? commentators) { final tab = book is PdfBook @@ -40,58 +71,99 @@ class BookmarkView extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - return state.bookmarks.isEmpty - ? const Center(child: Text('אין סימניות')) - : Column( - children: [ - Expanded( - child: ListView.builder( - itemCount: state.bookmarks.length, - itemBuilder: (context, index) => ListTile( - selected: false, - leading: state.bookmarks[index].book is PdfBook - ? const Icon(Icons.picture_as_pdf) - : null, - title: Text(state.bookmarks[index].ref), - onTap: () => _openBook( - context, - state.bookmarks[index].book, - state.bookmarks[index].index, - state.bookmarks[index].commentatorsToShow), - trailing: IconButton( - icon: const Icon( - Icons.delete_forever, - ), - onPressed: () { - context - .read() - .removeBookmark(index); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('הסימניה נמחקה'), - ), - ); - }, - )), - ), + if (state.bookmarks.isEmpty) { + return const Center(child: Text('אין סימניות')); + } + + // Filter bookmarks based on search query + final filteredBookmarks = _searchQuery.isEmpty + ? state.bookmarks + : state.bookmarks.where((bookmark) => + bookmark.ref.toLowerCase().contains(_searchQuery.toLowerCase())).toList(); + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + decoration: InputDecoration( + hintText: 'חפש בסימניות...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), ), - Padding( - padding: const EdgeInsets.all(8.0), - child: ElevatedButton( + contentPadding: const EdgeInsets.symmetric(horizontal: 16.0), + ), + ), + ), + Expanded( + child: filteredBookmarks.isEmpty + ? const Center(child: Text('לא נמצאו תוצאות')) + : ListView.builder( + itemCount: filteredBookmarks.length, + itemBuilder: (context, index) { + final bookmark = filteredBookmarks[index]; + final originalIndex = state.bookmarks.indexOf(bookmark); + return ListTile( + selected: false, + leading: bookmark.book is PdfBook + ? const Icon(Icons.picture_as_pdf) + : null, + title: Text(bookmark.ref), + onTap: () => _openBook( + context, + bookmark.book, + bookmark.index, + bookmark.commentatorsToShow), + trailing: IconButton( + icon: const Icon( + Icons.delete_forever, + ), onPressed: () { - context.read().clearBookmarks(); + context + .read() + .removeBookmark(originalIndex); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('כל הסימניות נמחקו'), - duration: const Duration(milliseconds: 350), + content: Text('הסימניה נמחקה'), ), ); }, - child: const Text('מחק את כל הסימניות'), ), - ), - ], - ); + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: ElevatedButton( + onPressed: () { + context.read().clearBookmarks(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('כל הסימניות נמחקו'), + duration: const Duration(milliseconds: 350), + ), + ); + }, + child: const Text('מחק את כל הסימניות'), + ), + ), + ], + ); }, ); } diff --git a/lib/history/history_screen.dart b/lib/history/history_screen.dart index c0d369fbd..48edcf69e 100644 --- a/lib/history/history_screen.dart +++ b/lib/history/history_screen.dart @@ -16,8 +16,40 @@ import 'package:otzaria/tabs/models/searching_tab.dart'; import 'package:otzaria/tabs/models/text_tab.dart'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; -class HistoryView extends StatelessWidget { +class HistoryView extends StatefulWidget { const HistoryView({Key? key}) : super(key: key); + + @override + State createState() => _HistoryViewState(); +} + +class _HistoryViewState extends State { + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + String _searchQuery = ''; + + @override + void initState() { + super.initState(); + _searchController.addListener(() { + setState(() { + _searchQuery = _searchController.text; + }); + }); + + // Auto-focus the search field when the screen opens + WidgetsBinding.instance.addPostFrameCallback((_) { + _searchFocusNode.requestFocus(); + }); + } + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + void _openBook( BuildContext context, Book book, int index, List? commentators) { final tab = book is PdfBook @@ -77,13 +109,48 @@ class HistoryView extends StatelessWidget { return const Center(child: Text('אין היסטוריה')); } + // Filter history based on search query + final filteredHistory = _searchQuery.isEmpty + ? state.history + : state.history.where((item) => + item.ref.toLowerCase().contains(_searchQuery.toLowerCase())).toList(); + return Column( children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + decoration: InputDecoration( + hintText: 'חפש בהיסטוריה...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16.0), + ), + ), + ), Expanded( - child: ListView.builder( - itemCount: state.history.length, + child: filteredHistory.isEmpty + ? const Center(child: Text('לא נמצאו תוצאות')) + : ListView.builder( + itemCount: filteredHistory.length, itemBuilder: (context, index) { - final historyItem = state.history[index]; + final historyItem = filteredHistory[index]; + final originalIndex = state.history.indexOf(historyItem); return ListTile( leading: _getLeadingIcon(historyItem.book, historyItem.isSearch), @@ -134,7 +201,7 @@ class HistoryView extends StatelessWidget { trailing: IconButton( icon: const Icon(Icons.delete_forever), onPressed: () { - context.read().add(RemoveHistory(index)); + context.read().add(RemoveHistory(originalIndex)); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('נמחק בהצלחה')), ); From 56aafdc75f68f1bc8c294147e1d98cb32ae08a60 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 14 Sep 2025 23:55:30 +0300 Subject: [PATCH 192/197] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=A0=D7=99=20?= =?UTF-8?q?=D7=9E=D7=A4=D7=AA=D7=97=D7=99=D7=9D=20=D7=9E=D7=94=D7=9E=D7=90?= =?UTF-8?q?=D7=92=D7=A8=20=D7=A9=D7=9C=D7=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/update/my_updat_widget.dart | 62 +++++++++++++++++++-------------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/lib/update/my_updat_widget.dart b/lib/update/my_updat_widget.dart index d9d95bce5..ec9805581 100644 --- a/lib/update/my_updat_widget.dart +++ b/lib/update/my_updat_widget.dart @@ -49,19 +49,21 @@ class MyUpdatWidget extends StatelessWidget { return UpdatWindowManager( getLatestVersion: () async { // Github gives us a super useful latest endpoint, and we can use it to get the latest stable release - final isDevChannel = Settings.getValue('key-dev-channel') ?? false; - + final isDevChannel = + Settings.getValue('key-dev-channel') ?? false; + if (isDevChannel) { // For dev channel, get the latest pre-release from the main repo final data = await http.get(Uri.parse( - "https://api.github.com/repos/sivan22/otzaria/releases", + "https://api.github.com/repos/Y-PLONI/otzaria/releases", )); final releases = jsonDecode(data.body) as List; // Find the first pre-release that is not a draft and not a PR preview final preRelease = releases.firstWhere( - (release) => release["prerelease"] == true && - release["draft"] == false && - !release["tag_name"].toString().contains('-pr-'), + (release) => + release["prerelease"] == true && + release["draft"] == false && + !release["tag_name"].toString().contains('-pr-'), orElse: () => releases.first, ); return preRelease["tag_name"]; @@ -75,22 +77,26 @@ class MyUpdatWidget extends StatelessWidget { }, getBinaryUrl: (version) async { // Get the release info to find the correct asset + final isDevChannelForBinary = + Settings.getValue('key-dev-channel') ?? false; + final repo = isDevChannelForBinary ? "Y-PLONI" : "sivan22"; final data = await http.get(Uri.parse( - "https://api.github.com/repos/sivan22/otzaria/releases/tags/$version", + "https://api.github.com/repos/$repo/otzaria/releases/tags/$version", )); final release = jsonDecode(data.body); final assets = release["assets"] as List; - + // Find the appropriate asset for the current platform final platformName = Platform.operatingSystem; - final isDevChannel = Settings.getValue('key-dev-channel') ?? false; - + final isDevChannel = + Settings.getValue('key-dev-channel') ?? false; + String? assetUrl; - + for (final asset in assets) { final name = asset["name"] as String; final downloadUrl = asset["browser_download_url"] as String; - + switch (platformName) { case 'windows': // For dev channel prefer MSIX, otherwise EXE @@ -102,11 +108,13 @@ class MyUpdatWidget extends StatelessWidget { break; } // Fallback: Windows ZIP - if (name.contains('windows') && name.endsWith('.zip') && assetUrl == null) { + if (name.contains('windows') && + name.endsWith('.zip') && + assetUrl == null) { assetUrl = downloadUrl; } break; - + case 'macos': // Look for macOS zip file (workflow creates otzaria-macos.zip) if (name.contains('macos') && name.endsWith('.zip')) { @@ -114,7 +122,7 @@ class MyUpdatWidget extends StatelessWidget { break; } break; - + case 'linux': // Prefer DEB, then RPM, then raw zip (workflow creates otzaria-linux-raw.zip) if (name.endsWith('.deb')) { @@ -122,34 +130,38 @@ class MyUpdatWidget extends StatelessWidget { break; } else if (name.endsWith('.rpm') && assetUrl == null) { assetUrl = downloadUrl; - } else if (name.contains('linux') && name.endsWith('.zip') && assetUrl == null) { + } else if (name.contains('linux') && + name.endsWith('.zip') && + assetUrl == null) { assetUrl = downloadUrl; } break; } } - + if (assetUrl == null) { throw Exception('No suitable binary found for $platformName'); } - + return assetUrl; }, appName: "otzaria", // This is used to name the downloaded files. getChangelog: (_, __) async { // That same latest endpoint gives us access to a markdown-flavored release body. Perfect! - final isDevChannel = Settings.getValue('key-dev-channel') ?? false; - + final isDevChannel = + Settings.getValue('key-dev-channel') ?? false; + if (isDevChannel) { // For dev channel, get changelog from the latest pre-release final data = await http.get(Uri.parse( - "https://api.github.com/repos/sivan22/otzaria/releases", + "https://api.github.com/repos/Y-PLONI/otzaria/releases", )); final releases = jsonDecode(data.body) as List; final preRelease = releases.firstWhere( - (release) => release["prerelease"] == true && - release["draft"] == false && - !release["tag_name"].toString().contains('-pr-'), + (release) => + release["prerelease"] == true && + release["draft"] == false && + !release["tag_name"].toString().contains('-pr-'), orElse: () => releases.first, ); return preRelease["body"]; @@ -168,6 +180,4 @@ class MyUpdatWidget extends StatelessWidget { child: child, ); }); - - } From c3304c148973d072328249fb1bfe4de952fc7af7 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Sun, 14 Sep 2025 23:56:02 +0300 Subject: [PATCH 193/197] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20=D7=92?= =?UTF-8?q?=D7=A8=D7=A1=D7=94=20=D7=9C952?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++-- installer/otzaria.iss | 2 +- installer/otzaria_full.iss | 2 +- pubspec.yaml | 4 ++-- version.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 0ba9938ef..c81ea435d 100644 --- a/.gitignore +++ b/.gitignore @@ -60,7 +60,7 @@ android/ build/ fonts/ -installer/otzaria-0.9.51-windows.exe -installer/otzaria-0.9.51-windows-full.exe +installer/otzaria-0.9.52-windows.exe +installer/otzaria-0.9.52-windows-full.exe pubspec.lock flutter/ diff --git a/installer/otzaria.iss b/installer/otzaria.iss index 1afb525d8..d2c24d723 100644 --- a/installer/otzaria.iss +++ b/installer/otzaria.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.51" +#define MyAppVersion "0.9.52" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/installer/otzaria_full.iss b/installer/otzaria_full.iss index 5ab6b83b7..9685f4cae 100644 --- a/installer/otzaria_full.iss +++ b/installer/otzaria_full.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.51" +#define MyAppVersion "0.9.52" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/pubspec.yaml b/pubspec.yaml index 6da721d50..1532e70c1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,7 @@ msix_config: publisher_display_name: sivan22 identity_name: sivan22.Otzaria description: "ספריית ספרים יהודיים עם אפשרויות חיפוש חכם" - msix_version: 0.9.51.0 + msix_version: 0.9.52.0 logo_path: assets/icon/icon.png publisher: CN=sivan22, O=sivan22, C=IL certificate_path: sivan22.pfx @@ -36,7 +36,7 @@ msix_config: # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.9.51 +version: 0.9.52 environment: sdk: ">=3.2.6 <4.0.0" diff --git a/version.json b/version.json index d4df9cf73..098c394c5 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "0.9.51" + "version": "0.9.52" } \ No newline at end of file From 0218b62f0a5127270d0b2d78c10f5e0aab76eb42 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 15 Sep 2025 16:22:00 +0300 Subject: [PATCH 194/197] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=A0=D7=99?= =?UTF-8?q?=D7=9D=20=D7=91=D7=9E=D7=AA=D7=9E=D7=97=D7=99=D7=9D=20=D7=95?= =?UTF-8?q?=D7=91=D7=A7=D7=95=20=D7=90=D7=95=D7=A6=D7=A8=D7=99=D7=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release-webhook.yml | 34 +++++++++++ webhooks/main.py | 52 +++++++++++++++++ webhooks/mitmachim.py | 84 +++++++++++++++++++++++++++ webhooks/yemot.py | 62 ++++++++++++++++++++ 4 files changed, 232 insertions(+) create mode 100644 .github/workflows/release-webhook.yml create mode 100644 webhooks/main.py create mode 100644 webhooks/mitmachim.py create mode 100644 webhooks/yemot.py diff --git a/.github/workflows/release-webhook.yml b/.github/workflows/release-webhook.yml new file mode 100644 index 000000000..08459801a --- /dev/null +++ b/.github/workflows/release-webhook.yml @@ -0,0 +1,34 @@ +name: Send Release Webhook + +on: + release: + types: [published] + + workflow_dispatch: # מאפשר הפעלה ידנית + +jobs: + send_webhook: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: pip install bs4 requests pyluach + + - name: Run webhook script + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_NAME: ${{ github.event.release.name }} + RELEASE_BODY: ${{ github.event.release.body }} + RELEASE_URL: ${{ github.event.release.html_url }} + USER_NAME: ${{ secrets.USER_NAME }} + PASSWORD: ${{ secrets.PASSWORD }} + TOKEN_YEMOT: ${{ secrets.TOKEN_YEMOT }} + run: python webhooks/main.py diff --git a/webhooks/main.py b/webhooks/main.py new file mode 100644 index 000000000..a506bb6bb --- /dev/null +++ b/webhooks/main.py @@ -0,0 +1,52 @@ +import os +import json + +from pyluach import dates + +from mitmachim import MitmachimClient +from yemot import split_and_send + + +def heb_date() -> str: + today = dates.HebrewDate.today() + date_str = today.hebrew_date_string() + return date_str + + +date_str = heb_date() +RELEASE_TAG = os.getenv("RELEASE_TAG", "Unknown") +RELEASE_NAME = os.getenv("RELEASE_NAME", "No Name") +RELEASE_BODY = os.getenv("RELEASE_BODY", "") +RELEASE_URL = os.getenv("RELEASE_URL", "") +GITHUB_EVENT_PATH = os.getenv("GITHUB_EVENT_PATH") +username = os.getenv("USER_NAME") +password = os.getenv("PASSWORD") +yemot_token = os.getenv("TOKEN_YEMOT") +asset_links = [] +if GITHUB_EVENT_PATH: + with open(GITHUB_EVENT_PATH, "r", encoding="utf-8") as f: + event_data = json.load(f) + assets = event_data.get("release", {}).get("assets", []) + for asset in assets: + asset_links.append(f"[{asset['name']}]({asset['browser_download_url']})") +date_yemot = f"עדכון {date_str}\n" +yemot_path = "ivr2:/2" +tzintuk_list_name = "software update" +yemot_message = f"עדכון {date_str}\nשחרור {RELEASE_NAME}\nפרטים: {RELEASE_BODY}\n" +content_mitmachim = f"עדכון {date_str}\nשחרור {RELEASE_NAME}\nפרטים: {RELEASE_BODY}\n{RELEASE_URL}\nקבצים מצורפים:\n* {"\n* ".join(asset_links)}" + +client = MitmachimClient(username.strip().replace(" ", "+"), password.strip()) +if asset_links: + try: + client.login() + topic_id = 87961 + client.send_post(content_mitmachim, topic_id) + except Exception as e: + print(e) + finally: + client.logout() + + try: + split_and_send(yemot_message, date_yemot, yemot_token, yemot_path, tzintuk_list_name) + except Exception as e: + print(e) diff --git a/webhooks/mitmachim.py b/webhooks/mitmachim.py new file mode 100644 index 000000000..693df5d7e --- /dev/null +++ b/webhooks/mitmachim.py @@ -0,0 +1,84 @@ +import requests +from bs4 import BeautifulSoup +import re +import uuid + + +class MitmachimClient: + def __init__(self, username, password): + self.base_url = "https://mitmachim.top" + self.session = requests.Session() + self.username = username + self.password = password + self.csrf_token = None + self.headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Accept-Language": "he,he-IL;q=0.8,en-US;q=0.5,en;q=0.3", + } + + @staticmethod + def extract_csrf_token(html): + def find_token_in_script(script_text): + csrf_match = re.search(r'"csrf_token":"([^"]+)"', script_text) + return csrf_match.group(1) if csrf_match else None + + soup = BeautifulSoup(html, "html.parser") + script_tags = soup.find_all("script") + for script in script_tags: + if "csrf" in str(script): + return find_token_in_script(str(script)) + return None + + def fetch_csrf_token(self): + login_page = self.session.get(f"{self.base_url}/login", headers=self.headers) + self.csrf_token = self.extract_csrf_token(login_page.text) + + def login(self): + self.fetch_csrf_token() + if not self.csrf_token: + raise ValueError("Failed to fetch CSRF token") + + login_data = { + "username": self.username, + "password": self.password, + "_csrf": self.csrf_token, + "noscript": "false", + "remember": "on", + } + login_headers = self.headers.copy() + login_headers.update({ + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "x-csrf-token": self.csrf_token, + }) + + response = self.session.post(f"{self.base_url}/login", headers=login_headers, data=login_data) + if response.status_code != 200: + raise ValueError(f"Login failed with status code {response.status_code}") + print("Login successful") + + def send_post(self, content, topic_id, to_pid=None): + post_url = f"{self.base_url}/api/v3/topics/{topic_id}" + post_headers = self.headers.copy() + post_headers.update({ + "Content-Type": "application/json; charset=utf-8", + "x-csrf-token": self.csrf_token, + }) + data = { + "uuid": str(uuid.uuid4()), + "tid": topic_id, + "handle": "", + "content": content, + "toPid": to_pid, + } + response = self.session.post(post_url, json=data, headers=post_headers) + return response.json() + + def logout(self): + logout_url = f"{self.base_url}/logout" + logout_headers = self.headers.copy() + logout_headers.update({"x-csrf-token": self.csrf_token}) + response = self.session.post(logout_url, headers=logout_headers) + if response.status_code == 200: + print("Logout successful") + else: + print(f"Logout failed with status code {response.status_code}") diff --git a/webhooks/yemot.py b/webhooks/yemot.py new file mode 100644 index 000000000..45afbd324 --- /dev/null +++ b/webhooks/yemot.py @@ -0,0 +1,62 @@ +import requests + + +BASE_URL = "https://www.call2all.co.il/ym/api/" + + +def split_content(content: str) -> list[str]: + all_partes = [] + start = 0 + chunk_size = 2000 + while len(content) - start > chunk_size: + part = content[start:content.rfind("\n", start, start + chunk_size)] + all_partes.append(part.strip()) + start += len(part) + all_partes.append(content[start:].strip()) + return all_partes + + +def split_and_send(content: str, date_yemot: str, token: str, path: str, tzintuk_list_name: str): + num = get_file_num(token, path) + all_partes = split_content(content) + for chunk in all_partes[-1::-1]: + num += 1 + file_name = str(num).zfill(3) + send_to_yemot(chunk, token, path, file_name) + send_to_yemot(date_yemot, token, path, f"{file_name}-Title") + send_tzintuk(token, tzintuk_list_name) + + +def send_to_yemot(content: str, token: str, path: str, file_name: str) -> int: + url = f"{BASE_URL}UploadTextFile" + data = { + "token": token, + "what": f"{path}/{file_name}.tts", + "contents": content + } + response = requests.post(url, data=data) + return response.status_code + + +def get_file_num(token: str, path: str) -> int: + url = f"{BASE_URL}GetIVR2DirStats" + data = { + "token": token, + "path": path + } + response = requests.get(url, params=data).json() + try: + max_file = response["maxFile"]["name"] + return int(max_file.split(".")[0]) + except: + return -1 + + +def send_tzintuk(token: str, list_name: str) -> int: + url = f"{BASE_URL}RunTzintuk" + data = { + "token": token, + "phones": f"tzl:{list_name}" + } + response = requests.get(url, params=data) + return response.status_code From 1593d62770706be33f3de2f51b154f8040f0967a Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 15 Sep 2025 16:22:56 +0300 Subject: [PATCH 195/197] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20=D7=9E?= =?UTF-8?q?=D7=99=D7=A7=D7=95=D7=9D=20=D7=94=D7=95=D7=A8=D7=93=D7=AA=20?= =?UTF-8?q?=D7=94=D7=9E=D7=90=D7=92=D7=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/empty_library/bloc/empty_library_bloc.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/empty_library/bloc/empty_library_bloc.dart b/lib/empty_library/bloc/empty_library_bloc.dart index 7fd0e3c7d..2b0dcf5f3 100644 --- a/lib/empty_library/bloc/empty_library_bloc.dart +++ b/lib/empty_library/bloc/empty_library_bloc.dart @@ -160,7 +160,7 @@ class EmptyLibraryBloc extends Bloc { final request = http.Request( 'GET', Uri.parse( - 'https://github.com/Sivan22/otzaria-library/releases/download/latest/otzaria_latest.zip'), + 'https://github.com/zevisvei/otzaria-library/releases/download/latest/otzaria_latest.zip'), ); final response = await http.Client().send(request); From cff47656f6a4c16455fd5b950ea3fc5402fb3d6e Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Mon, 15 Sep 2025 16:54:02 +0300 Subject: [PATCH 196/197] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20=D7=9C?= =?UTF-8?q?953,=20=D7=9C=D7=A6=D7=95=D7=A8=D7=9A=20=D7=91=D7=93=D7=99?= =?UTF-8?q?=D7=A7=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++-- installer/otzaria.iss | 2 +- installer/otzaria_full.iss | 2 +- pubspec.yaml | 4 ++-- version.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index c81ea435d..33cfc2791 100644 --- a/.gitignore +++ b/.gitignore @@ -60,7 +60,7 @@ android/ build/ fonts/ -installer/otzaria-0.9.52-windows.exe -installer/otzaria-0.9.52-windows-full.exe +installer/otzaria-0.9.53-windows.exe +installer/otzaria-0.9.53-windows-full.exe pubspec.lock flutter/ diff --git a/installer/otzaria.iss b/installer/otzaria.iss index d2c24d723..5a3ea3097 100644 --- a/installer/otzaria.iss +++ b/installer/otzaria.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.52" +#define MyAppVersion "0.9.53" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/installer/otzaria_full.iss b/installer/otzaria_full.iss index 9685f4cae..5f37d8c36 100644 --- a/installer/otzaria_full.iss +++ b/installer/otzaria_full.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "אוצריא" -#define MyAppVersion "0.9.52" +#define MyAppVersion "0.9.53" #define MyAppPublisher "sivan22" #define MyAppURL "https://github.com/Sivan22/otzaria" #define MyAppExeName "otzaria.exe" diff --git a/pubspec.yaml b/pubspec.yaml index 1532e70c1..2bde87f28 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,7 @@ msix_config: publisher_display_name: sivan22 identity_name: sivan22.Otzaria description: "ספריית ספרים יהודיים עם אפשרויות חיפוש חכם" - msix_version: 0.9.52.0 + msix_version: 0.9.53.0 logo_path: assets/icon/icon.png publisher: CN=sivan22, O=sivan22, C=IL certificate_path: sivan22.pfx @@ -36,7 +36,7 @@ msix_config: # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.9.52 +version: 0.9.53 environment: sdk: ">=3.2.6 <4.0.0" diff --git a/version.json b/version.json index 098c394c5..f58143d81 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "0.9.52" + "version": "0.9.53" } \ No newline at end of file From 0bd6cd2a1cccb03d9cb2f442bcc19fef200e4ec1 Mon Sep 17 00:00:00 2001 From: Sivan Ratson <89018301+Sivan22@users.noreply.github.com> Date: Mon, 15 Sep 2025 20:26:16 +0300 Subject: [PATCH 197/197] Update flutter.yml --- .github/workflows/flutter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index 72abe36d9..8303fac19 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -373,7 +373,7 @@ jobs: run: | VERSION=$(grep '^version:' pubspec.yaml | cut -d ' ' -f 2 | cut -d '+' -f 1) echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "tag=v$VERSION-pr-${{ github.event.number }}-${{ github.run_number }}" >> $GITHUB_OUTPUT + echo "tag=$VERSION-pr-${{ github.event.number }}-${{ github.run_number }}" >> $GITHUB_OUTPUT echo "title=Otzaria v$VERSION - PR #${{ github.event.number }} Preview" >> $GITHUB_OUTPUT - name: Get commit message