#101 adapted ScaleGestureRecognizer to eagerly accept multiple pointer gestures

This commit is contained in:
Thibault Deckers 2021-11-25 10:50:06 +09:00
parent 769c8f9f2f
commit 005339094b
27 changed files with 608 additions and 177 deletions

View file

@ -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<void> 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<void> refresh({
@ -717,7 +717,7 @@ class AvesEntry {
Future<void> _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();
}
}

View file

@ -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';

View file

@ -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<AvesEntry>? entries;
const EntryAddedEvent([this.entries]);
}
@immutable
class EntryRemovedEvent {
final Set<AvesEntry> entries;
const EntryRemovedEvent(this.entries);
}
@immutable
class EntryMovedEvent {
final Set<AvesEntry> entries;
const EntryMovedEvent(this.entries);
}
@immutable
class EntryRefreshedEvent {
final Set<AvesEntry> entries;
const EntryRefreshedEvent(this.entries);
}
@immutable
class FilterVisibilityChangedEvent {
final Set<CollectionFilter> 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});
}

View file

@ -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<AvesEntry>? entries;
const EntryAddedEvent([this.entries]);
}
@immutable
class EntryRemovedEvent {
final Set<AvesEntry> entries;
const EntryRemovedEvent(this.entries);
}
@immutable
class EntryMovedEvent {
final Set<AvesEntry> entries;
const EntryMovedEvent(this.entries);
}
@immutable
class EntryRefreshedEvent {
final Set<AvesEntry> entries;
const EntryRefreshedEvent(this.entries);
}
@immutable
class FilterVisibilityChangedEvent {
final Set<CollectionFilter> 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});
}

View file

@ -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<List<int>>? _chunks = <List<int>>[];
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<int> chunk in _chunks!) {
_bytes!.setRange(offset, offset + chunk.length, chunk);
offset += chunk.length;
}

View file

@ -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,
};

View file

@ -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<VoidCallback>? _listeners = ObserverList<VoidCallback>();
@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<VoidCallback>.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();
}
}

View file

@ -42,8 +42,6 @@ class _EntryQueryBarState extends State<EntryQueryBar> {
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);
}

View file

@ -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';

View file

@ -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 {

View file

@ -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

View file

@ -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<PointerDeviceKind>? 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<int, Offset> _pointerLocations;
late List<int> _pointerQueue; // A queue to sort pointers in order of entrance
final Map<int, VelocityTracker> _velocityTrackers = <int, VelocityTracker>{};
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 = <int, Offset>{};
_pointerQueue = <int>[];
}
}
@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<void>('onEnd', () => onEnd!(ScaleEndDetails(velocity: velocity, pointerCount: _pointerQueue.length)));
} else {
invokeCallback<void>('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<void>('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<void>('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';
}

View file

@ -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

View file

@ -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<T> extends StatelessWidget {
const SectionedListSliver({Key? key}) : super(key: key);

View file

@ -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

View file

@ -268,7 +268,7 @@ class _GeoMapState extends State<GeoMap> {
void _onCollectionChanged() {
_defaultMarkerCluster = _buildFluster();
_slowMarkerCluster = null;
_clusterChangeNotifier.notifyListeners();
_clusterChangeNotifier.notify();
}
Fluster<GeoEntry> _buildFluster({int nodeSize = 64}) {

View file

@ -134,7 +134,7 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
isReadyToRender: (key) => key.entry.isThumbnailReady(extent: GeoMap.markerImageExtent),
onRendered: (key, bitmap) {
_markerBitmaps[key] = bitmap;
_markerBitmapChangeNotifier.notifyListeners();
_markerBitmapChangeNotifier.notify();
},
),
MapDecorator(

View file

@ -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<T> extends State<GridScaleGestureDetector<T
@override
Widget build(BuildContext context) {
return GestureDetector(
onScaleStart: (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;
final 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,
);
final tileExtentController = context.read<TileExtentController>();
// 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: <Type, GestureRecognizerFactory>{
EagerScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<EagerScaleGestureRecognizer>(
() => 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<TileExtentController>();
_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<TileExtentController>();
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<HighlightInfo>().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<TileExtentController>();
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<HighlightInfo>().trackItem(trackItem, animate: false, highlightItem: highlightItem);
_applyingScale = false;
});
}
}
RenderMetaData? _getClosestRenderMetadata({

View file

@ -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';

View file

@ -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<T> extends PageRoute<T> {
SearchPageRoute({
required this.delegate,

View file

@ -290,7 +290,7 @@ class _QuickActionEditorBodyState<T extends Object> extends State<QuickActionEdi
targetIndex,
duration: Durations.quickActionListAnimation,
);
_quickActionsChangeNotifier.notifyListeners();
_quickActionsChangeNotifier.notify();
Future.delayed(Durations.quickActionListAnimation).then((value) => _reordering = false);
return true;
}
@ -305,7 +305,7 @@ class _QuickActionEditorBodyState<T extends Object> extends State<QuickActionEdi
(context, animation) => DraggedPlaceholder(child: _buildQuickActionButton(action, animation)),
duration: Durations.quickActionListAnimation,
);
_quickActionsChangeNotifier.notifyListeners();
_quickActionsChangeNotifier.notify();
return true;
}

View file

@ -390,7 +390,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
if (!_isEntryTracked && _verticalPager.page?.floor() == transitionPage) {
_trackEntry();
}
_verticalScrollNotifier.notifyListeners();
_verticalScrollNotifier.notify();
}
void _goToCollection(CollectionFilter filter) {

View file

@ -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';

View file

@ -75,7 +75,6 @@ class OverlayTextButton extends StatelessWidget {
shape: MaterialStateProperty.all<OutlinedBorder>(const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(_borderRadius)),
)),
// shape: MaterialStateProperty.all<OutlinedBorder>(CircleBorder()),
),
child: Text(buttonLabel),
),

View file

@ -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));
}

View file

@ -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`