diff --git a/lib/components/canvas/save_indicator.dart b/lib/components/canvas/save_indicator.dart index 62756d2de3..01e2fbc896 100644 --- a/lib/components/canvas/save_indicator.dart +++ b/lib/components/canvas/save_indicator.dart @@ -28,7 +28,8 @@ class SaveIndicator extends StatelessWidget { icon: switch (savingState.value) { SavingState.waitingToSave => const Icon(Icons.save), SavingState.saving => const CircularProgressIndicator.adaptive(), - SavingState.saved => const Icon(Icons.arrow_back), + SavingState.savedWithoutThumbnail => const Icon(Icons.arrow_back), + SavingState.savedWithThumbnail => const Icon(Icons.arrow_back), }, ), ); @@ -36,13 +37,17 @@ class SaveIndicator extends StatelessWidget { ); } + /// When the save/exit button is pressed void _onPressed(BuildContext context) { switch (savingState.value) { case SavingState.waitingToSave: triggerSave(); case SavingState.saving: break; - case SavingState.saved: + case SavingState.savedWithoutThumbnail: + triggerSave(); // triggering save will be created thumbnail and then is finished editing + _back(context); + case SavingState.savedWithThumbnail: _back(context); } } @@ -62,5 +67,11 @@ class SaveIndicator extends StatelessWidget { enum SavingState { waitingToSave, saving, - saved, + + /// Saved, but the thumbnail still needs updating. + /// (Thumbnails aren't created when auto-saving to avoid lag.) + savedWithoutThumbnail, + + /// Saved, and the thumbnail is up-to-date. + savedWithThumbnail, } diff --git a/lib/components/home/preview_card.dart b/lib/components/home/preview_card.dart index f773ecf5d9..09ecdcc5a2 100644 --- a/lib/components/home/preview_card.dart +++ b/lib/components/home/preview_card.dart @@ -53,8 +53,11 @@ class _PreviewCardState extends State { } StreamSubscription? fileWriteSubscription; + + /// Listen to changes of thumbnail file void fileWriteListener(FileOperation event) { - if (event.filePath != widget.filePath) return; + if (event.filePath != '${widget.filePath}${Editor.extension}.p') return; + if (event.type == FileOperationType.delete) { thumbnail.image = null; } else if (event.type == FileOperationType.write) { diff --git a/lib/components/navbar/responsive_navbar.dart b/lib/components/navbar/responsive_navbar.dart index 280074a22a..d9b108abfd 100644 --- a/lib/components/navbar/responsive_navbar.dart +++ b/lib/components/navbar/responsive_navbar.dart @@ -63,7 +63,8 @@ class _ResponsiveNavbarState extends State { final savingState = Whiteboard.savingState; switch (savingState) { case null: - case SavingState.saved: + case SavingState.savedWithoutThumbnail: + case SavingState.savedWithThumbnail: break; case SavingState.waitingToSave: Whiteboard.triggerSave(); diff --git a/lib/data/file_manager/file_manager.dart b/lib/data/file_manager/file_manager.dart index c6b7bc1933..b0022038e9 100644 --- a/lib/data/file_manager/file_manager.dart +++ b/lib/data/file_manager/file_manager.dart @@ -28,6 +28,8 @@ class FileManager { /// Realistically, this value never changes. static late String documentsDirectory; + /// A stream of [FileOperation]s. Note that file paths + /// include the file extension. static final StreamController fileWriteStream = StreamController.broadcast( onListen: () => _fileWriteStreamIsListening = true, @@ -73,14 +75,6 @@ class FileManager { @visibleForTesting static void broadcastFileWrite(FileOperationType type, String path) async { if (!_fileWriteStreamIsListening) return; - - // remove extension - if (path.endsWith(Editor.extension)) { - path = path.substring(0, path.length - Editor.extension.length); - } else if (path.endsWith(Editor.extensionOldJson)) { - path = path.substring(0, path.length - Editor.extensionOldJson.length); - } - fileWriteStream.add(FileOperation(type, path)); } diff --git a/lib/pages/editor/editor.dart b/lib/pages/editor/editor.dart index b1a65f04b5..7f82bc63e8 100644 --- a/lib/pages/editor/editor.dart +++ b/lib/pages/editor/editor.dart @@ -170,8 +170,9 @@ class EditorState extends State { Prefs.lastTool.value = tool.toolId; } - ValueNotifier savingState = ValueNotifier(SavingState.saved); - Timer? _delayedSaveTimer; + final savingState = ValueNotifier(SavingState.savedWithThumbnail); + @visibleForTesting + Timer? delayedSaveTimer; // used to prevent accidentally drawing when pinch zooming int lastSeenPointerCount = 0; @@ -258,7 +259,8 @@ class EditorState extends State { clearAllPages(); // save cleared whiteboard - await saveToFile(); + // without thumbanil as whiteboard thumbnail is never used + await saveToFile(createThumbnail: false); Whiteboard.needsToAutoClearWhiteboard = false; } else { setState(() {}); @@ -320,12 +322,12 @@ class EditorState extends State { late final topOfLastPage = -CanvasGestureDetector.getTopOfPage( pageIndex: coreInfo.pages.length - 1, pages: coreInfo.pages, - screenWidth: MediaQuery.sizeOf(context).width, + screenWidth: _mediaQuery.size.width, ); final bottomOfLastPage = -CanvasGestureDetector.getTopOfPage( pageIndex: coreInfo.pages.length, pages: coreInfo.pages, - screenWidth: MediaQuery.sizeOf(context).width, + screenWidth: _mediaQuery.size.width, ); if (scrollY < bottomOfLastPage) { @@ -802,28 +804,34 @@ class EditorState extends State { void autosaveAfterDelay() { savingState.value = SavingState.waitingToSave; - _delayedSaveTimer?.cancel(); + delayedSaveTimer?.cancel(); if (Prefs.autosaveDelay.value < 0) return; - _delayedSaveTimer = + delayedSaveTimer = Timer(Duration(milliseconds: Prefs.autosaveDelay.value), () { - saveToFile(); + saveToFile(createThumbnail: false); }); } - Future saveToFile() async { + Future saveToFile({required bool createThumbnail}) async { + // createThumbnail=false is used when called from autosave - to avoid lagging during thumbnail creation if (coreInfo.readOnly) return; switch (savingState.value) { - case SavingState.saved: + case SavingState.savedWithThumbnail: // avoid saving if nothing has changed return; + case SavingState.savedWithoutThumbnail: + // note is saved, but thumbnail need to be created + createThumbnailPreview(); + savingState.value = SavingState.savedWithThumbnail; + return; case SavingState.saving: // avoid saving if already saving log.warning('saveToFile() called while already saving'); return; case SavingState.waitingToSave: // continue - _delayedSaveTimer?.cancel(); + delayedSaveTimer?.cancel(); savingState.value = SavingState.saving; } @@ -852,7 +860,11 @@ class EditorState extends State { numAssets: assets.length, ), ]); - savingState.value = SavingState.saved; + if (createThumbnail) { + savingState.value = SavingState.savedWithThumbnail; + } else { + savingState.value = SavingState.savedWithoutThumbnail; + } } catch (e) { log.severe('Failed to save file: $e', e); savingState.value = SavingState.waitingToSave; @@ -860,6 +872,14 @@ class EditorState extends State { return; } + if (createThumbnail) await createThumbnailPreview(); + } + + /// create thumbnail of note + Future createThumbnailPreview() async { + if (coreInfo.readOnly) return; + final filePath = coreInfo.filePath + Editor.extension; + if (!mounted) return; final screenshotter = ScreenshotController(); final page = coreInfo.pages.first; @@ -868,31 +888,35 @@ class EditorState extends State { ); final thumbnailSize = Size(720, 720 * previewHeight / page.size.width); final thumbnail = await screenshotter.captureFromWidget( - Theme( - data: ThemeData( - brightness: Brightness.light, - colorScheme: const ColorScheme.light( - primary: EditorExporter.primaryColor, - secondary: EditorExporter.secondaryColor, - ), + MediaQuery( + data: MediaQueryData( + size: thumbnailSize, + devicePixelRatio: 1, ), - child: Localizations.override( - context: context, - child: SizedBox( - width: thumbnailSize.width, - height: thumbnailSize.height, - child: FittedBox( - child: pagePreviewBuilder( - context, - pageIndex: 0, - previewHeight: previewHeight, + child: MaterialApp( + theme: ThemeData( + brightness: Brightness.light, + colorScheme: const ColorScheme.light( + primary: EditorExporter.primaryColor, + secondary: EditorExporter.secondaryColor, + ), + ), + home: SizedBox( + width: thumbnailSize.width, + height: thumbnailSize.height, + child: FittedBox( + child: Builder( + builder: (context) => pagePreviewBuilder( + context, + pageIndex: 0, + previewHeight: previewHeight, + ), + ), ), ), ), - ), ), pixelRatio: 1, - context: context, targetSize: thumbnailSize, ); await FileManager.writeFile( @@ -1280,6 +1304,14 @@ class EditorState extends State { )); } + late MediaQueryData _mediaQuery = const MediaQueryData(); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _mediaQuery = MediaQuery.of(context); + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -1553,17 +1585,18 @@ class EditorState extends State { builder: (context, savingState, child) { // don't allow user to go back until saving is done return PopScope( - canPop: savingState == SavingState.saved, + canPop: savingState == SavingState.savedWithThumbnail, onPopInvoked: (didPop) { switch (savingState) { case SavingState.waitingToSave: assert(!didPop); - saveToFile(); // trigger save now + saveToFile(createThumbnail: true); // trigger save now snackBarNeedsToSaveBeforeExiting(); case SavingState.saving: assert(!didPop); snackBarNeedsToSaveBeforeExiting(); - case SavingState.saved: + case SavingState.savedWithoutThumbnail: + case SavingState.savedWithThumbnail: break; } }, @@ -1592,7 +1625,7 @@ class EditorState extends State { ), leading: SaveIndicator( savingState: savingState, - triggerSave: saveToFile, + triggerSave: () => saveToFile(createThumbnail: true), ), actions: [ IconButton( @@ -1607,7 +1640,7 @@ class EditorState extends State { CanvasGestureDetector.scrollToPage( pageIndex: currentPageIndex + 1, pages: coreInfo.pages, - screenWidth: MediaQuery.sizeOf(context).width, + screenWidth: _mediaQuery.size.width, transformationController: _transformationController, ); }), @@ -1920,11 +1953,9 @@ class EditorState extends State { int get currentPageIndex { if (!mounted) return _lastCurrentPageIndex; - final screenWidth = MediaQuery.sizeOf(context).width; - return _lastCurrentPageIndex = getPageIndexFromScrollPosition( scrollY: -scrollY, - screenWidth: screenWidth, + screenWidth: _mediaQuery.size.width, pages: coreInfo.pages, ); } @@ -1951,21 +1982,21 @@ class EditorState extends State { } @override - void dispose() { + void dispose() async { + delayedSaveTimer?.cancel(); + _lastSeenPointerCountTimer?.cancel(); + (() async { if (_renameTimer?.isActive ?? false) { _renameTimer!.cancel(); await _renameFileNow(); filenameTextEditingController.dispose(); } - await saveToFile(); + await saveToFile(createThumbnail: true); })(); DynamicMaterialApp.removeFullscreenListener(_setState); - _delayedSaveTimer?.cancel(); - _lastSeenPointerCountTimer?.cancel(); - _removeKeybindings(); coreInfo.dispose(); diff --git a/lib/pages/home/whiteboard.dart b/lib/pages/home/whiteboard.dart index a2d04a7303..7bf7137f79 100644 --- a/lib/pages/home/whiteboard.dart +++ b/lib/pages/home/whiteboard.dart @@ -21,7 +21,7 @@ class Whiteboard extends StatelessWidget { final editorState = _whiteboardKey.currentState; if (editorState == null) return; assert(editorState.savingState.value == SavingState.waitingToSave); - editorState.saveToFile(); + editorState.saveToFile(createThumbnail: false); editorState.snackBarNeedsToSaveBeforeExiting(); } diff --git a/test/editor_undo_redo_test.dart b/test/editor_undo_redo_test.dart index df19380217..494c40ac7b 100644 --- a/test/editor_undo_redo_test.dart +++ b/test/editor_undo_redo_test.dart @@ -61,6 +61,11 @@ void main() { reason: 'Editor is still read-only'); printOnFailure('Editor core info is loaded'); + addTearDown(() async { + editorState.delayedSaveTimer?.cancel(); + await FileManager.deleteFile(filePath + Editor.extension); + }); + IconButton getUndoBtn() => tester.widget(find.ancestor( of: find.byIcon(Icons.undo), matching: find.byType(IconButton), @@ -108,14 +113,6 @@ void main() { reason: 'Undo button should be enabled after undo and draw'); expect(getRedoBtn().onPressed, isNull, reason: 'Redo button should be disabled after undo and draw'); - - // save file now to supersede the save timer (which would run after the test is finished) - printOnFailure('Saving file: $filePath${Editor.extension}'); - await tester.runAsync(() async { - await editorState.saveToFile(); - await Future.delayed(const Duration(milliseconds: 100)); - await FileManager.deleteFile(filePath + Editor.extension); - }); }); } diff --git a/test/fm_write_stream_test.dart b/test/fm_write_stream_test.dart index b173806386..7dc76a9665 100644 --- a/test/fm_write_stream_test.dart +++ b/test/fm_write_stream_test.dart @@ -32,7 +32,7 @@ void main() { // check that the event was received expect(events.length, 1); - expect(events.last.filePath, '/test'); // without the extension + expect(events.last.filePath, '/test.sbn2'); expect(events.last.type, FileOperationType.write); }); @@ -50,7 +50,7 @@ void main() { await file.writeAsString('test_content'); await Future.delayed(const Duration(milliseconds: 100)); expect(events.length, greaterThanOrEqualTo(2)); - expect(events.last.filePath, '/test'); // without the extension + expect(events.last.filePath, '/test.sbn2'); expect(events.last.type, FileOperationType.write); events.clear(); @@ -58,7 +58,7 @@ void main() { await file.delete(); await Future.delayed(const Duration(milliseconds: 100)); expect(events.length, greaterThanOrEqualTo(1)); - expect(events.last.filePath, '/test'); // without the extension + expect(events.last.filePath, '/test.sbn2'); expect(events.last.type, FileOperationType.delete); }); });