diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d6130f2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + pull_request: + branches: + - main + +jobs: + test: + name: Run tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + + - name: Install dependencies + working-directory: manylines_editor + run: flutter pub get + + - name: Run tests + working-directory: manylines_editor + run: flutter test \ No newline at end of file diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..8888aac --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,48 @@ +name: Deploy Dart docs to GitHub Pages + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + + +jobs: + build: + name: Build documentation + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + + - name: Install dependencies + working-directory: manylines_editor + run: flutter pub get + + - name: Generate docs + working-directory: manylines_editor + run: dart doc . + + - name: Upload Pages artifact from Dart docs + uses: actions/upload-pages-artifact@v3 + with: + path: manylines_editor/doc/api + deploy: + name: Deploy to GitHub Pages + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/manylines_editor/.metadata b/manylines_editor/.metadata index 89db5de..4ed0c94 100644 --- a/manylines_editor/.metadata +++ b/manylines_editor/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "ff37bef603469fb030f2b72995ab929ccfc227f0" + revision: "48c32af0345e9ad5747f78ddce828c7f795f7159" channel: "stable" project_type: app @@ -13,26 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - platform: android - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - platform: ios - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - platform: linux - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - platform: macos - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 + base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 - platform: web - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - platform: windows - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 + base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 # User provided section diff --git a/manylines_editor/README.md b/manylines_editor/README.md new file mode 100644 index 0000000..2e2531d --- /dev/null +++ b/manylines_editor/README.md @@ -0,0 +1,3 @@ +# manylines_editor + +A new Flutter project. diff --git a/manylines_editor/lib/app/app.dart b/manylines_editor/lib/app/app.dart new file mode 100644 index 0000000..8bd06f5 --- /dev/null +++ b/manylines_editor/lib/app/app.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'providers.dart'; +import 'theme.dart'; +import 'router.dart'; +import '../entities/setting/setting_repository.dart'; + +class ManyllinesApp extends StatelessWidget { + const ManyllinesApp({super.key}); + + @override + Widget build(BuildContext context) { + return AppProviders( + child: Consumer( + builder: (context, settingState, _) { + return MaterialApp( + title: 'Manylines', + theme: AppTheme.light, + darkTheme: AppTheme.dark, + themeMode: settingState.isDarkMode ? ThemeMode.dark : ThemeMode.light, + localizationsDelegates: AppLocalizations.delegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('ru', 'RU'), + home: const AppRouter(), + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/app/providers.dart b/manylines_editor/lib/app/providers.dart new file mode 100644 index 0000000..c02aac5 --- /dev/null +++ b/manylines_editor/lib/app/providers.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../entities/project/project_repository.dart'; +import '../entities/document/document_repository.dart'; +import '../entities/setting/setting_repository.dart'; + +class AppProviders extends StatelessWidget { + final Widget child; + + const AppProviders({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + + final projectRepo = ProjectRepository(); + final documentRepo = DocumentRepository(projectRepo); + final settingRepo = SettingRepository(); + + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: projectRepo), + ChangeNotifierProvider.value(value: documentRepo), + ChangeNotifierProvider.value(value: settingRepo), + ], + child: child, + ); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/app/router.dart b/manylines_editor/lib/app/router.dart new file mode 100644 index 0000000..5eae0f9 --- /dev/null +++ b/manylines_editor/lib/app/router.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../entities/project/project.dart'; +import '../pages/projects/projects_page.dart'; +import '../pages/workspace/workspace_page.dart'; +import '../entities/project/project_repository.dart'; + +class AppRouter extends StatelessWidget { + const AppRouter({super.key}); + + @override + Widget build(BuildContext context) { + return Selector( + selector: (_, repo) => repo.selectedProject, + builder: (context, selectedProject, _) { + return selectedProject == null + ? const ProjectsPage() + : const WorkspacePage(); + }, + ); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/app/theme.dart b/manylines_editor/lib/app/theme.dart new file mode 100644 index 0000000..1eb2ced --- /dev/null +++ b/manylines_editor/lib/app/theme.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart' as quill; +import 'package:flutter_localizations/flutter_localizations.dart'; + +class AppTheme { + static final light = ThemeData( + useMaterial3: true, + colorSchemeSeed: Colors.green, + brightness: Brightness.light, + fontFamily: 'Roboto', + ); + + static final dark = ThemeData( + useMaterial3: true, + colorSchemeSeed: Colors.green, + brightness: Brightness.dark, + fontFamily: 'Roboto', + ); +} + +class AppLocalizations { + static const delegates = [ + quill.FlutterQuillLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ]; + + static const supportedLocales = [ + Locale('ru', 'RU'), + Locale('en', 'US'), + ]; +} \ No newline at end of file diff --git a/manylines_editor/lib/entities/document/document.dart b/manylines_editor/lib/entities/document/document.dart new file mode 100644 index 0000000..7138006 --- /dev/null +++ b/manylines_editor/lib/entities/document/document.dart @@ -0,0 +1,25 @@ +import 'package:dart_quill_delta/dart_quill_delta.dart'; +import '../glossary_entry/glossary_entry.dart'; + +class AppDocument { + final String id; + final String name; + int viewCount; + bool isPinned; + String? parentId; + Delta content; + List glossary; // ✅ Изменяемый список + + AppDocument({ + required this.id, + required this.name, + this.viewCount = 0, + this.isPinned = false, + this.parentId, + required this.content, + List? glossary, // ✅ Изменили на nullable + }) : glossary = glossary ?? []; // ✅ Создаём mutable список + + + bool get isChild => parentId != null; +} \ No newline at end of file diff --git a/manylines_editor/lib/entities/document/document_repository.dart b/manylines_editor/lib/entities/document/document_repository.dart new file mode 100644 index 0000000..a25f1a2 --- /dev/null +++ b/manylines_editor/lib/entities/document/document_repository.dart @@ -0,0 +1,237 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart' as quill; +import 'package:dart_quill_delta/dart_quill_delta.dart'; +import 'document.dart'; +import '../glossary_entry/glossary_entry.dart'; +import '../project/project_repository.dart'; + +class DocumentRepository extends ChangeNotifier { + final ProjectRepository _projectRepo; + + AppDocument? _selectedDocument; + AppDocument? _secondSelectedDocument; + bool _isGlossaryPanelOpen = false; + String? _selectedTextForGlossary; + + // ✅ Хранилище контроллеров + final Map _controllers = {}; + + DocumentRepository(this._projectRepo); + + AppDocument? get selectedDocument => _selectedDocument; + AppDocument? get secondSelectedDocument => _secondSelectedDocument; + bool get isGlossaryPanelOpen => _isGlossaryPanelOpen; + String? get selectedTextForGlossary => _selectedTextForGlossary; + + AppDocument createDocument({ + required String name, + required Delta content, + String? parentId, +}) { + return AppDocument( + id: 'd${DateTime.now().millisecondsSinceEpoch}', + name: name, + content: content, + parentId: parentId, + glossary: [], // ✅ Явно передаём mutable список + ); +} + + // ✅ Создаём или получаем контроллер + quill.QuillController getOrCreateController(AppDocument document) { + if (_controllers.containsKey(document.id)) { + return _controllers[document.id]!; + } + + final controller = quill.QuillController( + document: quill.Document.fromJson(document.content.toJson()), + selection: const TextSelection.collapsed(offset: 0), + ); + + // ✅ Сохраняем изменения в документ + controller.changes.listen((change) { + document.content = controller.document.toDelta(); + }); + + _controllers[document.id] = controller; + return controller; + } + + // ✅ Вызывать только при удалении документа или закрытии приложения + void disposeController(String documentId) { + final controller = _controllers[documentId]; + if (controller != null) { + controller.dispose(); + _controllers.remove(documentId); + } + } + + // ✅ Вызывать при закрытии приложения + void disposeAll() { + for (var controller in _controllers.values) { + controller.dispose(); + } + _controllers.clear(); + } + + void selectDocument(AppDocument document) { + _selectedDocument = document; + incrementViewCount(document); + notifyListeners(); + } + + void selectSecondDocument(AppDocument document) { + _secondSelectedDocument = document; + incrementViewCount(document); + notifyListeners(); + } + + void closeFirstEditor() { + _selectedDocument = null; + notifyListeners(); + } + + void closeSecondEditor() { + _secondSelectedDocument = null; + notifyListeners(); + } + + void closeEditorIfOpen(String documentId) { + if (_selectedDocument?.id == documentId) _selectedDocument = null; + if (_secondSelectedDocument?.id == documentId) _secondSelectedDocument = null; + notifyListeners(); + } + + void incrementViewCount(AppDocument doc) { + doc.viewCount++; + notifyListeners(); + } + + void togglePin(AppDocument doc) { + doc.isPinned = !doc.isPinned; + notifyListeners(); // ✅ Обязательно! +} + + void indentDocument(String documentId, String parentId) { + final project = _projectRepo.selectedProject; + if (project == null) return; + + final doc = project.documents.firstWhere((d) => d.id == documentId); + doc.parentId = parentId; + notifyListeners(); + } + + void outdentDocument(String documentId) { + final project = _projectRepo.selectedProject; + if (project == null) return; + + final doc = project.documents.firstWhere((d) => d.id == documentId); + doc.parentId = null; + notifyListeners(); + } + + void toggleGlossaryPanel() { + _isGlossaryPanelOpen = !_isGlossaryPanelOpen; + notifyListeners(); + } + + void setSelectedTextForGlossary(String text) { + _selectedTextForGlossary = text; + notifyListeners(); + } + + void clearSelectedTextForGlossary() { + _selectedTextForGlossary = null; + notifyListeners(); + } + + void addGlossaryEntry(String documentId, String term) { + print('🔍 addGlossaryEntry вызван'); + print(' - documentId: $documentId'); + print(' - term: $term'); + + final project = _projectRepo.selectedProject; + print(' - selectedProject: ${project?.name ?? "null"}'); + + if (project == null) { + print('❌ Проект не выбран'); + return; + } + + try { + final doc = project.documents.firstWhere((d) => d.id == documentId); + print(' - Найден документ: ${doc.name}'); + print(' - Глоссарий до: ${doc.glossary.length} записей'); + + final entry = GlossaryEntry( + id: 'g${DateTime.now().millisecondsSinceEpoch}', + term: term, + definition: '', + isExpanded: true, + ); + + doc.glossary.add(entry); + print(' - Глоссарий после: ${doc.glossary.length} записей'); + print('✅ Термин добавлен успешно'); + + notifyListeners(); + } catch (e) { + print('❌ Ошибка: $e'); + } +} + + void updateGlossaryDefinition(String entryId, String definition) { + final project = _projectRepo.selectedProject; + if (project == null) return; + + // ✅ Ищем во ВСЕХ документах проекта + for (var doc in project.documents) { + final index = doc.glossary.indexWhere((e) => e.id == entryId); + if (index != -1) { + // ✅ Обновляем только найденную запись + doc.glossary[index].definition = definition; + notifyListeners(); + return; // ✅ Выходим после первого нахождения + } + } +} + + // lib/entities/document/document_repository.dart + +void toggleGlossaryEntry(String entryId) { + final project = _projectRepo.selectedProject; + if (project == null) return; + + // ✅ Ищем во ВСЕХ документах + for (var doc in project.documents) { + final index = doc.glossary.indexWhere((e) => e.id == entryId); + if (index != -1) { + // ✅ Переключаем только найденную запись + doc.glossary[index].isExpanded = !doc.glossary[index].isExpanded; + notifyListeners(); + return; // ✅ Выходим после первого нахождения + } + } +} + +void openGlossaryPanel() { + _isGlossaryPanelOpen = true; + notifyListeners(); +} + + void deleteGlossaryEntry(String entryId) { + final project = _projectRepo.selectedProject; + if (project == null) return; + + for (var doc in project.documents) { + doc.glossary.removeWhere((e) => e.id == entryId); + } + notifyListeners(); + } + + // ✅ Вызывать при удалении документа + void deleteDocumentControllers(String documentId) { + disposeController(documentId); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/entities/glossary_entry/glossary_entry.dart b/manylines_editor/lib/entities/glossary_entry/glossary_entry.dart new file mode 100644 index 0000000..48e99ca --- /dev/null +++ b/manylines_editor/lib/entities/glossary_entry/glossary_entry.dart @@ -0,0 +1,15 @@ +class GlossaryEntry { + final String id; + final String term; + String definition; + bool isExpanded; + DateTime createdAt; + + GlossaryEntry({ + required this.id, + required this.term, + this.definition = '', + this.isExpanded = false, + DateTime? createdAt, + }) : createdAt = createdAt ?? DateTime.now(); +} \ No newline at end of file diff --git a/manylines_editor/lib/entities/glossary_entry/glossary_repository.dart b/manylines_editor/lib/entities/glossary_entry/glossary_repository.dart new file mode 100644 index 0000000..d402ef3 --- /dev/null +++ b/manylines_editor/lib/entities/glossary_entry/glossary_repository.dart @@ -0,0 +1,43 @@ +import 'package:flutter/foundation.dart'; +import 'glossary_entry.dart'; + +class GlossaryRepository extends ChangeNotifier { + final Map> _entries = {}; + + List getEntries(String documentId) { + return _entries[documentId] ?? []; + } + + void addEntry(String documentId, GlossaryEntry entry) { + _entries.putIfAbsent(documentId, () => []); + _entries[documentId]!.add(entry); + notifyListeners(); + } + + void updateEntry(String documentId, String entryId, String definition) { + final entries = _entries[documentId]; + if (entries != null) { + final index = entries.indexWhere((e) => e.id == entryId); + if (index != -1) { + entries[index].definition = definition; + notifyListeners(); + } + } + } + + void deleteEntry(String documentId, String entryId) { + _entries[documentId]?.removeWhere((e) => e.id == entryId); + notifyListeners(); + } + + void toggleEntry(String documentId, String entryId) { + final entries = _entries[documentId]; + if (entries != null) { + final index = entries.indexWhere((e) => e.id == entryId); + if (index != -1) { + entries[index].isExpanded = !entries[index].isExpanded; + notifyListeners(); + } + } + } +} \ No newline at end of file diff --git a/manylines_editor/lib/entities/project/project.dart b/manylines_editor/lib/entities/project/project.dart new file mode 100644 index 0000000..f4a7efe --- /dev/null +++ b/manylines_editor/lib/entities/project/project.dart @@ -0,0 +1,28 @@ +import '../document/document.dart'; + +class Project { + final String id; + final String name; + final List documents; + + Project({ + required this.id, + required this.name, + required this.documents, + }); + + int get maxViewCount { + if (documents.isEmpty) return 0; + return documents.map((doc) => doc.viewCount).reduce((a, b) => a > b ? a : b); + } + + bool isDocumentMostUsed(AppDocument doc) { + return doc.viewCount >= maxViewCount && maxViewCount > 0; + } + + List get pinnedDocuments => + documents.where((doc) => doc.isPinned).toList(); + + List get unpinnedDocuments => + documents.where((doc) => !doc.isPinned).toList(); +} \ No newline at end of file diff --git a/manylines_editor/lib/entities/project/project_repository.dart b/manylines_editor/lib/entities/project/project_repository.dart new file mode 100644 index 0000000..ffb5a5e --- /dev/null +++ b/manylines_editor/lib/entities/project/project_repository.dart @@ -0,0 +1,83 @@ +import 'package:flutter/foundation.dart'; +import 'project.dart'; +import '../document/document.dart'; + +class ProjectRepository extends ChangeNotifier { + final List _projects = [ + Project(id: 'p1', name: 'Project 1', documents: []), + Project(id: 'p2', name: 'Project 2', documents: []), + ]; + + Project? _selectedProject; + bool _isGraphView = false; + + List get projects => _projects; + Project? get selectedProject => _selectedProject; + bool get isGraphView => _isGraphView; + + void addProject(String name) { + _projects.add(Project( + id: 'p${_projects.length + 1}', + name: name, + documents: [], + )); + notifyListeners(); + } + + void selectProject(Project project) { + _selectedProject = project; + notifyListeners(); + } + + void clearSelectedProject() { + _selectedProject = null; + notifyListeners(); + } + + void reorderProjects(int oldIndex, int newIndex) { + if (newIndex > oldIndex) newIndex -= 1; + final item = _projects.removeAt(oldIndex); + _projects.insert(newIndex, item); + notifyListeners(); + } + + void reorderPinnedDocuments(int oldIndex, int newIndex) { + if (_selectedProject == null) return; + + final pinnedDocs = _selectedProject!.pinnedDocuments; + if (newIndex > oldIndex) newIndex -= 1; + + final doc = pinnedDocs.removeAt(oldIndex); + final targetDoc = pinnedDocs[newIndex]; + + final docMainIndex = _selectedProject!.documents.indexOf(doc); + final targetMainIndex = _selectedProject!.documents.indexOf(targetDoc); + + _selectedProject!.documents.removeAt(docMainIndex); + _selectedProject!.documents.insert(targetMainIndex, doc); + + notifyListeners(); + } + + void toggleViewMode() { + _isGraphView = !_isGraphView; + notifyListeners(); + } + + void addDocumentToProject(AppDocument document) { + if (_selectedProject == null) return; + _selectedProject!.documents.add(document); + notifyListeners(); +} + + void deleteDocument(AppDocument doc) { + if (_selectedProject == null) return; + _selectedProject!.documents.remove(doc); + notifyListeners(); + } + + void togglePin(AppDocument doc) { + doc.isPinned = !doc.isPinned; + notifyListeners(); // ✅ Уведомляем о изменении +} +} \ No newline at end of file diff --git a/manylines_editor/lib/entities/setting/setting.dart b/manylines_editor/lib/entities/setting/setting.dart new file mode 100644 index 0000000..2134b45 --- /dev/null +++ b/manylines_editor/lib/entities/setting/setting.dart @@ -0,0 +1,13 @@ +class Setting { + final String id; + final String name; + bool expanded; + bool enabled; + + Setting({ + required this.id, + required this.name, + this.expanded = false, + this.enabled = false, + }); +} \ No newline at end of file diff --git a/manylines_editor/lib/entities/setting/setting_repository.dart b/manylines_editor/lib/entities/setting/setting_repository.dart new file mode 100644 index 0000000..a1b800c --- /dev/null +++ b/manylines_editor/lib/entities/setting/setting_repository.dart @@ -0,0 +1,68 @@ +import 'package:flutter/foundation.dart'; +import 'setting.dart'; + +class SettingRepository extends ChangeNotifier { + final List _settings = [ + Setting(id: 'setting1', name: 'Setting 1', expanded: true), + Setting(id: 'setting2', name: 'Setting 2', expanded: false), + Setting(id: 'setting3', name: 'Setting 3', expanded: true), + ]; + + bool _switchableValue = true; + bool _isDarkMode = false; + bool _isGraphView = false; + bool _isSidePanelCollapsed = false; + + List get settings => _settings; + bool get switchableValue => _switchableValue; + bool get isDarkMode => _isDarkMode; + bool get isGraphView => _isGraphView; + bool get isSidePanelCollapsed => _isSidePanelCollapsed; + + void toggleDarkMode(bool value) { + _isDarkMode = value; + notifyListeners(); + } + + void toggleViewMode() { + _isGraphView = !_isGraphView; + notifyListeners(); + } + + void toggleSidePanel() { + _isSidePanelCollapsed = !_isSidePanelCollapsed; + notifyListeners(); + } + + void setSwitchableValue(bool value) { + _switchableValue = value; + notifyListeners(); + } + + void reorderSettings(int oldIndex, int newIndex) { + if (newIndex > oldIndex) newIndex -= 1; + final item = _settings.removeAt(oldIndex); + _settings.insert(newIndex, item); + notifyListeners(); + } + + void toggleSettingExpansion(String id) { + for (var setting in _settings) { + if (setting.id == id) { + setting.expanded = !setting.expanded; + notifyListeners(); + break; + } + } + } + + void toggleSettingEnabled(String id, bool value) { + for (var setting in _settings) { + if (setting.id == id) { + setting.enabled = value; + notifyListeners(); + break; + } + } + } +} \ No newline at end of file diff --git a/manylines_editor/lib/features/document/create_document.dart b/manylines_editor/lib/features/document/create_document.dart new file mode 100644 index 0000000..50a4f86 --- /dev/null +++ b/manylines_editor/lib/features/document/create_document.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:dart_quill_delta/dart_quill_delta.dart'; +import '../../entities/document/document_repository.dart'; +import '../../entities/project/project_repository.dart'; +import '../../entities/setting/setting_repository.dart'; +import '../../shared/ui/inputs/text_field.dart'; + +class CreateDocumentFeature { + static void show(BuildContext context) { + final controller = TextEditingController(); + final formKey = GlobalKey(); + + showDialog( + context: context, + builder: (context) { + final isDarkMode = context.watch().isDarkMode; + return AlertDialog( + backgroundColor: isDarkMode ? Colors.grey[850] : Colors.white, + title: Text('Новый документ', + style: TextStyle(color: isDarkMode ? Colors.white : Colors.black87)), + content: Form( + key: formKey, + child: CustomTextField( + controller: controller, + label: 'Название документа', + prefixIcon: Icons.description, + validator: (value) { + if (value == null || value.trim().isEmpty) return 'Введите название документа'; + return null; + }, + onFieldSubmitted: (_) { + if (formKey.currentState!.validate()) { + execute(context, controller.text.trim()); + Navigator.pop(context); + } + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Отмена', style: TextStyle(color: Colors.grey[600])), + ), + ElevatedButton( + onPressed: () { + if (formKey.currentState!.validate()) { + execute(context, controller.text.trim()); + Navigator.pop(context); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green[700], + foregroundColor: Colors.white, + ), + child: const Text('Создать'), + ), + ], + ); + }, + ); + } + + static void execute(BuildContext context, String name) { + final projectRepo = Provider.of(context, listen: false); + final documentRepo = Provider.of(context, listen: false); + + if (projectRepo.selectedProject == null) return; + + final newDoc = documentRepo.createDocument( + name: name, + content: Delta()..insert('New document content...\n'), + ); + + projectRepo.addDocumentToProject(newDoc); + + documentRepo.selectDocument(newDoc); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/features/document/delete_document.dart b/manylines_editor/lib/features/document/delete_document.dart new file mode 100644 index 0000000..35da7a3 --- /dev/null +++ b/manylines_editor/lib/features/document/delete_document.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../entities/document/document.dart'; +import '../../entities/document/document_repository.dart'; +import '../../entities/project/project_repository.dart'; +import '../../entities/setting/setting_repository.dart'; + +class DeleteDocumentFeature { + static void showConfirmation(BuildContext context, AppDocument doc) { + final isDarkMode = context.watch().isDarkMode; + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: isDarkMode ? Colors.grey[850] : Colors.white, + title: const Text('Удалить документ?'), + content: Text('Документ "${doc.name}" будет удалён без возможности восстановления.', + style: TextStyle(color: isDarkMode ? Colors.white70 : Colors.black87)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text('Отмена', style: TextStyle(color: isDarkMode ? Colors.grey[400] : Colors.grey[600])), + ), + ElevatedButton( + onPressed: () { + execute(context, doc); + Navigator.pop(ctx); + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white), + child: const Text('Удалить'), + ), + ], + ), + ); + } + + static void execute(BuildContext context, AppDocument doc) { + final projectRepo = Provider.of(context, listen: false); + final documentRepo = Provider.of(context, listen: false); + + documentRepo.deleteDocumentControllers(doc.id); + + projectRepo.deleteDocument(doc); + documentRepo.closeEditorIfOpen(doc.id); +} +} \ No newline at end of file diff --git a/manylines_editor/lib/features/document/indent_document.dart b/manylines_editor/lib/features/document/indent_document.dart new file mode 100644 index 0000000..d54ed84 --- /dev/null +++ b/manylines_editor/lib/features/document/indent_document.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../entities/document/document.dart'; +import '../../entities/document/document_repository.dart'; +import '../../entities/project/project_repository.dart'; + +class IndentDocumentFeature { + static void execute(BuildContext context, int index) { + final projectRepo = Provider.of(context, listen: false); + final documentRepo = Provider.of(context, listen: false); + + if (projectRepo.selectedProject == null || index <= 0) return; + + final docs = projectRepo.selectedProject!.documents; + AppDocument? parentDoc; + + for (int i = index - 1; i >= 0; i--) { + if (docs[i].parentId == null && !docs[i].isPinned) { + parentDoc = docs[i]; + break; + } + } + + if (parentDoc != null) { + documentRepo.indentDocument(docs[index].id, parentDoc.id); + } + } +} \ No newline at end of file diff --git a/manylines_editor/lib/features/document/outdent_document.dart b/manylines_editor/lib/features/document/outdent_document.dart new file mode 100644 index 0000000..ca10dff --- /dev/null +++ b/manylines_editor/lib/features/document/outdent_document.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../entities/document/document_repository.dart'; +import '../../entities/project/project_repository.dart'; + +class OutdentDocumentFeature { + static void execute(BuildContext context, int index) { + final projectRepo = Provider.of(context, listen: false); + final documentRepo = Provider.of(context, listen: false); + + if (projectRepo.selectedProject == null) return; + + final docs = projectRepo.selectedProject!.documents; + if (index >= 0 && index < docs.length) { + documentRepo.outdentDocument(docs[index].id); + } + } +} \ No newline at end of file diff --git a/manylines_editor/lib/features/document/toggle_pin.dart b/manylines_editor/lib/features/document/toggle_pin.dart new file mode 100644 index 0000000..4a8cf3f --- /dev/null +++ b/manylines_editor/lib/features/document/toggle_pin.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../entities/document/document.dart'; +import '../../entities/document/document_repository.dart'; + +class TogglePinFeature { + static void execute(BuildContext context, AppDocument doc) { + final repo = Provider.of(context, listen: false); + repo.togglePin(doc); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/features/editor/close_editor.dart b/manylines_editor/lib/features/editor/close_editor.dart new file mode 100644 index 0000000..c7b30b2 --- /dev/null +++ b/manylines_editor/lib/features/editor/close_editor.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../entities/document/document_repository.dart'; + +class CloseEditorFeature { + static void execute(BuildContext context, {int editorIndex = 1}) { + final repo = Provider.of(context, listen: false); + + if (editorIndex == 1) { + repo.closeFirstEditor(); + } else { + repo.closeSecondEditor(); + } + } +} \ No newline at end of file diff --git a/manylines_editor/lib/features/editor/handle_text_selection.dart b/manylines_editor/lib/features/editor/handle_text_selection.dart new file mode 100644 index 0000000..add81b6 --- /dev/null +++ b/manylines_editor/lib/features/editor/handle_text_selection.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart' as quill; +import 'package:provider/provider.dart'; +import '../../entities/document/document_repository.dart'; + +class HandleTextSelectionFeature { + static void execute(BuildContext context, quill.QuillController? controller) { + if (controller == null) return; + + final selection = controller.selection; + if (selection.isCollapsed) return; + + final text = controller.document.toPlainText(); + if (selection.baseOffset >= text.length || selection.extentOffset >= text.length) return; + + final start = selection.baseOffset < selection.extentOffset + ? selection.baseOffset : selection.extentOffset; + final end = selection.baseOffset < selection.extentOffset + ? selection.extentOffset : selection.baseOffset; + + final selectedText = text.substring(start, end); + + if (selectedText.trim().isNotEmpty) { + final repo = Provider.of(context, listen: false); + repo.setSelectedTextForGlossary(selectedText.trim()); + repo.openGlossaryPanel(); + } + } +} \ No newline at end of file diff --git a/manylines_editor/lib/features/editor/open_editor.dart b/manylines_editor/lib/features/editor/open_editor.dart new file mode 100644 index 0000000..5e05e16 --- /dev/null +++ b/manylines_editor/lib/features/editor/open_editor.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../entities/document/document_repository.dart'; +import '../../entities/document/document.dart'; + +class OpenEditorFeature { + static void execute(BuildContext context, AppDocument document, {int editorIndex = 1}) { + final repo = Provider.of(context, listen: false); + + if (editorIndex == 1) { + repo.selectDocument(document); + } else { + repo.selectSecondDocument(document); + } + + repo.incrementViewCount(document); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/features/glossary/add_entry.dart b/manylines_editor/lib/features/glossary/add_entry.dart new file mode 100644 index 0000000..4a050df --- /dev/null +++ b/manylines_editor/lib/features/glossary/add_entry.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../entities/document/document_repository.dart'; +import '../../entities/glossary_entry/glossary_entry.dart'; + +class AddGlossaryEntryFeature { + static void execute( + BuildContext context, + String term, + String documentId, + ) { + final repo = Provider.of(context, listen: false); + + final newEntry = GlossaryEntry( + id: 'g${DateTime.now().millisecondsSinceEpoch}', + term: term, + definition: '', + isExpanded: true, + ); + + repo.addGlossaryEntry(documentId, term); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/features/glossary/delete_entry.dart b/manylines_editor/lib/features/glossary/delete_entry.dart new file mode 100644 index 0000000..3c59a04 --- /dev/null +++ b/manylines_editor/lib/features/glossary/delete_entry.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../entities/document/document_repository.dart'; + +class DeleteGlossaryEntryFeature { + static void execute(BuildContext context, String entryId) { + final repo = Provider.of(context, listen: false); + repo.deleteGlossaryEntry(entryId); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/features/glossary/edit_entry.dart b/manylines_editor/lib/features/glossary/edit_entry.dart new file mode 100644 index 0000000..c0bceaf --- /dev/null +++ b/manylines_editor/lib/features/glossary/edit_entry.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../entities/document/document_repository.dart'; + +class EditGlossaryEntryFeature { + static void execute(BuildContext context, String entryId, String definition) { + final repo = Provider.of(context, listen: false); + repo.updateGlossaryDefinition(entryId, definition); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/features/glossary/toggle_entry.dart b/manylines_editor/lib/features/glossary/toggle_entry.dart new file mode 100644 index 0000000..a7a4948 --- /dev/null +++ b/manylines_editor/lib/features/glossary/toggle_entry.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../entities/document/document_repository.dart'; + +class ToggleGlossaryEntryFeature { + static void execute(BuildContext context, String entryId) { + final repo = Provider.of(context, listen: false); + repo.toggleGlossaryEntry(entryId); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/features/project/create_project.dart b/manylines_editor/lib/features/project/create_project.dart new file mode 100644 index 0000000..8021771 --- /dev/null +++ b/manylines_editor/lib/features/project/create_project.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../entities/project/project_repository.dart'; +import '../../shared/ui/inputs/text_field.dart'; + +class CreateProjectFeature { + static void show(BuildContext context) { + final controller = TextEditingController(); + final formKey = GlobalKey(); + + showDialog( + context: context, + builder: (context) => Consumer( + builder: (context, repo, _) { + return AlertDialog( + title: const Text('Новый проект'), + content: Form( + key: formKey, + child: CustomTextField( + controller: controller, + label: 'Название проекта', + validator: (value) => + value?.trim().isEmpty == true ? 'Введите название' : null, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + ElevatedButton( + onPressed: () { + if (formKey.currentState!.validate()) { + repo.addProject(controller.text.trim()); + Navigator.pop(context); + } + }, + child: const Text('Создать'), + ), + ], + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/features/project/delete_project.dart b/manylines_editor/lib/features/project/delete_project.dart new file mode 100644 index 0000000..4357c8c --- /dev/null +++ b/manylines_editor/lib/features/project/delete_project.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../entities/project/project_repository.dart'; +import '../../entities/project/project.dart'; + +class DeleteProjectFeature { + static void showConfirmation(BuildContext context, Project project) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Удалить проект?'), + content: Text('Проект "${project.name}" будет удалён без возможности восстановления.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Отмена'), + ), + ElevatedButton( + onPressed: () { + final repo = Provider.of(context, listen: false); + Navigator.pop(ctx); + Navigator.pop(context); + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Удалить'), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/features/project/select_project.dart b/manylines_editor/lib/features/project/select_project.dart new file mode 100644 index 0000000..2b47565 --- /dev/null +++ b/manylines_editor/lib/features/project/select_project.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../entities/project/project_repository.dart'; +import '../../entities/project/project.dart'; +import '../../entities/document/document_repository.dart'; + +class SelectProjectFeature { + static void execute(BuildContext context, Project project) { + final projectRepo = Provider.of(context, listen: false); + final documentRepo = Provider.of(context, listen: false); + + projectRepo.selectProject(project); + + if (project.documents.isNotEmpty) { + final mostUsed = project.documents.reduce((a, b) => a.viewCount > b.viewCount ? a : b); + documentRepo.selectDocument(mostUsed); + documentRepo.incrementViewCount(mostUsed); + } + } +} \ No newline at end of file diff --git a/manylines_editor/lib/features/settings/toggle_dark_mode.dart b/manylines_editor/lib/features/settings/toggle_dark_mode.dart new file mode 100644 index 0000000..bc3e8df --- /dev/null +++ b/manylines_editor/lib/features/settings/toggle_dark_mode.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../entities/setting/setting_repository.dart'; + +class ToggleDarkModeFeature { + static void execute(BuildContext context, bool value) { + final repo = Provider.of(context, listen: false); + repo.toggleDarkMode(value); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/features/settings/toggle_setting_expansion.dart b/manylines_editor/lib/features/settings/toggle_setting_expansion.dart new file mode 100644 index 0000000..63468ee --- /dev/null +++ b/manylines_editor/lib/features/settings/toggle_setting_expansion.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../entities/setting/setting_repository.dart'; + +class ToggleSettingExpansionFeature { + static void execute(BuildContext context, String id) { + final repo = Provider.of(context, listen: false); + repo.toggleSettingExpansion(id); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/features/settings/toggle_side_panel.dart b/manylines_editor/lib/features/settings/toggle_side_panel.dart new file mode 100644 index 0000000..f73e6f7 --- /dev/null +++ b/manylines_editor/lib/features/settings/toggle_side_panel.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../entities/setting/setting_repository.dart'; + +class ToggleSidePanelFeature { + static void execute(BuildContext context) { + final repo = Provider.of(context, listen: false); + repo.toggleSidePanel(); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/features/settings/toggle_view_mode.dart b/manylines_editor/lib/features/settings/toggle_view_mode.dart new file mode 100644 index 0000000..772066a --- /dev/null +++ b/manylines_editor/lib/features/settings/toggle_view_mode.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../entities/project/project_repository.dart'; + +class ToggleViewModeFeature { + static void execute(BuildContext context) { + final repo = Provider.of(context, listen: false); + repo.toggleViewMode(); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/main.dart b/manylines_editor/lib/main.dart index 6ae3dee..81d6803 100644 --- a/manylines_editor/lib/main.dart +++ b/manylines_editor/lib/main.dart @@ -1,123 +1,34 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'app/providers.dart'; +import 'app/theme.dart'; +import 'app/router.dart'; +import 'entities/setting/setting_repository.dart'; void main() { - runApp(const MyApp()); + runApp(const ManyllinesApp()); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class ManyllinesApp extends StatelessWidget { + const ManyllinesApp({super.key}); - // This widget is the root of your application. @override Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: .fromSeed(seedColor: Colors.deepPurple), + return AppProviders( + child: Consumer( + builder: (context, settingState, _) { + return MaterialApp( + title: 'Manylines', + theme: AppTheme.light, + darkTheme: AppTheme.dark, + themeMode: settingState.isDarkMode ? ThemeMode.dark : ThemeMode.light, + localizationsDelegates: AppLocalizations.delegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('ru', 'RU'), + home: const AppRouter(), + ); + }, ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), ); } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: .center, - children: [ - const Text('Hello, world!'), - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), - ); - } -} +} \ No newline at end of file diff --git a/manylines_editor/lib/pages/projects/projects_page.dart b/manylines_editor/lib/pages/projects/projects_page.dart new file mode 100644 index 0000000..9d923e8 --- /dev/null +++ b/manylines_editor/lib/pages/projects/projects_page.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../entities/project/project_repository.dart'; +import '../../entities/setting/setting_repository.dart'; +import '../../shared/ui/layouts/constrained_layout.dart'; +import '../../features/project/create_project.dart'; +import 'widgets/project_header.dart'; +import 'widgets/project_list.dart'; +import 'widgets/settings_list.dart'; + +class ProjectsPage extends StatelessWidget { + const ProjectsPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: ConstrainedLayout( + child: Column( + children: [ + const ProjectHeader(), + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + const ProjectList(), + const SettingsList(), + _buildOtherSettings(context), + ], + ), + ), + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => CreateProjectFeature.show(context), + child: const Icon(Icons.add), + ), + ); + } + + Widget _buildOtherSettings(BuildContext context) { + final state = context.watch(); + final isDarkMode = state.isDarkMode; + final textColor = isDarkMode ? Colors.white54 : Colors.black54; + final bgColor = isDarkMode + ? const Color.fromARGB(255, 6, 58, 137) + : Colors.blue[50]; + + return Container( + color: bgColor, + padding: const EdgeInsets.all(16), + child: Center( + child: Text('Other Settings ...', style: TextStyle(color: textColor)), + ), + ); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/pages/projects/widgets/project_dialog.dart b/manylines_editor/lib/pages/projects/widgets/project_dialog.dart new file mode 100644 index 0000000..5a68235 --- /dev/null +++ b/manylines_editor/lib/pages/projects/widgets/project_dialog.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../entities/project/project_repository.dart'; +import '../../../entities/setting/setting_repository.dart'; +import '../../../shared/ui/inputs/text_field.dart'; + +class ProjectDialog { + static void showCreate(BuildContext context) { + final controller = TextEditingController(); + final formKey = GlobalKey(); + + showDialog( + context: context, + builder: (context) => Consumer( + builder: (context, repo, _) { + final isDarkMode = context.watch().isDarkMode; + return AlertDialog( + backgroundColor: isDarkMode ? Colors.grey[850] : Colors.white, + title: Text('Новый проект', + style: TextStyle(color: isDarkMode ? Colors.white : Colors.black87)), + content: Form( + key: formKey, + child: CustomTextField( + controller: controller, + label: 'Название проекта', + prefixIcon: Icons.folder, + validator: (value) { + if (value == null || value.trim().isEmpty) return 'Введите название проекта'; + return null; + }, + onFieldSubmitted: (_) { + if (formKey.currentState!.validate()) { + repo.addProject(controller.text.trim()); + Navigator.pop(context); + } + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Отмена', style: TextStyle(color: Colors.grey[600])), + ), + ElevatedButton( + onPressed: () { + if (formKey.currentState!.validate()) { + repo.addProject(controller.text.trim()); + Navigator.pop(context); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green[700], + foregroundColor: Colors.white, + ), + child: const Text('Создать'), + ), + ], + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/pages/projects/widgets/project_header.dart b/manylines_editor/lib/pages/projects/widgets/project_header.dart new file mode 100644 index 0000000..9180fd2 --- /dev/null +++ b/manylines_editor/lib/pages/projects/widgets/project_header.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../entities/project/project_repository.dart'; +import '../../../entities/setting/setting_repository.dart'; + +class ProjectHeader extends StatelessWidget { + const ProjectHeader({super.key}); + + @override + Widget build(BuildContext context) { + final projectState = context.watch(); + final settingState = context.watch(); + + final isDarkMode = settingState.isDarkMode; + final headerBg = isDarkMode ? Colors.grey[850] : Colors.white; + final logoBg = isDarkMode ? Colors.grey[800] : Colors.white; + final borderColor = isDarkMode ? Colors.grey[700]! : Colors.grey[300]!; + final logoBorderColor = isDarkMode ? Colors.grey[600]! : Colors.grey[400]!; + final textColor = isDarkMode ? Colors.white : Colors.black87; + final logoTextColor = isDarkMode ? Colors.white : Colors.black; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: borderColor)), + color: headerBg, + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all(color: logoBorderColor), + color: logoBg, + borderRadius: BorderRadius.circular(4), + ), + child: Text('Logo', style: TextStyle(fontWeight: FontWeight.bold, color: logoTextColor)), + ), + const SizedBox(width: 12), + Expanded(child: Text('Manyllines', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: textColor))), + ], + ), + ); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/pages/projects/widgets/project_list.dart b/manylines_editor/lib/pages/projects/widgets/project_list.dart new file mode 100644 index 0000000..ea8a371 --- /dev/null +++ b/manylines_editor/lib/pages/projects/widgets/project_list.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../entities/project/project_repository.dart'; +import '../../../entities/setting/setting_repository.dart'; +import '../../../features/project/select_project.dart'; + +class ProjectList extends StatelessWidget { + const ProjectList({super.key}); + + @override + Widget build(BuildContext context) { + final projectState = context.watch(); + final settingState = context.watch(); + + final bgColor = settingState.isDarkMode ? Colors.green[900] : Colors.green[50]; + final borderColor = settingState.isDarkMode + ? const Color.fromARGB(255, 0, 47, 22) + : Colors.green.shade200; + final textColor = settingState.isDarkMode ? Colors.white : Colors.black87; + + if (settingState.switchableValue) { + return ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: projectState.projects.length, + onReorder: (oldIndex, newIndex) => projectState.reorderProjects(oldIndex, newIndex), + itemBuilder: (context, index) { + final project = projectState.projects[index]; + return Container( + key: ValueKey(project.id), + decoration: BoxDecoration( + color: bgColor, + border: Border(bottom: BorderSide(color: borderColor)), + ), + child: ListTile( + title: Text(project.name, style: TextStyle(color: textColor)), + subtitle: Text('${project.documents.length} документов', + style: TextStyle(fontSize: 12, color: textColor.withOpacity(0.7))), + trailing: Icon(Icons.drag_handle, + color: settingState.isDarkMode ? Colors.white54 : Colors.grey), + onTap: () => SelectProjectFeature.execute(context, project), + ), + ); + }, + ); + } else { + return Container( + decoration: BoxDecoration( + color: bgColor, + border: Border(bottom: BorderSide(color: borderColor)), + ), + child: ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: projectState.projects.length, + itemBuilder: (context, index) { + final project = projectState.projects[index]; + return Container( + key: ValueKey(project.id), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: borderColor)), + ), + child: ListTile( + title: Text(project.name, style: TextStyle(color: textColor)), + subtitle: Text('${project.documents.length} документов', + style: TextStyle(fontSize: 12, color: textColor.withOpacity(0.7))), + onTap: () => SelectProjectFeature.execute(context, project), + ), + ); + }, + ), + ); + } + } +} \ No newline at end of file diff --git a/manylines_editor/lib/pages/projects/widgets/settings_list.dart b/manylines_editor/lib/pages/projects/widgets/settings_list.dart new file mode 100644 index 0000000..81d6373 --- /dev/null +++ b/manylines_editor/lib/pages/projects/widgets/settings_list.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../entities/setting/setting_repository.dart'; +import '../../../features/settings/toggle_setting_expansion.dart'; +import '../../../features/settings/toggle_dark_mode.dart'; +import '../../../shared/ui/buttons/primary_button.dart'; + +class SettingsList extends StatelessWidget { + const SettingsList({super.key}); + + @override + Widget build(BuildContext context) { + final settingState = context.watch(); + final isDarkMode = settingState.isDarkMode; + final bgColor = isDarkMode ? Colors.blue[900]! : Colors.blue[50]!; + final borderColor = isDarkMode ? Colors.blue[700]! : Colors.blue[200]!; + final textColor = isDarkMode ? Colors.white : Colors.black87; + + if (settingState.switchableValue) { + return ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: settingState.settings.length, + onReorder: (oldIndex, newIndex) => settingState.reorderSettings(oldIndex, newIndex), + itemBuilder: (context, index) { + final setting = settingState.settings[index]; + final isExpanded = setting.expanded; + return Column( + key: ValueKey(setting.id), + mainAxisSize: MainAxisSize.min, + children: [ + _buildSettingHeader(setting, isExpanded, textColor, borderColor, context), + if (setting.id == 'setting1' && isExpanded) _buildDescriptionSection1(isDarkMode), + if (setting.id == 'setting2' && isExpanded) _buildDescriptionSection2(isDarkMode, context), + if (setting.id == 'setting3' && isExpanded) _buildDescriptionSection3(isDarkMode, context), + ], + ); + }, + ); + } else { + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: settingState.settings.length, + itemBuilder: (context, index) { + final setting = settingState.settings[index]; + final isExpanded = setting.expanded; + return Column( + key: ValueKey(setting.id), + mainAxisSize: MainAxisSize.min, + children: [ + _buildSettingHeader(setting, isExpanded, textColor, borderColor, context), + if (setting.id == 'setting1' && isExpanded) _buildDescriptionSection1(isDarkMode), + if (setting.id == 'setting2' && isExpanded) _buildDescriptionSection2(isDarkMode, context), + if (setting.id == 'setting3' && isExpanded) _buildDescriptionSection3(isDarkMode, context), + ], + ); + }, + ); + } + } + + Widget _buildSettingHeader( + dynamic setting, bool isExpanded, Color textColor, Color borderColor, BuildContext context) { + return Container( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: borderColor)), + color: context.watch().isDarkMode ? Colors.blue[800]! : Colors.white, + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(setting.name, style: TextStyle(color: textColor)), + Row( + children: [ + IconButton( + icon: Icon(isExpanded ? Icons.arrow_drop_down : Icons.arrow_drop_up, color: textColor), + onPressed: () => ToggleSettingExpansionFeature.execute(context, setting.id), + ), + if (context.watch().switchableValue) + Icon(Icons.drag_handle, + color: context.watch().isDarkMode ? Colors.white54 : Colors.grey), + ], + ), + ], + ), + ); + } + + Widget _buildDescriptionSection1(bool isDarkMode) { + return Container( + color: isDarkMode ? Colors.grey[850]! : Colors.grey[50]!, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Description', style: TextStyle(fontSize: 14, color: isDarkMode ? Colors.grey[300]! : Colors.grey[700]!)), + const SizedBox(height: 12), + Row(children: [ + _buildOutlinedButton('A', isDarkMode), + const SizedBox(width: 8), + _buildOutlinedButton('B', isDarkMode), + const SizedBox(width: 8), + _buildOutlinedButton('C', isDarkMode), + ]), + ], + ), + ); + } + + Widget _buildDescriptionSection2(bool isDarkMode, BuildContext context) { + return Container( + color: isDarkMode ? Colors.grey[850]! : Colors.grey[50]!, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Description', style: TextStyle(fontSize: 14, color: isDarkMode ? Colors.grey[300]! : Colors.grey[700]!)), + const SizedBox(height: 12), + Row(children: [ + _buildOutlinedButton('A', isDarkMode), + const SizedBox(width: 8), + _buildOutlinedButton('B', isDarkMode), + const SizedBox(width: 8), + _buildOutlinedButton('C', isDarkMode), + ]), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row(children: [ + Icon(isDarkMode ? Icons.dark_mode : Icons.light_mode, + size: 20, color: isDarkMode ? Colors.yellow[200]! : Colors.orange), + const SizedBox(width: 8), + Text(isDarkMode ? 'Тёмная тема' : 'Светлая тема', + style: TextStyle(color: isDarkMode ? Colors.white : Colors.black87)), + ]), + Switch( + value: isDarkMode, + onChanged: (value) => ToggleDarkModeFeature.execute(context, value), + ), + ], + ), + ], + ), + ); + } + + Widget _buildDescriptionSection3(bool isDarkMode, BuildContext context) { + return Container( + color: isDarkMode ? Colors.grey[850]! : Colors.grey[50]!, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Description', style: TextStyle(fontSize: 14, color: isDarkMode ? Colors.grey[300]! : Colors.grey[700]!)), // ✅ Добавьте ! + const SizedBox(height: 12), + Row(children: [ + _buildOutlinedButton('A', isDarkMode), + const SizedBox(width: 8), + _buildOutlinedButton('B', isDarkMode), + const SizedBox(width: 8), + _buildOutlinedButton('C', isDarkMode), + ]), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Switchable', style: TextStyle(color: isDarkMode ? Colors.white : Colors.black87)), + Switch( + value: context.watch().switchableValue, + onChanged: (value) => context.read().setSwitchableValue(value), + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Listable', style: TextStyle(color: isDarkMode ? Colors.white : Colors.black87)), + IconButton( + icon: Icon(Icons.arrow_drop_down, color: isDarkMode ? Colors.white : Colors.black87), + onPressed: () {}, + ), + ], + ), + ], + ), + ); + } + + Widget _buildOutlinedButton(String label, bool isDarkMode) { + return Expanded( + child: PrimaryButton.outlined( + onPressed: () {}, + label: label, + isDarkMode: isDarkMode, + ), + ); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/pages/workspace/widgets/editor_area.dart b/manylines_editor/lib/pages/workspace/widgets/editor_area.dart new file mode 100644 index 0000000..9d9d403 --- /dev/null +++ b/manylines_editor/lib/pages/workspace/widgets/editor_area.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../entities/project/project_repository.dart'; +import '../../../entities/document/document_repository.dart'; +import '../../../entities/setting/setting_repository.dart'; +import '../../../widgets/quill_editor_wrapper.dart'; +import '../../../features/editor/open_editor.dart'; +import '../../../features/editor/close_editor.dart'; + +class EditorArea extends StatelessWidget { + const EditorArea({super.key}); + + @override + Widget build(BuildContext context) { + final documentState = context.watch(); + final settingState = context.watch(); + + final showTwoEditors = documentState.secondSelectedDocument != null; + final borderColor = settingState.isDarkMode + ? Colors.grey[700]! : Colors.grey[300]!; + final textColor = settingState.isDarkMode + ? Colors.white : Colors.black87; + + if (showTwoEditors) { + return _buildTwoEditorsLayout(context, borderColor, textColor); + } else { + return _buildSingleEditorLayout(context, textColor); + } + } + + Widget _buildSingleEditorLayout(BuildContext context, Color textColor) { + final documentState = context.watch(); + final selectedDocument = documentState.selectedDocument; + + if (selectedDocument == null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.description_outlined, size: 64, color: Colors.grey[400]), + const SizedBox(height: 16), + Text('Выберите документ', style: TextStyle(color: textColor)), + ], + ), + ); + } + + return Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + color: context.watch().isDarkMode ? Colors.grey[850] : Colors.grey[100], + child: Row( + children: [ + Expanded( + child: Text( + selectedDocument.name, + style: TextStyle(fontWeight: FontWeight.w500, color: textColor), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => CloseEditorFeature.execute(context, editorIndex: 1), + tooltip: 'Закрыть', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ), + Expanded( + child: QuillEditorWrapper( + document: selectedDocument, + editorIndex: 1, + ), + ), + ], + ); + } + + Widget _buildTwoEditorsLayout(BuildContext context, Color borderColor, Color textColor) { + final documentState = context.watch(); + + return Row( + children: [ + Expanded( + child: Column( + children: [ + _buildEditorHeader(context, 1, textColor), + Expanded( + child: Container( + decoration: BoxDecoration( + border: Border(right: BorderSide(color: borderColor)), + ), + child: documentState.selectedDocument != null + ? QuillEditorWrapper( + document: documentState.selectedDocument!, + editorIndex: 1, + ) + : Center(child: Text('Выберите документ', style: TextStyle(color: textColor))), + ), + ), + ], + ), + ), + Expanded( + child: Column( + children: [ + _buildEditorHeader(context, 2, textColor), + Expanded( + child: documentState.secondSelectedDocument != null + ? QuillEditorWrapper( + document: documentState.secondSelectedDocument!, + editorIndex: 2, + ) + : Center(child: Text('Выберите документ', style: TextStyle(color: textColor))), + ), + ], + ), + ), + ], + ); + } + + Widget _buildEditorHeader(BuildContext context, int index, Color textColor) { + final documentState = context.watch(); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + color: context.watch().isDarkMode ? Colors.grey[850] : Colors.grey[100], + child: Row( + children: [ + Expanded( + child: Text( + index == 1 + ? documentState.selectedDocument?.name ?? 'Первый редактор' + : documentState.secondSelectedDocument?.name ?? 'Второй редактор', + style: TextStyle(fontWeight: FontWeight.w500, color: textColor), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => CloseEditorFeature.execute(context, editorIndex: index), + tooltip: 'Закрыть', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/pages/workspace/widgets/glossary_entry_tile.dart b/manylines_editor/lib/pages/workspace/widgets/glossary_entry_tile.dart new file mode 100644 index 0000000..6ecd1eb --- /dev/null +++ b/manylines_editor/lib/pages/workspace/widgets/glossary_entry_tile.dart @@ -0,0 +1,133 @@ +// lib/pages/workspace/widgets/glossary_entry_tile.dart + +import 'package:flutter/material.dart'; +import '../../../entities/glossary_entry/glossary_entry.dart'; + +class GlossaryEntryTile extends StatefulWidget { + final GlossaryEntry entry; + final bool isDarkMode; + final Color textColor; + final Color borderColor; + final Function(String) onUpdateDefinition; + final VoidCallback onToggleExpand; + final VoidCallback onDelete; + + const GlossaryEntryTile({ + super.key, + required this.entry, + required this.isDarkMode, + required this.textColor, + required this.borderColor, + required this.onUpdateDefinition, + required this.onToggleExpand, + required this.onDelete, + }); + + @override + State createState() => _GlossaryEntryTileState(); +} + +class _GlossaryEntryTileState extends State { + late TextEditingController _definitionController; + + @override + void initState() { + super.initState(); + // ✅ Создаём контроллер с текущим определением + _definitionController = TextEditingController(text: widget.entry.definition); + } + + @override + void didUpdateWidget(GlossaryEntryTile oldWidget) { + super.didUpdateWidget(oldWidget); + // ✅ Обновляем контроллер если entry изменился + if (oldWidget.entry.id != widget.entry.id || + oldWidget.entry.definition != widget.entry.definition) { + _definitionController.text = widget.entry.definition; + } + } + + @override + void dispose() { + _definitionController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: widget.borderColor)), + color: widget.entry.isExpanded + ? (widget.isDarkMode ? Colors.blue[900]!.withOpacity(0.2) : Colors.blue[50]) + : Colors.transparent, + ), + child: Column( + children: [ + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + title: Row( + children: [ + Icon( + widget.entry.isExpanded ? Icons.arrow_drop_down : Icons.arrow_right, + size: 20, + color: widget.textColor, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + widget.entry.term, + style: const TextStyle(fontWeight: FontWeight.w500), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 18), + onPressed: widget.onDelete, + tooltip: 'Удалить', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + onTap: widget.onToggleExpand, + ), + if (widget.entry.isExpanded) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Определение:', + style: TextStyle( + fontSize: 12, + color: widget.isDarkMode ? Colors.grey[400] : Colors.grey[600], + ), + ), + const SizedBox(height: 8), + TextField( + controller: _definitionController, + maxLines: 5, + minLines: 3, + style: TextStyle(color: widget.textColor), + decoration: InputDecoration( + hintText: 'Введите определение...', + hintStyle: TextStyle(color: Colors.grey[500]), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + filled: true, + fillColor: widget.isDarkMode ? Colors.grey[800] : Colors.white, + ), + onChanged: widget.onUpdateDefinition, + ), + ], + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/pages/workspace/widgets/glossary_panel.dart b/manylines_editor/lib/pages/workspace/widgets/glossary_panel.dart new file mode 100644 index 0000000..33ea310 --- /dev/null +++ b/manylines_editor/lib/pages/workspace/widgets/glossary_panel.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../entities/document/document_repository.dart'; +import '../../../entities/project/project_repository.dart'; +import '../../../entities/setting/setting_repository.dart'; +import '../../../features/glossary/add_entry.dart'; +import '../../../features/glossary/edit_entry.dart'; +import '../../../features/glossary/delete_entry.dart'; +import '../../../features/glossary/toggle_entry.dart'; +import 'glossary_entry_tile.dart'; + +class GlossaryPanel extends StatelessWidget { + const GlossaryPanel({super.key}); + + @override + Widget build(BuildContext context) { + final documentState = context.watch(); + final projectState = context.watch(); + final settingState = context.watch(); + + final document = documentState.selectedDocument; + final isDarkMode = settingState.isDarkMode; + final textColor = isDarkMode ? Colors.white : Colors.black87; + final borderColor = isDarkMode ? Colors.grey[700]! : Colors.grey[300]!; + + if (document == null) { + return Container( + width: 300, + color: isDarkMode ? Colors.grey[900] : Colors.grey[50], + child: const Center(child: Text('Выберите документ')), + ); + } + + return Container( + width: 300, + decoration: BoxDecoration( + border: Border(left: BorderSide(color: borderColor)), + color: isDarkMode ? Colors.grey[900] : Colors.grey[50], + ), + child: Column( + children: [ + _buildHeader(document.name, textColor, isDarkMode, context), + Expanded( + child: document.glossary.isEmpty + ? _buildEmptyState(isDarkMode) + : ListView.builder( + itemCount: document.glossary.length, + itemBuilder: (context, index) { + final entry = document.glossary[index]; + return GlossaryEntryTile( + entry: entry, + isDarkMode: isDarkMode, + textColor: textColor, + borderColor: borderColor, + onUpdateDefinition: (definition) => + EditGlossaryEntryFeature.execute(context, entry.id, definition), + onToggleExpand: () => ToggleGlossaryEntryFeature.execute(context, entry.id), + onDelete: () => DeleteGlossaryEntryFeature.execute(context, entry.id), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildHeader(String documentName, Color textColor, bool isDarkMode, BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + color: isDarkMode ? Colors.grey[800] : Colors.grey[200], + child: Row( + children: [ + const Icon(Icons.book, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + documentName, + style: const TextStyle(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + Widget _buildEmptyState(bool isDarkMode) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.book_outlined, size: 48, color: Colors.grey[400]), + const SizedBox(height: 16), + Text('Глоссарий пуст', style: TextStyle(color: Colors.grey[600])), + const SizedBox(height: 8), + Text( + 'Выделите текст и нажмите кнопку 📖', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Colors.grey[500]), + ), + ], + ), + ); + } +} + + Widget _buildAddEntrySection( + String selectedText, + Color textColor, + bool isDarkMode, + String documentId, + BuildContext context, + ) { + return Container( + padding: const EdgeInsets.all(8), + color: isDarkMode ? Colors.blue[900]!.withOpacity(0.3) : Colors.blue[50], + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Выделенный текст:', style: TextStyle(fontSize: 12, color: Colors.grey[600])), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[800] : Colors.white, + border: Border.all(color: Colors.blue[700]!), + borderRadius: BorderRadius.circular(4), + ), + child: Text(selectedText, style: TextStyle(color: textColor)), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => AddGlossaryEntryFeature.execute( + context, + selectedText, + documentId, + ), + icon: const Icon(Icons.add, size: 18), + label: const Text('Добавить в глоссарий'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue[700], + foregroundColor: Colors.white, + ), + ), + ), + ], + ), + ); + } diff --git a/manylines_editor/lib/pages/workspace/widgets/side_panel.dart b/manylines_editor/lib/pages/workspace/widgets/side_panel.dart new file mode 100644 index 0000000..deed296 --- /dev/null +++ b/manylines_editor/lib/pages/workspace/widgets/side_panel.dart @@ -0,0 +1,495 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../entities/project/project.dart'; +import '../../../entities/project/project_repository.dart'; +import '../../../entities/document/document_repository.dart'; +import '../../../entities/document/document.dart'; +import '../../../entities/setting/setting_repository.dart'; +import '../../../features/document/create_document.dart'; +import '../../../features/document/delete_document.dart'; +import '../../../features/document/toggle_pin.dart'; +import '../../../features/document/indent_document.dart'; +import '../../../features/document/outdent_document.dart'; + +class SidePanel extends StatelessWidget { + const SidePanel({super.key}); + + @override + Widget build(BuildContext context) { + final projectState = context.watch(); + final settingState = context.watch(); + final isPanelCollapsed = settingState.isSidePanelCollapsed; + + final leftPanelBg = settingState.isDarkMode ? Colors.grey[900] : Colors.white; + final headerBg = settingState.isDarkMode ? Colors.green[900] : Colors.green[50]; + final textColor = settingState.isDarkMode ? Colors.white : Colors.black87; + final borderColor = settingState.isDarkMode ? Colors.grey[700]! : Colors.grey[300]!; + + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + width: isPanelCollapsed ? 0 : 300, + decoration: BoxDecoration( + border: Border(right: BorderSide(color: borderColor)), + ), + child: isPanelCollapsed + ? const SizedBox.shrink() + : Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + color: headerBg, + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => projectState.clearSelectedProject(), + tooltip: 'Back to projects', + ), + Expanded( + child: Text( + projectState.selectedProject?.name ?? '', + style: TextStyle(color: textColor), + ), + ), + IconButton( + icon: Icon( + settingState.isGraphView ? Icons.list : Icons.account_tree, + color: textColor, + ), + onPressed: () => settingState.toggleViewMode(), + tooltip: settingState.isGraphView ? 'Список' : 'Граф', + ), + ], + ), + ), + Expanded( + child: Material( + color: leftPanelBg, + child: Column( + children: [ + Expanded( + child: Selector( + selector: (_, state) => state.isGraphView, + builder: (context, isGraphView, _) { + return isGraphView + ? const _DocumentsGraph() + : const _DocumentsList(); + }, + ), + ), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: borderColor)), + ), + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => CreateDocumentFeature.show(context), + icon: const Icon(Icons.add, size: 18), + label: const Text('Новый документ'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + foregroundColor: settingState.isDarkMode ? Colors.white : Colors.green[700], + side: BorderSide( + color: settingState.isDarkMode ? Colors.green[400]! : Colors.green[700]!, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _DocumentsList extends StatelessWidget { + const _DocumentsList(); + + @override + Widget build(BuildContext context) { + final projectState = context.watch(); + final project = projectState.selectedProject; + + context.watch(); + + if (project == null) { + return const Center(child: Text('Нет проекта')); + } + + final pinnedDocs = project.pinnedDocuments; + final unpinnedDocs = project.unpinnedDocuments; + final isDarkMode = context.watch().isDarkMode; + + return Column( + children: [ + if (pinnedDocs.isNotEmpty) + Container( + color: isDarkMode ? Colors.green[900]!.withOpacity(0.3) : Colors.green[50], + child: ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: pinnedDocs.length, + onReorder: (oldIndex, newIndex) => projectState.reorderPinnedDocuments(oldIndex, newIndex), + itemBuilder: (context, index) { + final doc = pinnedDocs[index]; + final isSelected = context.watch().selectedDocument?.id == doc.id; + + return Container( + key: ValueKey(doc.id), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isDarkMode ? Colors.green[700]! : Colors.green[200]!, + ), + ), + ), + child: ListTile( + selected: isSelected, + selectedTileColor: Theme.of(context).colorScheme.secondaryContainer, + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${index + 1}.', + style: TextStyle( + fontSize: 12, + color: Colors.green[700], + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + Icon( + Icons.push_pin, + size: 16, + color: Colors.green[700], + ), + ], + ), + title: Text(doc.name), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () {}, + child: Checkbox( + value: doc.isPinned, + activeColor: Colors.green[700], + onChanged: (value) { + TogglePinFeature.execute(context, doc); + }, + ), + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.more_vert, size: 20), + onPressed: () => _showDeleteMenu(context, doc), + tooltip: 'Меню', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + Icon( + Icons.drag_handle, + color: isDarkMode ? Colors.white54 : Colors.grey, + ), + ], + ), + onTap: () { + final repo = context.read(); + repo.selectDocument(doc); + }, + onLongPress: () => context.read().selectSecondDocument(doc), + ), + ); + }, + ), + ), + Expanded( + child: Container( + color: isDarkMode ? Colors.blue[900]!.withOpacity(0.2) : Colors.blue[50], + child: _buildDismissibleList(project, unpinnedDocs, context), + ), + ), + ], + ); + } + + Widget _buildDismissibleList(Project project, List docs, BuildContext context) { + final isDarkMode = context.watch().isDarkMode; + + return ListView.builder( + itemCount: docs.length, + itemBuilder: (context, index) { + final doc = docs[index]; + final isSelected = context.watch().selectedDocument?.id == doc.id; + final actualIndex = project.documents.indexOf(doc); + + String number; + int rootCount = 0; + int childCount = 0; + + for (int i = 0; i <= index; i++) { + if (docs[i].parentId == null) { + rootCount++; + childCount = 0; + } else { + childCount++; + } + } + + if (doc.parentId == null) { + number = '$rootCount.'; + } else { + number = '$rootCount.$childCount'; + } + + return Dismissible( + key: ValueKey(doc.id), + direction: DismissDirection.horizontal, + background: Container( + color: Colors.blue, + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(left: 16), + child: const Icon(Icons.arrow_back, color: Colors.white), + ), + secondaryBackground: Container( + color: Colors.green, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), + child: const Icon(Icons.arrow_forward, color: Colors.white), + ), + confirmDismiss: (direction) async { + if (direction == DismissDirection.startToEnd) { + IndentDocumentFeature.execute(context, actualIndex); + } else if (direction == DismissDirection.endToStart) { + OutdentDocumentFeature.execute(context, actualIndex); + } + return false; + }, + child: Container( + decoration: BoxDecoration( + color: doc.parentId == null + ? (isDarkMode ? Colors.blue[900]!.withOpacity(0.2) : Colors.blue[50]) + : (isDarkMode ? Colors.green[900]!.withOpacity(0.3) : Colors.green[50]), + border: Border( + bottom: BorderSide( + color: isDarkMode + ? (doc.parentId == null ? Colors.blue[700]! : Colors.green[700]!) + : (doc.parentId == null ? Colors.blue[200]! : Colors.green[200]!), + ), + ), + ), + child: ListTile( + selected: isSelected, + selectedTileColor: Theme.of(context).colorScheme.secondaryContainer, + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + number, + style: TextStyle( + fontSize: 12, + color: doc.parentId == null ? Colors.blue[700] : Colors.green[700], + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + Icon( + doc.parentId == null ? Icons.insert_drive_file : Icons.subdirectory_arrow_right, + size: 16, + color: doc.parentId == null ? Colors.blue[700] : Colors.green[700], + ), + ], + ), + title: Text(doc.name), + subtitle: doc.parentId != null ? Text('Поддокумент', style: TextStyle(fontSize: 10, color: Colors.grey[600])) : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () {}, + child: Checkbox( + value: doc.isPinned, + activeColor: Colors.green[700], + onChanged: (value) { + TogglePinFeature.execute(context, doc); + }, + ), + ), + const SizedBox(width: 4), + if (doc.parentId != null) + Icon(Icons.swipe, size: 16, color: Colors.grey[500]), + IconButton( + icon: const Icon(Icons.more_vert, size: 20), + onPressed: () => _showDeleteMenu(context, doc), + tooltip: 'Меню', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + onTap: () { + final repo = context.read(); + repo.selectDocument(doc); + }, + onLongPress: () => context.read().selectSecondDocument(doc), + ), + ), + ); + }, + ); + } + + void _showDeleteMenu(BuildContext context, AppDocument doc) { + DeleteDocumentFeature.showConfirmation(context, doc); + } +} + +class _DocumentsGraph extends StatelessWidget { + const _DocumentsGraph(); + + @override + Widget build(BuildContext context) { + final projectState = context.watch(); + final project = projectState.selectedProject; + final docs = project?.documents ?? []; + final isDarkMode = context.watch().isDarkMode; + + if (docs.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.account_tree_outlined, size: 64, color: Colors.grey[400]), + const SizedBox(height: 16), + Text('Нет документов', style: TextStyle(color: Colors.grey[600])), + ], + ), + ); + } + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ..._buildDocumentNodes(docs, context), + ], + ), + ), + ); + } + + List _buildDocumentNodes(List docs, BuildContext context) { + final widgets = []; + final rootDocs = docs.where((d) => d.parentId == null).toList(); + + for (var doc in rootDocs) { + widgets.add(_buildDocumentNode(doc, docs, context)); + widgets.add(const SizedBox(height: 20)); + } + + return widgets; + } + + Widget _buildDocumentNode(AppDocument doc, List allDocs, BuildContext context) { + final isSelected = context.watch().selectedDocument?.id == doc.id; + final children = allDocs.where((d) => d.parentId == doc.id).toList(); + final isDarkMode = context.watch().isDarkMode; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () { + final repo = context.read(); + repo.selectDocument(doc); + }, + onLongPress: () => context.read().selectSecondDocument(doc), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: isSelected + ? (isDarkMode ? Colors.green[800] : Colors.green[100]) + : (isDarkMode ? Colors.grey[800] : Colors.white), + border: Border.all( + color: isSelected + ? Colors.green[700]! + : (isDarkMode ? Colors.grey[600]! : Colors.grey[400]!), + width: 2, + ), + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + doc.isPinned ? Icons.push_pin : Icons.insert_drive_file, + color: isSelected ? Colors.green[700] : Colors.grey[600], + size: 20, + ), + const SizedBox(width: 8), + Text( + doc.name, + style: TextStyle( + fontSize: 14, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), + IconButton( + icon: const Icon(Icons.more_vert, size: 18), + onPressed: () => _showDeleteMenuInGraph(context, doc), + tooltip: 'Меню', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ), + ), + + if (children.isNotEmpty) ...[ + const SizedBox(height: 8), + ...children.asMap().entries.map((entry) { + final childDoc = entry.value; + return Padding( + padding: const EdgeInsets.only(left: 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container(width: 30, height: 2, color: isDarkMode ? Colors.grey[600] : Colors.grey[400]), + Icon(Icons.arrow_forward, size: 16, color: isDarkMode ? Colors.grey[600] : Colors.grey[400]), + ], + ), + const SizedBox(height: 8), + _buildDocumentNode(childDoc, allDocs, context), + ], + ), + ); + }).toList(), + ], + ], + ); + } + + void _showDeleteMenuInGraph(BuildContext context, AppDocument doc) { + DeleteDocumentFeature.showConfirmation(context, doc); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/pages/workspace/workspace_page.dart b/manylines_editor/lib/pages/workspace/workspace_page.dart new file mode 100644 index 0000000..2ef5ee4 --- /dev/null +++ b/manylines_editor/lib/pages/workspace/workspace_page.dart @@ -0,0 +1,339 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../entities/document/document_repository.dart'; +import '../../entities/document/document.dart'; +import '../../entities/setting/setting_repository.dart'; +import '../../entities/project/project_repository.dart'; +import '../../widgets/quill_editor_wrapper.dart'; +import '../../features/document/create_document.dart'; +import 'widgets/side_panel.dart'; +import 'widgets/glossary_panel.dart'; + +class WorkspacePage extends StatelessWidget { + const WorkspacePage({super.key}); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= 700; + if (!isWide) { + return const _MobileWorkspace(); + } + return Selector( + selector: (_, repo) => repo.selectedDocument, + builder: (context, selectedDocument, _) { + return _buildDesktopLayout(context, selectedDocument); + }, + ); + }, + ); + } +} + +class _MobileWorkspace extends StatelessWidget { + const _MobileWorkspace(); + + @override + Widget build(BuildContext context) { + final documentState = context.watch(); + final document = documentState.selectedDocument; + + if (document == null) { + return const _MobileEmptyState(); + } + + return QuillEditorWrapper( + document: document, + editorIndex: 1, + ); + } +} + +class _MobileEmptyState extends StatelessWidget { + const _MobileEmptyState(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Выберите документ'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.read().clearSelectedProject(), + ), + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.description_outlined, size: 64, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + 'Выберите документ из списка', + style: TextStyle(color: Colors.grey[600]), + ), + ], + ), + ), + ); + } +} + +Widget _buildDesktopLayout(BuildContext context, AppDocument? selectedDocument) { + final settingState = context.watch(); + final documentState = context.watch(); + + final leftPanelBg = settingState.isDarkMode ? Colors.grey[900] : Colors.white; + final headerBg = settingState.isDarkMode ? Colors.green[900] : Colors.green[50]; + final textColor = settingState.isDarkMode ? Colors.white : Colors.black87; + final borderColor = settingState.isDarkMode ? Colors.grey[700]! : Colors.grey[300]!; + + final showTwoEditors = documentState.secondSelectedDocument != null; + final isPanelCollapsed = settingState.isSidePanelCollapsed; + final isGlossaryOpen = documentState.isGlossaryPanelOpen; + + return Scaffold( + body: Row( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + width: isPanelCollapsed ? 0 : 300, + decoration: BoxDecoration( + border: Border(right: BorderSide(color: borderColor)), + ), + child: isPanelCollapsed + ? const SizedBox.shrink() + : const SidePanel(), + ), + + Container( + width: 24, + decoration: BoxDecoration( + color: isPanelCollapsed ? (settingState.isDarkMode ? Colors.grey[800] : Colors.grey[200]) : Colors.transparent, + border: Border(right: BorderSide(color: borderColor)), + ), + child: Column( + children: [ + const SizedBox(height: 100), + GestureDetector( + onTap: () => settingState.toggleSidePanel(), + child: Container( + width: 24, + height: 82, + decoration: BoxDecoration( + color: settingState.isDarkMode ? Colors.grey[700] : Colors.grey[300], + ), + child: Icon( + isPanelCollapsed ? Icons.chevron_right : Icons.chevron_left, + size: 20, + color: textColor, + ), + ), + ), + const Expanded(child: SizedBox()), + ], + ), + ), + + Expanded( + child: showTwoEditors + ? _buildTwoEditorsLayout(context, borderColor, textColor) + : _buildSingleEditorLayout(context, selectedDocument, textColor), + ), + + if (isGlossaryOpen) ...[ + Container( + width: 24, + decoration: BoxDecoration( + color: settingState.isDarkMode ? Colors.grey[800] : Colors.grey[200], + border: Border(right: BorderSide(color: borderColor)), + ), + child: Column( + children: [ + const SizedBox(height: 100), + GestureDetector( + onTap: () => documentState.toggleGlossaryPanel(), + child: Container( + width: 24, + height: 82, + decoration: BoxDecoration( + color: settingState.isDarkMode ? Colors.grey[700] : Colors.grey[300], + ), + child: const Icon(Icons.chevron_right, size: 20, color: Colors.white), + ), + ), + const Expanded(child: SizedBox()), + ], + ), + ), + const GlossaryPanel(), + ] else + Container( + width: 24, + decoration: BoxDecoration( + color: settingState.isDarkMode ? Colors.grey[800] : Colors.grey[200], + border: Border(right: BorderSide(color: borderColor)), + ), + child: Column( + children: [ + const SizedBox(height: 100), + GestureDetector( + onTap: () => documentState.openGlossaryPanel(), + child: Container( + width: 24, + height: 82, + decoration: BoxDecoration( + color: settingState.isDarkMode ? Colors.blue[700] : Colors.blue[300], + ), + child: const Icon(Icons.chevron_left, size: 20, color: Colors.white), + ), + ), + const Expanded(child: SizedBox()), + ], + ), + ), + ], + ), + + persistentFooterButtons: [ + FloatingActionButton( + heroTag: 'createDoc', + onPressed: () => CreateDocumentFeature.show(context), + tooltip: 'Новый документ', + child: const Icon(Icons.add), + ), + ], + + floatingActionButton: Selector( + selector: (_, state) => state.isGraphView, + builder: (context, isGraphView, _) { + return FloatingActionButton( + onPressed: () => settingState.toggleViewMode(), + tooltip: isGraphView ? 'Список' : 'Граф', + child: Icon(isGraphView ? Icons.list : Icons.account_tree), + ); + }, + ), + ); +} + +Widget _buildSingleEditorLayout(BuildContext context, AppDocument? selectedDocument, Color textColor) { + if (selectedDocument == null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.description_outlined, size: 64, color: Colors.grey[400]), + const SizedBox(height: 16), + Text('Выберите документ', style: TextStyle(color: textColor)), + ], + ), + ); + } + + return Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + color: context.watch().isDarkMode ? Colors.grey[850] : Colors.grey[100], + child: Row( + children: [ + Expanded( + child: Text( + selectedDocument.name, + style: TextStyle(fontWeight: FontWeight.w500, color: textColor), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => context.read().closeFirstEditor(), + tooltip: 'Закрыть', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ), + Expanded( + child: QuillEditorWrapper( + document: selectedDocument, + editorIndex: 1, + ), + ), + ], + ); +} + +Widget _buildTwoEditorsLayout(BuildContext context, Color borderColor, Color textColor) { + final documentState = context.watch(); + + return Row( + children: [ + Expanded( + child: Column( + children: [ + _buildEditorHeader(context, 1, textColor, documentState.selectedDocument), + Expanded( + child: Container( + decoration: BoxDecoration(border: Border(right: BorderSide(color: borderColor))), + child: documentState.selectedDocument != null + ? QuillEditorWrapper( + document: documentState.selectedDocument!, + editorIndex: 1, + ) + : Center(child: Text('Выберите документ', style: TextStyle(color: textColor))), + ), + ), + ], + ), + ), + Expanded( + child: Column( + children: [ + _buildEditorHeader(context, 2, textColor, documentState.secondSelectedDocument), + Expanded( + child: documentState.secondSelectedDocument != null + ? QuillEditorWrapper( + document: documentState.secondSelectedDocument!, + editorIndex: 2, + ) + : Center(child: Text('Выберите документ', style: TextStyle(color: textColor))), + ), + ], + ), + ), + ], + ); +} + +Widget _buildEditorHeader(BuildContext context, int index, Color textColor, AppDocument? doc) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + color: context.watch().isDarkMode ? Colors.grey[850] : Colors.grey[100], + child: Row( + children: [ + Expanded( + child: Text( + doc?.name ?? (index == 1 ? 'Первый редактор' : 'Второй редактор'), + style: TextStyle(fontWeight: FontWeight.w500, color: textColor), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () { + if (doc != null) { + context.read().closeEditorIfOpen(doc.id); + } + }, + tooltip: 'Закрыть', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ); +} \ No newline at end of file diff --git a/manylines_editor/lib/shared/api/quill_config.dart b/manylines_editor/lib/shared/api/quill_config.dart new file mode 100644 index 0000000..b045e2b --- /dev/null +++ b/manylines_editor/lib/shared/api/quill_config.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart' as quill; + +class QuillConfig { + static const defaultToolbarConfig = quill.QuillSimpleToolbarConfig( + showBoldButton: true, + showItalicButton: true, + showUnderLineButton: true, + showStrikeThrough: true, + showFontSize: true, + showFontFamily: true, + showColorButton: true, + showBackgroundColorButton: true, + showAlignmentButtons: true, + showListNumbers: true, + showListBullets: true, + showIndent: true, + ); + + static const defaultEditorConfig = quill.QuillEditorConfig( + placeholder: 'Начните печатать...', + padding: EdgeInsets.all(16), + ); + + static quill.QuillController createController({ + required dynamic content, + TextSelection? selection, + }) { + return quill.QuillController( + document: content is quill.Document + ? content + : quill.Document.fromJson(content?.toJson() ?? {}), + selection: selection ?? const TextSelection.collapsed(offset: 0), + ); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/shared/ui/buttons/icon_button.dart b/manylines_editor/lib/shared/ui/buttons/icon_button.dart new file mode 100644 index 0000000..7179573 --- /dev/null +++ b/manylines_editor/lib/shared/ui/buttons/icon_button.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +class AppIconButton extends StatelessWidget { + final IconData icon; + final VoidCallback onPressed; + final String? tooltip; + final Color? color; + final double size; + final EdgeInsets padding; + + const AppIconButton({ + super.key, + required this.icon, + required this.onPressed, + this.tooltip, + this.color, + this.size = 20, + this.padding = EdgeInsets.zero, + }); + + @override + Widget build(BuildContext context) { + return IconButton( + icon: Icon(icon, size: size), + onPressed: onPressed, + tooltip: tooltip, + color: color, + padding: padding, + constraints: padding == EdgeInsets.zero ? const BoxConstraints() : null, + ); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/shared/ui/buttons/primary_button.dart b/manylines_editor/lib/shared/ui/buttons/primary_button.dart new file mode 100644 index 0000000..30c544b --- /dev/null +++ b/manylines_editor/lib/shared/ui/buttons/primary_button.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +class PrimaryButton extends StatelessWidget { + final VoidCallback onPressed; + final String label; + final IconData? icon; + final bool isDarkMode; + final ButtonStyle? style; + + const PrimaryButton({ + super.key, + required this.onPressed, + required this.label, + this.icon, + this.isDarkMode = false, + this.style, + }); + + factory PrimaryButton.outlined({ + required VoidCallback onPressed, + required String label, + bool isDarkMode = false, + }) { + return PrimaryButton( + onPressed: onPressed, + label: label, + isDarkMode: isDarkMode, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + side: BorderSide( + color: isDarkMode + ? const Color.fromARGB(255, 54, 107, 232) + : Colors.grey[400]!, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + foregroundColor: isDarkMode ? Colors.white : Colors.black87, + ), + ); + } + + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: onPressed, + style: style ?? ElevatedButton.styleFrom( + backgroundColor: Colors.green[700], + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + child: icon != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 18), + const SizedBox(width: 8), + Text(label), + ], + ) + : Text(label), + ); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/shared/ui/inputs/text_field.dart b/manylines_editor/lib/shared/ui/inputs/text_field.dart new file mode 100644 index 0000000..3706689 --- /dev/null +++ b/manylines_editor/lib/shared/ui/inputs/text_field.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +class CustomTextField extends StatelessWidget { + final TextEditingController controller; + final String label; + final String? Function(String?)? validator; + final IconData? prefixIcon; + final VoidCallback? onEditingComplete; + final ValueChanged? onFieldSubmitted; + final bool autofocus; + + const CustomTextField({ + super.key, + required this.controller, + required this.label, + this.validator, + this.prefixIcon, + this.onEditingComplete, + this.onFieldSubmitted, + this.autofocus = true, + }); + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + autofocus: autofocus, + decoration: InputDecoration( + labelText: label, + prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + validator: validator, + onEditingComplete: onEditingComplete, + onFieldSubmitted: onFieldSubmitted, + ); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/shared/ui/layouts/constrained_layout.dart b/manylines_editor/lib/shared/ui/layouts/constrained_layout.dart new file mode 100644 index 0000000..63c5857 --- /dev/null +++ b/manylines_editor/lib/shared/ui/layouts/constrained_layout.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +class ConstrainedLayout extends StatelessWidget { + final Widget child; + final double maxWidthRatio; + final double maxHeightRatio; + + const ConstrainedLayout({ + super.key, + required this.child, + this.maxWidthRatio = 0.7, + this.maxHeightRatio = 0.9, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * maxWidthRatio, + maxHeight: MediaQuery.of(context).size.height * maxHeightRatio, + ), + child: child, + ), + ); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/shared/ui/layouts/split_view.dart b/manylines_editor/lib/shared/ui/layouts/split_view.dart new file mode 100644 index 0000000..8b6e38c --- /dev/null +++ b/manylines_editor/lib/shared/ui/layouts/split_view.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class SplitView extends StatelessWidget { + final List children; + final List? weights; + final Color? dividerColor; + final double dividerThickness; + + const SplitView({ + super.key, + required this.children, + this.weights, + this.dividerColor, + this.dividerThickness = 1, + }); + + @override + Widget build(BuildContext context) { + if (children.length <= 1) { + return children.isNotEmpty ? children.first : const SizedBox(); + } + + final effectiveWeights = weights ?? List.filled(children.length, 1.0); + final totalWeight = effectiveWeights.reduce((a, b) => a + b); + + return Row( + children: [ + for (int i = 0; i < children.length; i++) ...[ + Expanded( + flex: effectiveWeights[i].round(), + child: children[i], + ), + if (i < children.length - 1) + Container( + width: dividerThickness, + color: dividerColor ?? Colors.grey[300], + ), + ], + ], + ); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/shared/utils/constants.dart b/manylines_editor/lib/shared/utils/constants.dart new file mode 100644 index 0000000..9e308b7 --- /dev/null +++ b/manylines_editor/lib/shared/utils/constants.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +class AppConstants { + + static const double sidebarWidth = 300.0; + static const double tabWidth = 24.0; + static const double tabHeight = 48.0; + static const double tabOffset = 100.0; + + static const Color primaryColor = Colors.green; + static const Color accentColor = Colors.blue; + + static const double desktopMinWidth = 700.0; + static const double constrainedMaxWidthRatio = 0.7; + static const double constrainedMaxHeightRatio = 0.9; + + static const Duration animationDuration = Duration(milliseconds: 300); + static const Curve animationCurve = Curves.easeInOut; + + static const double swipeThreshold = 300.0; + + static const double glossaryPanelWidth = 300.0; + + static const EdgeInsets editorPadding = EdgeInsets.all(16); +} \ No newline at end of file diff --git a/manylines_editor/lib/shared/utils/helpers.dart b/manylines_editor/lib/shared/utils/helpers.dart new file mode 100644 index 0000000..c621bba --- /dev/null +++ b/manylines_editor/lib/shared/utils/helpers.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +class Helpers { + static String truncateText(String text, int maxLength) { + if (text.length <= maxLength) return text; + return '${text.substring(0, maxLength)}...'; + } + + static Color getBorderColor(bool isDarkMode, {bool isPinned = false}) { + if (isDarkMode) { + return isPinned ? Colors.green[700]! : Colors.blue[700]!; + } + return isPinned ? Colors.green[200]! : Colors.blue[200]!; + } + + static Color getBackgroundColor(bool isDarkMode, {bool isPinned = false}) { + if (isDarkMode) { + return isPinned + ? Colors.green[900]! + : Colors.blue[900]!; + } + return isPinned ? Colors.green[50]! : Colors.blue[50]!; + } + + static IconData getDocumentIcon(bool isPinned) { + return isPinned ? Icons.push_pin : Icons.insert_drive_file; + } + + static String formatDocumentNumber(int mainIndex, int childIndex, bool hasChild) { + return hasChild ? '$mainIndex.$childIndex' : '$mainIndex.'; + } + + static bool isValidSelection(TextSelection selection, String text) { + if (selection.isCollapsed) return false; + if (selection.baseOffset >= text.length || selection.extentOffset >= text.length) return false; + return true; + } + + static String extractSelectedText(String text, TextSelection selection) { + final start = selection.baseOffset < selection.extentOffset + ? selection.baseOffset : selection.extentOffset; + final end = selection.baseOffset < selection.extentOffset + ? selection.extentOffset : selection.baseOffset; + return text.substring(start, end).trim(); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/widgets/collapsible_tab.dart b/manylines_editor/lib/widgets/collapsible_tab.dart new file mode 100644 index 0000000..8f10c22 --- /dev/null +++ b/manylines_editor/lib/widgets/collapsible_tab.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +class CollapsibleTab extends StatelessWidget { + final VoidCallback onTap; + final IconData icon; + final double height; + final double offset; + + const CollapsibleTab({ + super.key, + required this.onTap, + required this.icon, + this.height = 48, + this.offset = 100, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: 24, + decoration: BoxDecoration( + border: Border(right: BorderSide(color: Colors.grey[300]!)), + ), + child: Column( + children: [ + SizedBox(height: offset), + GestureDetector( + onTap: onTap, + child: Container( + width: 24, + height: height, + decoration: BoxDecoration( + color: Colors.blue[700], + ), + child: Icon(icon, size: 20, color: Colors.white), + ), + ), + const Expanded(child: SizedBox()), + ], + ), + ); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/widgets/empty_state.dart b/manylines_editor/lib/widgets/empty_state.dart new file mode 100644 index 0000000..ab12964 --- /dev/null +++ b/manylines_editor/lib/widgets/empty_state.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +class EmptyState extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + final Widget? action; + final Color? iconColor; + + const EmptyState({ + super.key, + required this.icon, + required this.title, + this.subtitle, + this.action, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 64, color: iconColor ?? Colors.grey[400]), + const SizedBox(height: 16), + Text(title, style: Theme.of(context).textTheme.titleMedium), + if (subtitle != null) ...[ + const SizedBox(height: 8), + Text(subtitle!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey[600], + ), + ), + ], + if (action != null) ...[ + const SizedBox(height: 24), + action!, + ], + ], + ), + ); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/widgets/quill_editor_wrapper.dart b/manylines_editor/lib/widgets/quill_editor_wrapper.dart new file mode 100644 index 0000000..39e711e --- /dev/null +++ b/manylines_editor/lib/widgets/quill_editor_wrapper.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart' as quill; +import 'package:provider/provider.dart'; +import '../entities/document/document.dart'; +import '../entities/document/document_repository.dart'; +import '../entities/glossary_entry/glossary_entry.dart'; +import '../features/editor/handle_text_selection.dart'; + +class QuillEditorWrapper extends StatefulWidget { + final AppDocument document; + final int editorIndex; + + const QuillEditorWrapper({ + super.key, + required this.document, + this.editorIndex = 1, + }); + + @override + State createState() => _QuillEditorWrapperState(); +} + +class _QuillEditorWrapperState extends State { + quill.QuillController? _controller; + + @override + void initState() { + super.initState(); + _initializeController(); + } + + @override + void didUpdateWidget(QuillEditorWrapper oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.document.id != widget.document.id) { + _initializeController(); + } + } + + void _initializeController() { + final repo = context.read(); + _controller = repo.getOrCreateController(widget.document); + } + + void _addSelectedToGlossary() { + print('🔍 _addSelectedToGlossary вызван'); + + final selectedText = _getSelectedText(); + print('🔍 Выделенный текст: $selectedText'); + + if (selectedText != null) { + print('🔍 Добавляем термин: $selectedText'); + print('🔍 Document ID: ${widget.document.id}'); + + final repo = context.read(); + repo.addGlossaryEntry(widget.document.id, selectedText); + + print('✅ Термин добавлен'); + print('Открываем Глоссарий'); + repo.openGlossaryPanel(); + } else { + print('❌ Текст не выделен'); + } +} + + String? _getSelectedText() { + if (_controller == null) return null; + + final selection = _controller!.selection; + if (selection.isCollapsed) return null; + + final text = _controller!.document.toPlainText(); + if (selection.baseOffset >= text.length || selection.extentOffset >= text.length) return null; + + final start = selection.baseOffset < selection.extentOffset + ? selection.baseOffset : selection.extentOffset; + final end = selection.baseOffset < selection.extentOffset + ? selection.extentOffset : selection.baseOffset; + + final selectedText = text.substring(start, end); + return selectedText.trim().isNotEmpty ? selectedText.trim() : null; + } + + @override + void dispose() { + _controller = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_controller == null) { + return const Center(child: CircularProgressIndicator()); + } + + return GestureDetector( + onPanEnd: (details) { + if (details.velocity.pixelsPerSecond.dx < -500) { + HandleTextSelectionFeature.execute(context, _controller); + } + }, + child: Column( + children: [ + Row( + children: [ + Expanded( + child: quill.QuillSimpleToolbar( + controller: _controller!, + config: const quill.QuillSimpleToolbarConfig( + showBoldButton: true, + showItalicButton: true, + showUnderLineButton: true, + showFontSize: true, + showAlignmentButtons: true, + showListNumbers: true, + showListBullets: true, + ), + ), + ), + Container( + decoration: BoxDecoration( + border: Border(left: BorderSide(color: Colors.grey[300]!)), + ), + child: IconButton( + icon: const Icon(Icons.book, size: 22), + onPressed: _addSelectedToGlossary, + tooltip: 'Добавить в глоссарий', + color: Colors.blue[700], + ), + ), + ], + ), + Expanded( + child: quill.QuillEditor( + key: ValueKey('editor_${widget.document.id}_${widget.editorIndex}'), + controller: _controller!, + config: const quill.QuillEditorConfig( + placeholder: 'Начните печатать...', + padding: EdgeInsets.all(16), + ), + focusNode: FocusNode(), + scrollController: ScrollController(), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/manylines_editor/lib/widgets/swipe_detector.dart b/manylines_editor/lib/widgets/swipe_detector.dart new file mode 100644 index 0000000..2ea4529 --- /dev/null +++ b/manylines_editor/lib/widgets/swipe_detector.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +class SwipeDetector extends StatelessWidget { + final Widget child; + final VoidCallback? onSwipeLeft; + final VoidCallback? onSwipeRight; + final double threshold; + + const SwipeDetector({ + super.key, + required this.child, + this.onSwipeLeft, + this.onSwipeRight, + this.threshold = 300, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onPanEnd: (details) { + final velocity = details.velocity.pixelsPerSecond.dx; + if (velocity < -threshold && onSwipeLeft != null) { + onSwipeLeft!(); + } else if (velocity > threshold && onSwipeRight != null) { + onSwipeRight!(); + } + }, + child: child, + ); + } +} \ No newline at end of file diff --git a/manylines_editor/linux/flutter/generated_plugin_registrant.cc b/manylines_editor/linux/flutter/generated_plugin_registrant.cc index e71a16d..f6f23bf 100644 --- a/manylines_editor/linux/flutter/generated_plugin_registrant.cc +++ b/manylines_editor/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + 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/manylines_editor/linux/flutter/generated_plugins.cmake b/manylines_editor/linux/flutter/generated_plugins.cmake index 2e1de87..f16b4c3 100644 --- a/manylines_editor/linux/flutter/generated_plugins.cmake +++ b/manylines_editor/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/manylines_editor/macos/Flutter/GeneratedPluginRegistrant.swift b/manylines_editor/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..392fb08 100644 --- a/manylines_editor/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/manylines_editor/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,10 @@ import FlutterMacOS import Foundation +import quill_native_bridge_macos +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + QuillNativeBridgePlugin.register(with: registry.registrar(forPlugin: "QuillNativeBridgePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/manylines_editor/pubspec.lock b/manylines_editor/pubspec.lock index 3cf1fef..24e0d53 100644 --- a/manylines_editor/pubspec.lock +++ b/manylines_editor/pubspec.lock @@ -1,14 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.13.1" boolean_selector: dependency: transitive description: @@ -25,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" clock: dependency: transitive description: @@ -41,14 +57,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: "direct main" description: name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dart_quill_delta: + dependency: "direct main" + description: + name: dart_quill_delta + sha256: bddb0b2948bd5b5a328f1651764486d162c59a8ccffd4c63e8b2c5e44be1dac4 + url: "https://pub.dev" + source: hosted + version: "10.8.3" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "0.4.1" fake_async: dependency: transitive description: @@ -57,11 +105,83 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_colorpicker: + dependency: transitive + description: + name: flutter_colorpicker + sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + flutter_keyboard_visibility_linux: + dependency: transitive + description: + name: flutter_keyboard_visibility_linux + sha256: "6fba7cd9bb033b6ddd8c2beb4c99ad02d728f1e6e6d9b9446667398b2ac39f08" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_macos: + dependency: transitive + description: + name: flutter_keyboard_visibility_macos + sha256: c5c49b16fff453dfdafdc16f26bdd8fb8d55812a1d50b0ce25fc8d9f2e53d086 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_platform_interface: + dependency: transitive + description: + name: flutter_keyboard_visibility_platform_interface + sha256: e43a89845873f7be10cb3884345ceb9aebf00a659f479d1c8f4293fcb37022a4 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_temp_fork: + dependency: transitive + description: + name: flutter_keyboard_visibility_temp_fork + sha256: e3d02900640fbc1129245540db16944a0898b8be81694f4bf04b6c985bed9048 + url: "https://pub.dev" + source: hosted + version: "0.1.5" + flutter_keyboard_visibility_windows: + dependency: transitive + description: + name: flutter_keyboard_visibility_windows + sha256: fc4b0f0b6be9b93ae527f3d527fb56ee2d918cd88bbca438c478af7bcfd0ef73 + url: "https://pub.dev" + source: hosted + version: "1.0.0" flutter_lints: dependency: "direct dev" description: @@ -70,11 +190,77 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_quill: + dependency: "direct main" + description: + name: flutter_quill + sha256: b96bb8525afdeaaea52f5d02f525e05cc34acd176467ab6d6f35d434cf14fde2 + url: "https://pub.dev" + source: hosted + version: "11.5.0" + flutter_quill_delta_from_html: + dependency: transitive + description: + name: flutter_quill_delta_from_html + sha256: "0eb801ea8dd498cadc057507af5da794d4c9599ce58b2569cb3d4bb53ba8bed2" + url: "https://pub.dev" + source: hosted + version: "1.5.3" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" leak_tracker: dependency: transitive description: @@ -107,14 +293,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 + url: "https://pub.dev" + source: hosted + version: "7.3.1" matcher: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -131,6 +325,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" path: dependency: transitive description: @@ -139,6 +341,94 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + quill_native_bridge: + dependency: transitive + description: + name: quill_native_bridge + sha256: "76a16512e398e84216f3f659f7cb18a89ec1e141ea908e954652b4ce6cf15b18" + url: "https://pub.dev" + source: hosted + version: "11.1.0" + quill_native_bridge_android: + dependency: transitive + description: + name: quill_native_bridge_android + sha256: b75c7e6ede362a7007f545118e756b1f19053994144ec9eda932ce5e54a57569 + url: "https://pub.dev" + source: hosted + version: "0.0.1+2" + quill_native_bridge_ios: + dependency: transitive + description: + name: quill_native_bridge_ios + sha256: d23de3cd7724d482fe2b514617f8eedc8f296e120fb297368917ac3b59d8099f + url: "https://pub.dev" + source: hosted + version: "0.0.1" + quill_native_bridge_macos: + dependency: transitive + description: + name: quill_native_bridge_macos + sha256: "1c0631bd1e2eee765a8b06017c5286a4e829778f4585736e048eb67c97af8a77" + url: "https://pub.dev" + source: hosted + version: "0.0.1" + quill_native_bridge_platform_interface: + dependency: transitive + description: + name: quill_native_bridge_platform_interface + sha256: "8264a2bdb8a294c31377a27b46c0f8717fa9f968cf113f7dc52d332ed9c84526" + url: "https://pub.dev" + source: hosted + version: "0.0.2+1" + quill_native_bridge_web: + dependency: transitive + description: + name: quill_native_bridge_web + sha256: "7c723f6824b0250d7f33e8b6c23f2f8eb0103fe48ee7ebf47ab6786b64d5c05d" + url: "https://pub.dev" + source: hosted + version: "0.0.2" + quill_native_bridge_windows: + dependency: transitive + description: + name: quill_native_bridge_windows + sha256: "3f96ced19e3206ddf4f6f7dde3eb16bdd05e10294964009ea3a806d995aa7caa" + url: "https://pub.dev" + source: hosted + version: "0.0.2" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" sky_engine: dependency: transitive description: flutter @@ -160,6 +450,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: @@ -188,10 +486,82 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + url: "https://pub.dev" + source: hosted + version: "0.7.9" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + url: "https://pub.dev" + source: hosted + version: "6.3.29" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" vector_math: dependency: transitive description: @@ -208,6 +578,22 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" sdks: dart: ">=3.11.1 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.38.0" diff --git a/manylines_editor/pubspec.yaml b/manylines_editor/pubspec.yaml index 363d8cd..5a77659 100644 --- a/manylines_editor/pubspec.yaml +++ b/manylines_editor/pubspec.yaml @@ -1,89 +1,27 @@ name: manylines_editor description: "A new Flutter project." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: 'none' -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# 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: 1.0.0+1 environment: sdk: ^3.11.1 -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. + flutter_quill: ^11.5.0 + dart_quill_delta: ^10.8.3 + provider: ^6.1.2 + flutter_riverpod: ^2.4.9 + flutter_localizations: + sdk: flutter cupertino_icons: ^1.0.8 dev_dependencies: flutter_test: sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. flutter_lints: ^6.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package diff --git a/manylines_editor/test/widget_test.dart b/manylines_editor/test/widget_test.dart index 57295e3..1d580ad 100644 --- a/manylines_editor/test/widget_test.dart +++ b/manylines_editor/test/widget_test.dart @@ -1,30 +1,71 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; - +import 'package:provider/provider.dart'; import 'package:manylines_editor/main.dart'; +import 'package:manylines_editor/app/providers.dart'; +import 'package:manylines_editor/entities/setting/setting_repository.dart'; +import 'package:manylines_editor/entities/project/project_repository.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); + testWidgets('App loads without crashing', (WidgetTester tester) async { + await tester.pumpWidget(const ManyllinesApp()); + await tester.pumpAndSettle(); + + expect(find.byType(Scaffold), findsOneWidget); + + expect(find.text('Manylines'), findsOneWidget); + }); + + testWidgets('Can create a new project', (WidgetTester tester) async { + await tester.pumpWidget(const ManyllinesApp()); + await tester.pumpAndSettle(); + + final fabFinder = find.byWidgetPredicate( + (widget) => widget is FloatingActionButton && widget.tooltip == 'Новый документ', + ); + + if (fabFinder.evaluate().isEmpty) { + await tester.tap(find.byIcon(Icons.add)); + } else { + await tester.tap(fabFinder); + } + await tester.pumpAndSettle(); - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); + expect(find.text('Новый документ'), findsOneWidget); + + await tester.enterText(find.byType(TextFormField), 'Test Project'); await tester.pump(); + + await tester.tap(find.text('Создать')); + await tester.pumpAndSettle(); + + expect(find.text('Test Project'), findsOneWidget); + }); + + testWidgets('Can toggle dark mode', (WidgetTester tester) async { + await tester.pumpWidget(const ManyllinesApp()); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(ManyllinesApp).first); + final settingRepo = Provider.of(context, listen: false); + + settingRepo.toggleDarkMode(true); + await tester.pump(); + + expect(settingRepo.isDarkMode, isTrue); + }); + + testWidgets('Can select a project', (WidgetTester tester) async { + await tester.pumpWidget(const ManyllinesApp()); + await tester.pumpAndSettle(); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + final projectTile = find.text('Project 1'); + expect(projectTile, findsOneWidget); + + await tester.tap(projectTile); + await tester.pumpAndSettle(); + + expect(find.byType(AnimatedContainer), findsOneWidget); }); -} +} \ No newline at end of file diff --git a/manylines_editor/windows/flutter/generated_plugin_registrant.cc b/manylines_editor/windows/flutter/generated_plugin_registrant.cc index 8b6d468..043a96f 100644 --- a/manylines_editor/windows/flutter/generated_plugin_registrant.cc +++ b/manylines_editor/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,12 @@ #include "generated_plugin_registrant.h" +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/manylines_editor/windows/flutter/generated_plugins.cmake b/manylines_editor/windows/flutter/generated_plugins.cmake index b93c4c3..a95e267 100644 --- a/manylines_editor/windows/flutter/generated_plugins.cmake +++ b/manylines_editor/windows/flutter/generated_plugins.cmake @@ -3,6 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST