diff --git a/lib/model/entry.dart b/lib/model/entry.dart index ad8b5438d..7861d2b5f 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -454,7 +454,7 @@ class AvesEntry { _catalogMetadata = newMetadata; _bestTitle = null; _tags = null; - metadataChangeNotifier.notifyListeners(); + metadataChangeNotifier.notify(); _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); } @@ -496,7 +496,7 @@ class AvesEntry { set addressDetails(AddressDetails? newAddress) { _addressDetails = newAddress; - addressChangeNotifier.notifyListeners(); + addressChangeNotifier.notify(); } Future locate({required bool background, required bool force, required Locale geocoderLocale}) async { @@ -620,7 +620,7 @@ class AvesEntry { } await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); - metadataChangeNotifier.notifyListeners(); + metadataChangeNotifier.notify(); } Future refresh({ @@ -717,7 +717,7 @@ class AvesEntry { Future _onVisualFieldChanged(int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async { if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) { await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); - imageChangeNotifier.notifyListeners(); + imageChangeNotifier.notify(); } } diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 6f05c03b3..60e16c912 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -10,6 +10,7 @@ import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/query.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/source/events.dart'; import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/section_keys.dart'; import 'package:aves/model/source/tag.dart'; diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 86f574b1f..25a8c9a6c 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -11,6 +11,7 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/enums.dart'; +import 'package:aves/model/source/events.dart'; import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/tag.dart'; import 'package:aves/services/analysis_service.dart'; @@ -183,7 +184,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM return; } await _moveEntry(entry, newFields, persist: persist); - entry.metadataChangeNotifier.notifyListeners(); + entry.metadataChangeNotifier.notify(); eventBus.fire(EntryMovedEvent({entry})); completer.complete(true); }, @@ -381,46 +382,3 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM } } } - -@immutable -class EntryAddedEvent { - final Set? entries; - - const EntryAddedEvent([this.entries]); -} - -@immutable -class EntryRemovedEvent { - final Set entries; - - const EntryRemovedEvent(this.entries); -} - -@immutable -class EntryMovedEvent { - final Set entries; - - const EntryMovedEvent(this.entries); -} - -@immutable -class EntryRefreshedEvent { - final Set entries; - - const EntryRefreshedEvent(this.entries); -} - -@immutable -class FilterVisibilityChangedEvent { - final Set filters; - final bool visible; - - const FilterVisibilityChangedEvent(this.filters, this.visible); -} - -@immutable -class ProgressEvent { - final int done, total; - - const ProgressEvent({required this.done, required this.total}); -} diff --git a/lib/model/source/events.dart b/lib/model/source/events.dart new file mode 100644 index 000000000..f4ce3f5d5 --- /dev/null +++ b/lib/model/source/events.dart @@ -0,0 +1,46 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:flutter/foundation.dart'; + +@immutable +class EntryAddedEvent { + final Set? entries; + + const EntryAddedEvent([this.entries]); +} + +@immutable +class EntryRemovedEvent { + final Set entries; + + const EntryRemovedEvent(this.entries); +} + +@immutable +class EntryMovedEvent { + final Set entries; + + const EntryMovedEvent(this.entries); +} + +@immutable +class EntryRefreshedEvent { + final Set entries; + + const EntryRefreshedEvent(this.entries); +} + +@immutable +class FilterVisibilityChangedEvent { + final Set filters; + final bool visible; + + const FilterVisibilityChangedEvent(this.filters, this.visible); +} + +@immutable +class ProgressEvent { + final int done, total; + + const ProgressEvent({required this.done, required this.total}); +} diff --git a/lib/services/common/output_buffer.dart b/lib/services/common/output_buffer.dart index 7d7088d3a..e64f04606 100644 --- a/lib/services/common/output_buffer.dart +++ b/lib/services/common/output_buffer.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:typed_data'; -// cf flutter/foundation `consolidateHttpClientResponseBytes` +// adapted from Flutter `_OutputBuffer` in `/foundation/consolidate_response.dart` class OutputBuffer extends ByteConversionSinkBase { List>? _chunks = >[]; int _contentLength = 0; @@ -21,8 +21,8 @@ class OutputBuffer extends ByteConversionSinkBase { return; } _bytes = Uint8List(_contentLength); - var offset = 0; - for (final chunk in _chunks!) { + int offset = 0; + for (final List chunk in _chunks!) { _bytes!.setRange(offset, offset + chunk.length, chunk); offset += chunk.length; } diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index a81e03bb8..c0de12a18 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -32,10 +32,10 @@ class AndroidFileUtils { downloadPath = pContext.join(primaryStorage, 'Download'); moviesPath = pContext.join(primaryStorage, 'Movies'); picturesPath = pContext.join(primaryStorage, 'Pictures'); - avesVideoCapturesPath = pContext.join(dcimPath, 'Videocaptures'); + avesVideoCapturesPath = pContext.join(dcimPath, 'Video Captures'); videoCapturesPaths = { // from Samsung - pContext.join(dcimPath, 'Video Captures'), + pContext.join(dcimPath, 'Videocaptures'), // from Aves avesVideoCapturesPath, }; diff --git a/lib/utils/change_notifier.dart b/lib/utils/change_notifier.dart index 2a1eddca8..60ad5d939 100644 --- a/lib/utils/change_notifier.dart +++ b/lib/utils/change_notifier.dart @@ -1,26 +1,9 @@ import 'package:flutter/foundation.dart'; -// reimplemented ChangeNotifier so that it can be used anywhere, not just as a mixin -class AChangeNotifier implements Listenable { - ObserverList? _listeners = ObserverList(); - - @override - void addListener(VoidCallback listener) => _listeners!.add(listener); - - @override - void removeListener(VoidCallback listener) => _listeners!.remove(listener); - - void dispose() => _listeners = null; - - void notifyListeners() { - if (_listeners == null) return; - final localListeners = List.from(_listeners!); - for (final listener in localListeners) { - try { - if (_listeners!.contains(listener)) listener(); - } catch (error, stack) { - debugPrint('$runtimeType failed to notify listeners with error=$error\n$stack'); - } - } +// `ChangeNotifier` wrapper so that it can be used anywhere, not just as a mixin +class AChangeNotifier extends ChangeNotifier { + void notify() { + // why is this protected? + super.notifyListeners(); } } diff --git a/lib/widgets/collection/query_bar.dart b/lib/widgets/collection/query_bar.dart index f915b9a49..f12c18b16 100644 --- a/lib/widgets/collection/query_bar.dart +++ b/lib/widgets/collection/query_bar.dart @@ -42,8 +42,6 @@ class _EntryQueryBarState extends State { super.dispose(); } - // TODO TLAD focus on text field when enabled (`autofocus` is unusable) - // TODO TLAD lose focus on navigation to viewer? void _registerWidget(EntryQueryBar widget) { widget.queryNotifier.addListener(_onQueryChanged); } diff --git a/lib/widgets/common/app_bar_subtitle.dart b/lib/widgets/common/app_bar_subtitle.dart index 4362ee50d..7975f088e 100644 --- a/lib/widgets/common/app_bar_subtitle.dart +++ b/lib/widgets/common/app_bar_subtitle.dart @@ -1,5 +1,6 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; +import 'package:aves/model/source/events.dart'; import 'package:aves/model/source/source_state.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; diff --git a/lib/widgets/common/aves_highlight.dart b/lib/widgets/common/aves_highlight.dart index 1ae123586..3e29db11e 100644 --- a/lib/widgets/common/aves_highlight.dart +++ b/lib/widgets/common/aves_highlight.dart @@ -2,7 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:highlight/highlight.dart' show highlight, Node; -// TODO TLAD use the TextSpan getter instead of this modified `HighlightView` when this is fixed: https://github.com/git-touch/highlight/issues/6 +// adapted from package `flutter_highlight` v0.7.0 `HighlightView` +// TODO TLAD use the TextSpan getter when this is fixed: https://github.com/git-touch/highlight/issues/6 /// Highlight Flutter Widget class AvesHighlightView extends StatelessWidget { diff --git a/lib/widgets/common/basic/draggable_scrollbar.dart b/lib/widgets/common/basic/draggable_scrollbar.dart index e3892c578..81a651ee5 100644 --- a/lib/widgets/common/basic/draggable_scrollbar.dart +++ b/lib/widgets/common/basic/draggable_scrollbar.dart @@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; /* - This is derived from `draggable_scrollbar` package v0.0.4: + adapted from package `draggable_scrollbar` v0.0.4: - removed default thumb builders - allow any `ScrollView` as child - allow any `Widget` as label content diff --git a/lib/widgets/common/behaviour/eager_scale_gesture_recognizer.dart b/lib/widgets/common/behaviour/eager_scale_gesture_recognizer.dart new file mode 100644 index 000000000..38fd4c2ec --- /dev/null +++ b/lib/widgets/common/behaviour/eager_scale_gesture_recognizer.dart @@ -0,0 +1,395 @@ +import 'dart:math' as math; + +import 'package:flutter/gestures.dart'; +import 'package:vector_math/vector_math_64.dart'; + +// adapted from Flutter `ScaleGestureRecognizer` in `/gestures/scale.dart` +// ignore_for_file: curly_braces_in_flow_control_structures, deprecated_member_use, unnecessary_null_comparison + +/// The possible states of a [ScaleGestureRecognizer]. +enum _ScaleState { + /// The recognizer is ready to start recognizing a gesture. + ready, + + /// The sequence of pointer events seen thus far is consistent with a scale + /// gesture but the gesture has not been accepted definitively. + possible, + + /// The sequence of pointer events seen thus far has been accepted + /// definitively as a scale gesture. + accepted, + + /// The sequence of pointer events seen thus far has been accepted + /// definitively as a scale gesture and the pointers established a focal point + /// and initial scale. + started, +} + +//////////////////////////////////////////////////////////////////////////////// + +bool _isFlingGesture(Velocity velocity) { + assert(velocity != null); + final double speedSquared = velocity.pixelsPerSecond.distanceSquared; + return speedSquared > kMinFlingVelocity * kMinFlingVelocity; +} + +/// Defines a line between two pointers on screen. +/// +/// [_LineBetweenPointers] is an abstraction of a line between two pointers in +/// contact with the screen. Used to track the rotation of a scale gesture. +class _LineBetweenPointers { + /// Creates a [_LineBetweenPointers]. None of the [pointerStartLocation], [pointerStartId] + /// [pointerEndLocation] and [pointerEndId] must be null. [pointerStartId] and [pointerEndId] + /// should be different. + _LineBetweenPointers({ + this.pointerStartLocation = Offset.zero, + this.pointerStartId = 0, + this.pointerEndLocation = Offset.zero, + this.pointerEndId = 1, + }) : assert(pointerStartLocation != null && pointerEndLocation != null), + assert(pointerStartId != null && pointerEndId != null), + assert(pointerStartId != pointerEndId); + + // The location and the id of the pointer that marks the start of the line. + final Offset pointerStartLocation; + final int pointerStartId; + + // The location and the id of the pointer that marks the end of the line. + final Offset pointerEndLocation; + final int pointerEndId; +} + +/// Recognizes a scale gesture. +/// +/// [ScaleGestureRecognizer] tracks the pointers in contact with the screen and +/// calculates their focal point, indicated scale, and rotation. When a focal +/// pointer is established, the recognizer calls [onStart]. As the focal point, +/// scale, rotation change, the recognizer calls [onUpdate]. When the pointers +/// are no longer in contact with the screen, the recognizer calls [onEnd]. +class EagerScaleGestureRecognizer extends OneSequenceGestureRecognizer { + /// Create a gesture recognizer for interactions intended for scaling content. + /// + /// {@macro flutter.gestures.GestureRecognizer.supportedDevices} + EagerScaleGestureRecognizer({ + Object? debugOwner, + @Deprecated( + 'Migrate to supportedDevices. ' + 'This feature was deprecated after v2.3.0-1.0.pre.', + ) + PointerDeviceKind? kind, + Set? supportedDevices, + this.dragStartBehavior = DragStartBehavior.down, + }) : assert(dragStartBehavior != null), + super( + debugOwner: debugOwner, + kind: kind, + supportedDevices: supportedDevices, + ); + + /// Determines what point is used as the starting point in all calculations + /// involving this gesture. + /// + /// When set to [DragStartBehavior.down], the scale is calculated starting + /// from the position where the pointer first contacted the screen. + /// + /// When set to [DragStartBehavior.start], the scale is calculated starting + /// from the position where the scale gesture began. The scale gesture may + /// begin after the time that the pointer first contacted the screen if there + /// are multiple listeners competing for the gesture. In that case, the + /// gesture arena waits to determine whether or not the gesture is a scale + /// gesture before giving the gesture to this GestureRecognizer. This happens + /// in the case of nested GestureDetectors, for example. + /// + /// Defaults to [DragStartBehavior.down]. + /// + /// See also: + /// + /// * https://flutter.dev/docs/development/ui/advanced/gestures#gesture-disambiguation, + /// which provides more information about the gesture arena. + DragStartBehavior dragStartBehavior; + + /// The pointers in contact with the screen have established a focal point and + /// initial scale of 1.0. + /// + /// This won't be called until the gesture arena has determined that this + /// GestureRecognizer has won the gesture. + /// + /// See also: + /// + /// * https://flutter.dev/docs/development/ui/advanced/gestures#gesture-disambiguation, + /// which provides more information about the gesture arena. + GestureScaleStartCallback? onStart; + + /// The pointers in contact with the screen have indicated a new focal point + /// and/or scale. + GestureScaleUpdateCallback? onUpdate; + + /// The pointers are no longer in contact with the screen. + GestureScaleEndCallback? onEnd; + + _ScaleState _state = _ScaleState.ready; + + Matrix4? _lastTransform; + + late Offset _initialFocalPoint; + late Offset _currentFocalPoint; + late double _initialSpan; + late double _currentSpan; + late double _initialHorizontalSpan; + late double _currentHorizontalSpan; + late double _initialVerticalSpan; + late double _currentVerticalSpan; + _LineBetweenPointers? _initialLine; + _LineBetweenPointers? _currentLine; + late Map _pointerLocations; + late List _pointerQueue; // A queue to sort pointers in order of entrance + final Map _velocityTrackers = {}; + + double get _scaleFactor => _initialSpan > 0.0 ? _currentSpan / _initialSpan : 1.0; + + double get _horizontalScaleFactor => _initialHorizontalSpan > 0.0 ? _currentHorizontalSpan / _initialHorizontalSpan : 1.0; + + double get _verticalScaleFactor => _initialVerticalSpan > 0.0 ? _currentVerticalSpan / _initialVerticalSpan : 1.0; + + double _computeRotationFactor() { + if (_initialLine == null || _currentLine == null) { + return 0.0; + } + final double fx = _initialLine!.pointerStartLocation.dx; + final double fy = _initialLine!.pointerStartLocation.dy; + final double sx = _initialLine!.pointerEndLocation.dx; + final double sy = _initialLine!.pointerEndLocation.dy; + + final double nfx = _currentLine!.pointerStartLocation.dx; + final double nfy = _currentLine!.pointerStartLocation.dy; + final double nsx = _currentLine!.pointerEndLocation.dx; + final double nsy = _currentLine!.pointerEndLocation.dy; + + final double angle1 = math.atan2(fy - sy, fx - sx); + final double angle2 = math.atan2(nfy - nsy, nfx - nsx); + + return angle2 - angle1; + } + + @override + void addAllowedPointer(PointerDownEvent event) { + super.addAllowedPointer(event); + _velocityTrackers[event.pointer] = VelocityTracker.withKind(event.kind); + if (_state == _ScaleState.ready) { + _state = _ScaleState.possible; + _initialSpan = 0.0; + _currentSpan = 0.0; + _initialHorizontalSpan = 0.0; + _currentHorizontalSpan = 0.0; + _initialVerticalSpan = 0.0; + _currentVerticalSpan = 0.0; + _pointerLocations = {}; + _pointerQueue = []; + } + } + + @override + void handleEvent(PointerEvent event) { + assert(_state != _ScaleState.ready); + bool didChangeConfiguration = false; + bool shouldStartIfAccepted = false; + if (event is PointerMoveEvent) { + final VelocityTracker tracker = _velocityTrackers[event.pointer]!; + if (!event.synthesized) tracker.addPosition(event.timeStamp, event.position); + _pointerLocations[event.pointer] = event.position; + shouldStartIfAccepted = true; + _lastTransform = event.transform; + } else if (event is PointerDownEvent) { + _pointerLocations[event.pointer] = event.position; + _pointerQueue.add(event.pointer); + didChangeConfiguration = true; + shouldStartIfAccepted = true; + _lastTransform = event.transform; + } else if (event is PointerUpEvent || event is PointerCancelEvent) { + _pointerLocations.remove(event.pointer); + _pointerQueue.remove(event.pointer); + didChangeConfiguration = true; + _lastTransform = event.transform; + } + + _updateLines(); + _update(); + + if (!didChangeConfiguration || _reconfigure(event.pointer)) _advanceStateMachine(shouldStartIfAccepted, event.kind); + stopTrackingIfPointerNoLongerDown(event); + } + + void _update() { + final int count = _pointerLocations.keys.length; + + // Compute the focal point + Offset focalPoint = Offset.zero; + for (final int pointer in _pointerLocations.keys) focalPoint += _pointerLocations[pointer]!; + _currentFocalPoint = count > 0 ? focalPoint / count.toDouble() : Offset.zero; + + // Span is the average deviation from focal point. Horizontal and vertical + // spans are the average deviations from the focal point's horizontal and + // vertical coordinates, respectively. + double totalDeviation = 0.0; + double totalHorizontalDeviation = 0.0; + double totalVerticalDeviation = 0.0; + for (final int pointer in _pointerLocations.keys) { + totalDeviation += (_currentFocalPoint - _pointerLocations[pointer]!).distance; + totalHorizontalDeviation += (_currentFocalPoint.dx - _pointerLocations[pointer]!.dx).abs(); + totalVerticalDeviation += (_currentFocalPoint.dy - _pointerLocations[pointer]!.dy).abs(); + } + _currentSpan = count > 0 ? totalDeviation / count : 0.0; + _currentHorizontalSpan = count > 0 ? totalHorizontalDeviation / count : 0.0; + _currentVerticalSpan = count > 0 ? totalVerticalDeviation / count : 0.0; + } + + /// Updates [_initialLine] and [_currentLine] accordingly to the situation of + /// the registered pointers. + void _updateLines() { + final int count = _pointerLocations.keys.length; + assert(_pointerQueue.length >= count); + + /// In case of just one pointer registered, reconfigure [_initialLine] + if (count < 2) { + _initialLine = _currentLine; + } else if (_initialLine != null && _initialLine!.pointerStartId == _pointerQueue[0] && _initialLine!.pointerEndId == _pointerQueue[1]) { + /// Rotation updated, set the [_currentLine] + _currentLine = _LineBetweenPointers( + pointerStartId: _pointerQueue[0], + pointerStartLocation: _pointerLocations[_pointerQueue[0]]!, + pointerEndId: _pointerQueue[1], + pointerEndLocation: _pointerLocations[_pointerQueue[1]]!, + ); + } else { + /// A new rotation process is on the way, set the [_initialLine] + _initialLine = _LineBetweenPointers( + pointerStartId: _pointerQueue[0], + pointerStartLocation: _pointerLocations[_pointerQueue[0]]!, + pointerEndId: _pointerQueue[1], + pointerEndLocation: _pointerLocations[_pointerQueue[1]]!, + ); + _currentLine = _initialLine; + } + } + + bool _reconfigure(int pointer) { + _initialFocalPoint = _currentFocalPoint; + _initialSpan = _currentSpan; + _initialLine = _currentLine; + _initialHorizontalSpan = _currentHorizontalSpan; + _initialVerticalSpan = _currentVerticalSpan; + if (_state == _ScaleState.started) { + if (onEnd != null) { + final VelocityTracker tracker = _velocityTrackers[pointer]!; + + Velocity velocity = tracker.getVelocity(); + if (_isFlingGesture(velocity)) { + final Offset pixelsPerSecond = velocity.pixelsPerSecond; + if (pixelsPerSecond.distanceSquared > kMaxFlingVelocity * kMaxFlingVelocity) velocity = Velocity(pixelsPerSecond: (pixelsPerSecond / pixelsPerSecond.distance) * kMaxFlingVelocity); + invokeCallback('onEnd', () => onEnd!(ScaleEndDetails(velocity: velocity, pointerCount: _pointerQueue.length))); + } else { + invokeCallback('onEnd', () => onEnd!(ScaleEndDetails(velocity: Velocity.zero, pointerCount: _pointerQueue.length))); + } + } + _state = _ScaleState.accepted; + return false; + } + return true; + } + + void _advanceStateMachine(bool shouldStartIfAccepted, PointerDeviceKind pointerDeviceKind) { + if (_state == _ScaleState.ready) _state = _ScaleState.possible; + + // TLAD insert start + if (_pointerQueue.length == 2) { + resolve(GestureDisposition.accepted); + } + // TLAD insert end + + if (_state == _ScaleState.possible) { + final double spanDelta = (_currentSpan - _initialSpan).abs(); + final double focalPointDelta = (_currentFocalPoint - _initialFocalPoint).distance; + if (spanDelta > computeScaleSlop(pointerDeviceKind) || focalPointDelta > computePanSlop(pointerDeviceKind)) resolve(GestureDisposition.accepted); + } else if (_state.index >= _ScaleState.accepted.index) { + resolve(GestureDisposition.accepted); + } + + if (_state == _ScaleState.accepted && shouldStartIfAccepted) { + _state = _ScaleState.started; + _dispatchOnStartCallbackIfNeeded(); + } + + if (_state == _ScaleState.started && onUpdate != null) + invokeCallback('onUpdate', () { + onUpdate!(ScaleUpdateDetails( + scale: _scaleFactor, + horizontalScale: _horizontalScaleFactor, + verticalScale: _verticalScaleFactor, + focalPoint: _currentFocalPoint, + localFocalPoint: PointerEvent.transformPosition(_lastTransform, _currentFocalPoint), + rotation: _computeRotationFactor(), + pointerCount: _pointerQueue.length, + delta: _currentFocalPoint - _initialFocalPoint, + )); + }); + } + + void _dispatchOnStartCallbackIfNeeded() { + assert(_state == _ScaleState.started); + if (onStart != null) + invokeCallback('onStart', () { + onStart!(ScaleStartDetails( + focalPoint: _currentFocalPoint, + localFocalPoint: PointerEvent.transformPosition(_lastTransform, _currentFocalPoint), + pointerCount: _pointerQueue.length, + )); + }); + } + + @override + void acceptGesture(int pointer) { + if (_state == _ScaleState.possible) { + _state = _ScaleState.started; + _dispatchOnStartCallbackIfNeeded(); + if (dragStartBehavior == DragStartBehavior.start) { + _initialFocalPoint = _currentFocalPoint; + _initialSpan = _currentSpan; + _initialLine = _currentLine; + _initialHorizontalSpan = _currentHorizontalSpan; + _initialVerticalSpan = _currentVerticalSpan; + } + } + } + + @override + void rejectGesture(int pointer) { + stopTrackingPointer(pointer); + } + + @override + void didStopTrackingLastPointer(int pointer) { + switch (_state) { + case _ScaleState.possible: + resolve(GestureDisposition.rejected); + break; + case _ScaleState.ready: + assert(false); // We should have not seen a pointer yet + break; + case _ScaleState.accepted: + break; + case _ScaleState.started: + assert(false); // We should be in the accepted state when user is done + break; + } + _state = _ScaleState.ready; + } + + @override + void dispose() { + _velocityTrackers.clear(); + super.dispose(); + } + + @override + String get debugDescription => 'scale'; +} diff --git a/lib/widgets/search/expandable_filter_row.dart b/lib/widgets/common/expandable_filter_row.dart similarity index 100% rename from lib/widgets/search/expandable_filter_row.dart rename to lib/widgets/common/expandable_filter_row.dart diff --git a/lib/widgets/common/fx/transition_image.dart b/lib/widgets/common/fx/transition_image.dart index 7d8531c82..e611fa921 100644 --- a/lib/widgets/common/fx/transition_image.dart +++ b/lib/widgets/common/fx/transition_image.dart @@ -3,7 +3,7 @@ import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -// adapted from `RawImage`, `paintImage()` from `DecorationImagePainter`, etc. +// adapted from Flutter `RawImage`, `paintImage()` from `DecorationImagePainter`, etc. // to transition between 2 different fits during hero animation: // - BoxFit.cover at t=0 // - BoxFit.contain at t=1 diff --git a/lib/widgets/common/grid/sliver.dart b/lib/widgets/common/grid/sliver.dart index e85987128..336d0bbd6 100644 --- a/lib/widgets/common/grid/sliver.dart +++ b/lib/widgets/common/grid/sliver.dart @@ -12,7 +12,7 @@ import 'package:provider/provider.dart'; // With the multiple `SliverGrid` solution, thumbnails at the beginning of each sections are built even though they are offscreen // because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0. // cf https://github.com/flutter/flutter/issues/49027 -// adapted from `RenderSliverFixedExtentBoxAdaptor` +// adapted from Flutter `RenderSliverFixedExtentBoxAdaptor` in `/rendering/sliver_fixed_extent_list.dart` class SectionedListSliver extends StatelessWidget { const SectionedListSliver({Key? key}) : super(key: key); diff --git a/lib/widgets/common/magnifier/magnifier.dart b/lib/widgets/common/magnifier/magnifier.dart index 17091e4d5..342a0590a 100644 --- a/lib/widgets/common/magnifier/magnifier.dart +++ b/lib/widgets/common/magnifier/magnifier.dart @@ -7,7 +7,7 @@ import 'package:aves/widgets/common/magnifier/scale/state.dart'; import 'package:flutter/material.dart'; /* - `Magnifier` is derived from `photo_view` package v0.9.2: + adapted from package `photo_view` v0.9.2: - removed image related aspects to focus on a general purpose pan/scale viewer (à la `InteractiveViewer`) - removed rotation and many customization parameters - removed ignorable/ignoring partial notifiers diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart index 3fe2d3905..b797dd5cf 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -268,7 +268,7 @@ class _GeoMapState extends State { void _onCollectionChanged() { _defaultMarkerCluster = _buildFluster(); _slowMarkerCluster = null; - _clusterChangeNotifier.notifyListeners(); + _clusterChangeNotifier.notify(); } Fluster _buildFluster({int nodeSize = 64}) { diff --git a/lib/widgets/common/map/google/map.dart b/lib/widgets/common/map/google/map.dart index e4f6d4485..c341be9bc 100644 --- a/lib/widgets/common/map/google/map.dart +++ b/lib/widgets/common/map/google/map.dart @@ -134,7 +134,7 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse isReadyToRender: (key) => key.entry.isThumbnailReady(extent: GeoMap.markerImageExtent), onRendered: (key, bitmap) { _markerBitmaps[key] = bitmap; - _markerBitmapChangeNotifier.notifyListeners(); + _markerBitmapChangeNotifier.notify(); }, ), MapDecorator( diff --git a/lib/widgets/common/scaling.dart b/lib/widgets/common/scaling.dart index ea1e624ca..054cb20e4 100644 --- a/lib/widgets/common/scaling.dart +++ b/lib/widgets/common/scaling.dart @@ -2,10 +2,12 @@ import 'dart:ui' as ui; import 'package:aves/model/highlight.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/behaviour/eager_scale_gesture_recognizer.dart'; import 'package:aves/widgets/common/grid/theme.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:collection/collection.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:provider/provider.dart'; @@ -49,95 +51,116 @@ class _GridScaleGestureDetectorState extends State(); + // as of Flutter v2.5.3, `ScaleGestureRecognizer` does not work well + // when combined with the `VerticalDragGestureRecognizer` inside a `GridView`, + // so it is modified to eagerly accept the gesture + // when multiple pointers are involved, and take priority over drag gestures. + return RawGestureDetector( + gestures: { + EagerScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => EagerScaleGestureRecognizer(debugOwner: this), + (instance) { + instance + ..onStart = _onScaleStart + ..onUpdate = _onScaleUpdate + ..onEnd = _onScaleEnd + ..dragStartBehavior = DragStartBehavior.start; + }, + ), + }, + child: child, + ); + } - final scrollableContext = widget.scrollableKey.currentContext!; - final scrollableBox = scrollableContext.findRenderObject() as RenderBox; - final renderMetaData = _getClosestRenderMetadata( - box: scrollableBox, - localFocalPoint: details.localFocalPoint, - spacing: tileExtentController.spacing, - ); - // abort if we cannot find an image to show on overlay - if (renderMetaData == null) return; - _metadata = renderMetaData.metaData; - _startSize = renderMetaData.size; - _scaledSizeNotifier = ValueNotifier(_startSize!); + void _onScaleStart(ScaleStartDetails details) { + // the gesture detector wrongly detects a new scaling gesture + // when scaling ends and we apply the new extent, so we prevent this + // until we scaled and scrolled to the tile in the new grid + if (_applyingScale) return; - // not the same as `MediaQuery.size.width`, because of screen insets/padding - final gridWidth = scrollableBox.size.width; + final tileExtentController = context.read(); - _extentMin = tileExtentController.effectiveExtentMin; - _extentMax = tileExtentController.effectiveExtentMax; + final scrollableContext = widget.scrollableKey.currentContext!; + final scrollableBox = scrollableContext.findRenderObject() as RenderBox; + final renderMetaData = _getClosestRenderMetadata( + box: scrollableBox, + localFocalPoint: details.localFocalPoint, + spacing: tileExtentController.spacing, + ); + // abort if we cannot find an image to show on overlay + if (renderMetaData == null) return; + _metadata = renderMetaData.metaData; + _startSize = renderMetaData.size; + _scaledSizeNotifier = ValueNotifier(_startSize!); - final halfSize = _startSize! / 2; - final thumbnailCenter = renderMetaData.localToGlobal(Offset(halfSize.width, halfSize.height)); - _overlayEntry = OverlayEntry( - builder: (context) => ScaleOverlay( - builder: (scaledTileSize) => SizedBox.fromSize( - size: scaledTileSize, - child: GridTheme( - extent: scaledTileSize.width, - child: widget.scaledBuilder(_metadata!.item, scaledTileSize), - ), - ), - center: thumbnailCenter, - viewportWidth: gridWidth, - gridBuilder: widget.gridBuilder, - scaledSizeNotifier: _scaledSizeNotifier!, + // not the same as `MediaQuery.size.width`, because of screen insets/padding + final gridWidth = scrollableBox.size.width; + + _extentMin = tileExtentController.effectiveExtentMin; + _extentMax = tileExtentController.effectiveExtentMax; + + final halfSize = _startSize! / 2; + final thumbnailCenter = renderMetaData.localToGlobal(Offset(halfSize.width, halfSize.height)); + _overlayEntry = OverlayEntry( + builder: (context) => ScaleOverlay( + builder: (scaledTileSize) => SizedBox.fromSize( + size: scaledTileSize, + child: GridTheme( + extent: scaledTileSize.width, + child: widget.scaledBuilder(_metadata!.item, scaledTileSize), ), - ); - Overlay.of(scrollableContext)!.insert(_overlayEntry!); - }, - onScaleUpdate: (details) { - if (_scaledSizeNotifier == null) return; - final s = details.scale; - final scaledWidth = (_startSize!.width * s).clamp(_extentMin!, _extentMax!); - _scaledSizeNotifier!.value = Size(scaledWidth, widget.heightForWidth(scaledWidth)); - }, - onScaleEnd: (details) { - if (_scaledSizeNotifier == null) return; - if (_overlayEntry != null) { - _overlayEntry!.remove(); - _overlayEntry = null; - } - - _applyingScale = true; - final tileExtentController = context.read(); - final oldExtent = tileExtentController.extentNotifier.value; - // sanitize and update grid layout if necessary - final newExtent = tileExtentController.setUserPreferredExtent(_scaledSizeNotifier!.value.width); - _scaledSizeNotifier = null; - if (newExtent == oldExtent) { - _applyingScale = false; - } else { - // scroll to show the focal point thumbnail at its new position - WidgetsBinding.instance!.addPostFrameCallback((_) { - final trackItem = _metadata!.item; - final highlightItem = widget.highlightItem?.call(trackItem) ?? trackItem; - context.read().trackItem(trackItem, animate: false, highlightItem: highlightItem); - _applyingScale = false; - }); - } - }, - child: GestureDetector( - // Horizontal/vertical drag gestures are interpreted as scaling - // if they are not handled by `onHorizontalDragStart`/`onVerticalDragStart` - // at the scaling `GestureDetector` level, or handled beforehand down the widget tree. - // Setting `onHorizontalDragStart`, `onVerticalDragStart`, and `onScaleStart` - // all at once is not allowed, so we use another `GestureDetector` for that. - onVerticalDragStart: (details) {}, - onHorizontalDragStart: (details) {}, - child: widget.child, + ), + center: thumbnailCenter, + viewportWidth: gridWidth, + gridBuilder: widget.gridBuilder, + scaledSizeNotifier: _scaledSizeNotifier!, ), ); + Overlay.of(scrollableContext)!.insert(_overlayEntry!); + } + + void _onScaleUpdate(ScaleUpdateDetails details) { + if (_scaledSizeNotifier == null) return; + final s = details.scale; + final scaledWidth = (_startSize!.width * s).clamp(_extentMin!, _extentMax!); + _scaledSizeNotifier!.value = Size(scaledWidth, widget.heightForWidth(scaledWidth)); + } + + void _onScaleEnd(ScaleEndDetails details) { + if (_scaledSizeNotifier == null) return; + if (_overlayEntry != null) { + _overlayEntry!.remove(); + _overlayEntry = null; + } + + _applyingScale = true; + final tileExtentController = context.read(); + final oldExtent = tileExtentController.extentNotifier.value; + // sanitize and update grid layout if necessary + final newExtent = tileExtentController.setUserPreferredExtent(_scaledSizeNotifier!.value.width); + _scaledSizeNotifier = null; + if (newExtent == oldExtent) { + _applyingScale = false; + } else { + // scroll to show the focal point thumbnail at its new position + WidgetsBinding.instance!.addPostFrameCallback((_) { + final trackItem = _metadata!.item; + final highlightItem = widget.highlightItem?.call(trackItem) ?? trackItem; + context.read().trackItem(trackItem, animate: false, highlightItem: highlightItem); + _applyingScale = false; + }); + } } RenderMetaData? _getClosestRenderMetadata({ diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart index dac59b386..0779ad10e 100644 --- a/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart @@ -5,10 +5,10 @@ import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/expandable_filter_row.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; -import 'package:aves/widgets/search/expandable_filter_row.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index 6dfe0c0c8..24927b16c 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -15,9 +15,9 @@ import 'package:aves/model/source/tag.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/expandable_filter_row.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; -import 'package:aves/widgets/search/expandable_filter_row.dart'; import 'package:aves/widgets/search/search_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -273,7 +273,7 @@ class CollectionSearchDelegate { focusNode?.unfocus(); } - // adapted from `SearchDelegate` + // adapted from Flutter `SearchDelegate` in `/material/search.dart` void showResults(BuildContext context) { focusNode?.unfocus(); @@ -311,10 +311,10 @@ class CollectionSearchDelegate { SearchPageRoute? route; } -// adapted from `SearchDelegate` +// adapted from Flutter `_SearchBody` in `/material/search.dart` enum SearchBody { suggestions, results } -// adapted from `SearchDelegate` +// adapted from Flutter `_SearchPageRoute` in `/material/search.dart` class SearchPageRoute extends PageRoute { SearchPageRoute({ required this.delegate, diff --git a/lib/widgets/settings/common/quick_actions/editor_page.dart b/lib/widgets/settings/common/quick_actions/editor_page.dart index 6c329871f..0139ded0d 100644 --- a/lib/widgets/settings/common/quick_actions/editor_page.dart +++ b/lib/widgets/settings/common/quick_actions/editor_page.dart @@ -290,7 +290,7 @@ class _QuickActionEditorBodyState extends State _reordering = false); return true; } @@ -305,7 +305,7 @@ class _QuickActionEditorBodyState extends State DraggedPlaceholder(child: _buildQuickActionButton(action, animation)), duration: Durations.quickActionListAnimation, ); - _quickActionsChangeNotifier.notifyListeners(); + _quickActionsChangeNotifier.notify(); return true; } diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 35ee00ca6..ca2f7f6e6 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -390,7 +390,7 @@ class _EntryViewerStackState extends State with FeedbackMixin, if (!_isEntryTracked && _verticalPager.page?.floor() == transitionPage) { _trackEntry(); } - _verticalScrollNotifier.notifyListeners(); + _verticalScrollNotifier.notify(); } void _goToCollection(CollectionFilter filter) { diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart b/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart index bb0d56cdd..8b724c00d 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart @@ -1,9 +1,8 @@ -// cf photoshop:ColorMode -// cf https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/photoshop.md import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; import 'package:flutter/widgets.dart'; +// cf https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/photoshop.md class XmpPhotoshopNamespace extends XmpNamespace { static const ns = 'photoshop'; diff --git a/lib/widgets/viewer/overlay/common.dart b/lib/widgets/viewer/overlay/common.dart index d209f4f1b..044933971 100644 --- a/lib/widgets/viewer/overlay/common.dart +++ b/lib/widgets/viewer/overlay/common.dart @@ -75,7 +75,6 @@ class OverlayTextButton extends StatelessWidget { shape: MaterialStateProperty.all(const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(_borderRadius)), )), - // shape: MaterialStateProperty.all(CircleBorder()), ), child: Text(buttonLabel), ), diff --git a/lib/widgets/viewer/video/fijkplayer.dart b/lib/widgets/viewer/video/fijkplayer.dart index 1cec118b9..50d1c2b5e 100644 --- a/lib/widgets/viewer/video/fijkplayer.dart +++ b/lib/widgets/viewer/video/fijkplayer.dart @@ -86,7 +86,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController { void _startListening() { _instance.addListener(_onValueChanged); - _subscriptions.add(_valueStream.where((value) => value.state == FijkState.completed).listen((_) => _completedNotifier.notifyListeners())); + _subscriptions.add(_valueStream.where((value) => value.state == FijkState.completed).listen((_) => _completedNotifier.notify())); _subscriptions.add(_instance.onTimedText.listen(_timedTextStreamController.add)); } diff --git a/pubspec.yaml b/pubspec.yaml index d09074b41..7f51aede6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -117,3 +117,30 @@ flutter: # capture shaders in profile mode (real device only): # % flutter drive --flavor play -t test_driver/driver_play.dart --profile --cache-sksl --write-sksl-on-exit shaders.sksl.json + +################################################################################ +# Adaptations + +# `DraggableScrollbar` in `/widgets/common/basic/draggable_scrollbar.dart` +# adapts from package `draggable_scrollbar` v0.0.4 +# +# `Magnifier` in `/widgets/common/magnifier/magnifier.dart` +# adapts from package `photo_view` v0.9.2 +# +# `AvesHighlightView` in `/widgets/common/aves_highlight.dart` +# adapts from package `flutter_highlight` v0.7.0 +# +# `OutputBuffer` in `/services/common/output_buffer.dart` +# adapts from Flutter `_OutputBuffer` in `/foundation/consolidate_response.dart` +# +# `EagerScaleGestureRecognizer` in `/widgets/common/behaviour/eager_scale_gesture_recognizer.dart` +# adapts from Flutter `ScaleGestureRecognizer` in `/gestures/scale.dart` +# +# `TransitionImage` in `/widgets/common/fx/transition_image.dart` +# adapts from Flutter `RawImage` in `/widgets/basic.dart` and `DecorationImagePainter` in `/painting/decoration_image.dart` +# +# `_RenderSliverKnownExtentBoxAdaptor` in `/widgets/common/grid/sliver.dart` +# adapts from Flutter `RenderSliverFixedExtentBoxAdaptor` in `/rendering/sliver_fixed_extent_list.dart` +# +# `CollectionSearchDelegate`, `SearchPageRoute` in `/widgets/search/search_delegate.dart` +# adapts from Flutter `SearchDelegate`, `_SearchPageRoute` in `/material/search.dart`