Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2023-02-20 23:30:24 +01:00
commit 298150c162
29 changed files with 437 additions and 247 deletions

View file

@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Clone the repository. - name: Clone the repository.
uses: actions/checkout@v2 uses: actions/checkout@v3
- name: Get packages for the Flutter project. - name: Get packages for the Flutter project.
run: scripts/pub_get_all.sh run: scripts/pub_get_all.sh

View file

@ -10,12 +10,13 @@ jobs:
name: Build and release artifacts. name: Build and release artifacts.
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/setup-java@v1 - uses: actions/setup-java@v3
with: with:
java-version: '11.x' distribution: 'zulu'
java-version: '11'
- name: Clone the repository. - name: Clone the repository.
uses: actions/checkout@v2 uses: actions/checkout@v3
- name: Get packages for the Flutter project. - name: Get packages for the Flutter project.
run: scripts/pub_get_all.sh run: scripts/pub_get_all.sh
@ -74,7 +75,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload app bundle - name: Upload app bundle
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
with: with:
name: appbundle name: appbundle
path: outputs/app-play-release.aab path: outputs/app-play-release.aab
@ -84,15 +85,15 @@ jobs:
needs: [ build ] needs: [ build ]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Get appbundle from artifacts. - name: Get appbundle from artifacts.
uses: actions/download-artifact@v2 uses: actions/download-artifact@v3
with: with:
name: appbundle name: appbundle
- name: Release app to beta channel. - name: Release app to beta channel.
uses: r0adkll/upload-google-play@v1 uses: r0adkll/upload-google-play@v1.1.1
with: with:
serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_ACCOUNT_KEY }} serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_ACCOUNT_KEY }}
packageName: deckers.thibault.aves packageName: deckers.thibault.aves

View file

@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased] ## <a id="unreleased"></a>[Unreleased]
## <a id="v1.8.0"></a>[v1.8.0] - 2023-02-20 ## <a id="v1.8.1"></a>[v1.8.1] - 2023-02-21
### Added ### Added
@ -28,6 +28,8 @@ All notable changes to this project will be documented in this file.
- copying to SD card in some cases - copying to SD card in some cases
- sharing SD card files referred by `file` URI - sharing SD card files referred by `file` URI
## <a id="v1.8.0"></a>[v1.8.0] - 2023-02-20 [YANKED]
## <a id="v1.7.10"></a>[v1.7.10] - 2023-01-18 ## <a id="v1.7.10"></a>[v1.7.10] - 2023-01-18
### Added ### Added

View file

@ -21,6 +21,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.*
class ImageOpStreamHandler(private val activity: Activity, private val arguments: Any?) : EventChannel.StreamHandler { class ImageOpStreamHandler(private val activity: Activity, private val arguments: Any?) : EventChannel.StreamHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@ -219,18 +220,20 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
entriesToNewName[AvesEntry(rawEntry)] = newName entriesToNewName[AvesEntry(rawEntry)] = newName
} }
// assume same provider for all entries val byProvider = entriesToNewName.entries.groupBy { kv -> getProvider(kv.key.uri) }
val firstEntry = entriesToNewName.keys.first() for ((provider, entryList) in byProvider) {
val provider = getProvider(firstEntry.uri)
if (provider == null) { if (provider == null) {
error("rename-provider", "failed to find provider for entry=$firstEntry", null) error("rename-provider", "failed to find provider for entry=${entryList.firstOrNull()}", null)
return return
} }
provider.renameMultiple(activity, entriesToNewName, ::isCancelledOp, object : ImageOpCallback { val entryMap = mapOf(*entryList.map { Pair(it.key, it.value) }.toTypedArray())
provider.renameMultiple(activity, entryMap, ::isCancelledOp, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields) override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("rename-failure", "failed to rename", throwable.message) override fun onFailure(throwable: Throwable) = error("rename-failure", "failed to rename", throwable.message)
}) })
}
endOfStream() endOfStream()
} }

View file

@ -1,5 +1,6 @@
package deckers.thibault.aves.model.provider package deckers.thibault.aves.model.provider
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import android.net.Uri import android.net.Uri
@ -53,6 +54,26 @@ internal class FileImageProvider : ImageProvider() {
throw Exception("failed to delete entry with uri=$uri path=$path") throw Exception("failed to delete entry with uri=$uri path=$path")
} }
override suspend fun renameSingle(
activity: Activity,
mimeType: String,
oldMediaUri: Uri,
oldPath: String,
newFile: File,
): FieldMap {
Log.d(LOG_TAG, "rename file at path=$oldPath")
val renamed = File(oldPath).renameTo(newFile)
if (!renamed) {
throw Exception("failed to rename file at path=$oldPath")
}
return hashMapOf(
"uri" to Uri.fromFile(newFile).toString(),
"path" to newFile.path,
"dateModifiedSecs" to newFile.lastModified() / 1000,
)
}
override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: FieldMap, callback: ImageOpCallback) { override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: FieldMap, callback: ImageOpCallback) {
try { try {
val file = File(path) val file = File(path)

View file

@ -68,13 +68,75 @@ abstract class ImageProvider {
callback.onFailure(UnsupportedOperationException("`moveMultiple` is not supported by this image provider")) callback.onFailure(UnsupportedOperationException("`moveMultiple` is not supported by this image provider"))
} }
open suspend fun renameMultiple( suspend fun renameMultiple(
activity: Activity, activity: Activity,
entriesToNewName: Map<AvesEntry, String>, entriesToNewName: Map<AvesEntry, String>,
isCancelledOp: CancelCheck, isCancelledOp: CancelCheck,
callback: ImageOpCallback, callback: ImageOpCallback,
) { ) {
callback.onFailure(UnsupportedOperationException("`renameMultiple` is not supported by this image provider")) for (kv in entriesToNewName) {
val entry = kv.key
val desiredName = kv.value
val sourceUri = entry.uri
val sourcePath = entry.path
val mimeType = entry.mimeType
val result: FieldMap = hashMapOf(
"uri" to sourceUri.toString(),
"success" to false,
)
// prevent naming with a `.` prefix as it would hide the file and remove it from the Media Store
if (sourcePath != null && !desiredName.startsWith('.')) {
try {
var newFields: FieldMap = skippedFieldMap
if (!isCancelledOp()) {
val desiredNameWithoutExtension = desiredName.substringBeforeLast(".")
val oldFile = File(sourcePath)
if (oldFile.nameWithoutExtension != desiredNameWithoutExtension) {
oldFile.parent?.let { dir ->
resolveTargetFileNameWithoutExtension(
contextWrapper = activity,
dir = dir,
desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = mimeType,
conflictStrategy = NameConflictStrategy.RENAME,
)?.let { targetNameWithoutExtension ->
val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}"
val newFile = File(dir, targetFileName)
if (oldFile != newFile) {
newFields = renameSingle(
activity = activity,
mimeType = mimeType,
oldMediaUri = sourceUri,
oldPath = sourcePath,
newFile = newFile,
)
}
}
}
}
}
result["newFields"] = newFields
result["success"] = true
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to rename to newFileName=$desiredName entry with sourcePath=$sourcePath", e)
}
}
callback.onSuccess(result)
}
}
open suspend fun renameSingle(
activity: Activity,
mimeType: String,
oldMediaUri: Uri,
oldPath: String,
newFile: File,
): FieldMap {
throw UnsupportedOperationException("`renameSingle` is not supported by this image provider")
} }
open fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: FieldMap, callback: ImageOpCallback) { open fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: FieldMap, callback: ImageOpCallback) {

View file

@ -552,10 +552,10 @@ class MediaStoreImageProvider : ImageProvider() {
) )
} else if (toVault) { } else if (toVault) {
hashMapOf( hashMapOf(
"origin" to SourceEntry.ORIGIN_VAULT,
"uri" to File(targetPath).toUri().toString(), "uri" to File(targetPath).toUri().toString(),
"contentId" to null, "contentId" to null,
"path" to targetPath, "path" to targetPath,
"origin" to SourceEntry.ORIGIN_VAULT,
) )
} else { } else {
scanNewPath(activity, targetPath, mimeType) scanNewPath(activity, targetPath, mimeType)
@ -626,75 +626,17 @@ class MediaStoreImageProvider : ImageProvider() {
return targetDir + fileName return targetDir + fileName
} }
override suspend fun renameMultiple( override suspend fun renameSingle(
activity: Activity,
entriesToNewName: Map<AvesEntry, String>,
isCancelledOp: CancelCheck,
callback: ImageOpCallback,
) {
for (kv in entriesToNewName) {
val entry = kv.key
val desiredName = kv.value
val sourceUri = entry.uri
val sourcePath = entry.path
val mimeType = entry.mimeType
val result: FieldMap = hashMapOf(
"uri" to sourceUri.toString(),
"success" to false,
)
// prevent naming with a `.` prefix as it would hide the file and remove it from the Media Store
if (sourcePath != null && !desiredName.startsWith('.')) {
try {
val newFields = if (isCancelledOp()) skippedFieldMap else renameSingle(
activity = activity,
mimeType = mimeType,
oldMediaUri = sourceUri,
oldPath = sourcePath,
desiredName = desiredName,
)
result["newFields"] = newFields
result["success"] = true
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to rename to newFileName=$desiredName entry with sourcePath=$sourcePath", e)
}
}
callback.onSuccess(result)
}
}
private suspend fun renameSingle(
activity: Activity, activity: Activity,
mimeType: String, mimeType: String,
oldMediaUri: Uri, oldMediaUri: Uri,
oldPath: String, oldPath: String,
desiredName: String, newFile: File,
): FieldMap { ): FieldMap = when {
val desiredNameWithoutExtension = desiredName.substringBeforeLast(".")
val oldFile = File(oldPath)
if (oldFile.nameWithoutExtension == desiredNameWithoutExtension) return skippedFieldMap
val dir = oldFile.parent ?: return skippedFieldMap
val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension(
contextWrapper = activity,
dir = dir,
desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = mimeType,
conflictStrategy = NameConflictStrategy.RENAME,
) ?: return skippedFieldMap
val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}"
val newFile = File(dir, targetFileName)
return when {
oldFile == newFile -> skippedFieldMap
StorageUtils.canEditByFile(activity, oldPath) -> renameSingleByFile(activity, mimeType, oldMediaUri, oldPath, newFile) StorageUtils.canEditByFile(activity, oldPath) -> renameSingleByFile(activity, mimeType, oldMediaUri, oldPath, newFile)
isMediaUriPermissionGranted(activity, oldMediaUri, mimeType) -> renameSingleByMediaStore(activity, mimeType, oldMediaUri, newFile) isMediaUriPermissionGranted(activity, oldMediaUri, mimeType) -> renameSingleByMediaStore(activity, mimeType, oldMediaUri, newFile)
else -> renameSingleByTreeDoc(activity, mimeType, oldMediaUri, oldPath, newFile) else -> renameSingleByTreeDoc(activity, mimeType, oldMediaUri, oldPath, newFile)
} }
}
private suspend fun renameSingleByMediaStore( private suspend fun renameSingleByMediaStore(
activity: Activity, activity: Activity,
@ -851,10 +793,12 @@ class MediaStoreImageProvider : ImageProvider() {
try { try {
val cursor = context.contentResolver.query(uri, projection, null, null, null) val cursor = context.contentResolver.query(uri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) { if (cursor != null && cursor.moveToFirst()) {
val newFields = HashMap<String, Any?>() val newFields = hashMapOf<String, Any?>(
newFields["uri"] = uri.toString() "origin" to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
newFields["contentId"] = uri.tryParseId() "uri" to uri.toString(),
newFields["path"] = path "contentId" to uri.tryParseId(),
"path" to path,
)
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED).let { if (it != -1) newFields["dateAddedSecs"] = cursor.getInt(it) } cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED).let { if (it != -1) newFields["dateAddedSecs"] = cursor.getInt(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) } cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
cursor.close() cursor.close()

View file

@ -0,0 +1,5 @@
In v1.8.1:
- Android TV support (cont'd)
- hide your secrets in vaults
- enjoy the app in Basque
Full changelog available on GitHub

View file

@ -0,0 +1,5 @@
In v1.8.1:
- Android TV support (cont'd)
- hide your secrets in vaults
- enjoy the app in Basque
Full changelog available on GitHub

View file

@ -266,9 +266,9 @@ class Dependencies {
sourceUrl: 'https://github.com/diegoveloper/flutter_percent_indicator', sourceUrl: 'https://github.com/diegoveloper/flutter_percent_indicator',
), ),
Dependency( Dependency(
name: 'Pinput', name: 'Pin Code Fields',
license: mit, license: mit,
sourceUrl: 'https://github.com/Tkko/Flutter_PinPut', sourceUrl: 'https://github.com/adar2378/pin_code_fields',
), ),
Dependency( Dependency(
name: 'Provider', name: 'Provider',

View file

@ -44,7 +44,18 @@ class _PasswordDialogState extends State<PasswordDialog> {
onSubmitted: (password) { onSubmitted: (password) {
if (widget.needConfirmation) { if (widget.needConfirmation) {
if (_confirming) { if (_confirming) {
Navigator.maybeOf(context)?.pop<String>(_firstPassword == password ? password : null); final match = _firstPassword == password;
Navigator.maybeOf(context)?.pop<String>(match ? password : null);
if (!match) {
showDialog(
context: context,
builder: (context) => AvesDialog(
content: Text(context.l10n.genericFailureFeedback),
actions: const [OkButton()],
),
routeSettings: const RouteSettings(name: AvesDialog.warningRouteName),
);
}
} else { } else {
_firstPassword = password; _firstPassword = password;
_controller.clear(); _controller.clear();

View file

@ -1,7 +1,7 @@
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pinput/pinput.dart'; import 'package:pin_code_fields/pin_code_fields.dart';
class PinDialog extends StatefulWidget { class PinDialog extends StatefulWidget {
static const routeName = '/dialog/pin'; static const routeName = '/dialog/pin';
@ -19,18 +19,12 @@ class PinDialog extends StatefulWidget {
class _PinDialogState extends State<PinDialog> { class _PinDialogState extends State<PinDialog> {
final _controller = TextEditingController(); final _controller = TextEditingController();
final _focusNode = FocusNode();
bool _confirming = false; bool _confirming = false;
String? _firstPin; String? _firstPin;
@override
void initState() {
super.initState();
_focusNode.requestFocus();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return AvesDialog( return AvesDialog(
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -38,24 +32,48 @@ class _PinDialogState extends State<PinDialog> {
Text(_confirming ? context.l10n.pinDialogConfirm : context.l10n.pinDialogEnter), Text(_confirming ? context.l10n.pinDialogConfirm : context.l10n.pinDialogEnter),
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
child: Pinput( child: PinCodeTextField(
appContext: context,
length: 4,
controller: _controller,
obscureText: true,
onChanged: (v) {},
onCompleted: (pin) { onCompleted: (pin) {
if (widget.needConfirmation) { if (widget.needConfirmation) {
if (_confirming) { if (_confirming) {
Navigator.maybeOf(context)?.pop<String>(_firstPin == pin ? pin : null); final match = _firstPin == pin;
Navigator.maybeOf(context)?.pop<String>(match ? pin : null);
if (!match) {
showDialog(
context: context,
builder: (context) => AvesDialog(
content: Text(context.l10n.genericFailureFeedback),
actions: const [OkButton()],
),
routeSettings: const RouteSettings(name: AvesDialog.warningRouteName),
);
}
} else { } else {
_firstPin = pin; _firstPin = pin;
_controller.clear(); _controller.clear();
setState(() => _confirming = true); setState(() => _confirming = true);
WidgetsBinding.instance.addPostFrameCallback((_) => _focusNode.requestFocus());
} }
} else { } else {
Navigator.maybeOf(context)?.pop<String>(pin); Navigator.maybeOf(context)?.pop<String>(pin);
} }
}, },
controller: _controller, animationType: AnimationType.scale,
focusNode: _focusNode, keyboardType: TextInputType.number,
obscureText: true, autoFocus: true,
autoDismissKeyboard: !widget.needConfirmation || _confirming,
pinTheme: PinTheme(
activeColor: colorScheme.onBackground,
inactiveColor: colorScheme.onBackground,
selectedColor: colorScheme.secondary,
selectedFillColor: colorScheme.secondary,
borderRadius: BorderRadius.circular(8),
shape: PinCodeFieldShape.box,
),
), ),
), ),
], ],

View file

@ -466,14 +466,16 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
if (!_overlayVisible.value) { if (!_overlayVisible.value) {
_overlayVisible.value = true; _overlayVisible.value = true;
} }
} else if (notification is PopVisualNotification) {
_popVisual();
} else if (notification is ShowInfoPageNotification) { } else if (notification is ShowInfoPageNotification) {
_goToVerticalPage(infoPage); _goToVerticalPage(infoPage);
} else if (notification is JumpToPreviousEntryNotification) { } else if (notification is ShowPreviousEntryNotification) {
_jumpToHorizontalPageByDelta(-1); _goToHorizontalPageByDelta(delta: -1, animate: notification.animate);
} else if (notification is JumpToNextEntryNotification) { } else if (notification is ShowNextEntryNotification) {
_jumpToHorizontalPageByDelta(1); _goToHorizontalPageByDelta(delta: 1, animate: notification.animate);
} else if (notification is JumpToEntryNotification) { } else if (notification is ShowEntryNotification) {
_jumpToHorizontalPageByIndex(notification.index); _goToHorizontalPageByIndex(page: notification.index, animate: notification.animate);
} else if (notification is VideoActionNotification) { } else if (notification is VideoActionNotification) {
final controller = notification.controller; final controller = notification.controller;
final action = notification.action; final action = notification.action;
@ -545,23 +547,33 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
} }
} }
void _jumpToHorizontalPageByDelta(int delta) { void _goToHorizontalPageByDelta({required int delta, required bool animate}) {
if (_horizontalPager.positions.isEmpty) return; if (_horizontalPager.positions.isEmpty) return;
final page = _horizontalPager.page?.round(); final page = _horizontalPager.page?.round();
if (page != null) { if (page != null) {
_jumpToHorizontalPageByIndex(page + delta); _goToHorizontalPageByIndex(page: page + delta, animate: animate);
} }
} }
void _jumpToHorizontalPageByIndex(int target) { Future<void> _goToHorizontalPageByIndex({required int page, required bool animate}) async {
final _collection = collection; final _collection = collection;
if (_collection != null) { if (_collection != null) {
if (!widget.viewerController.repeat) { if (!widget.viewerController.repeat) {
target = target.clamp(0, _collection.entryCount - 1); page = page.clamp(0, _collection.entryCount - 1);
}
if (_currentEntryIndex != page) {
final animationDuration = animate ? context.read<DurationsData>().viewerVerticalPageScrollAnimation : Duration.zero;
if (animationDuration > Duration.zero) {
// duration & curve should feel similar to changing page by vertical fling
await _horizontalPager.animateToPage(
page,
duration: animationDuration,
curve: Curves.easeOutQuart,
);
} else {
_horizontalPager.jumpToPage(page);
} }
if (_currentEntryIndex != target) {
_horizontalPager.jumpToPage(target);
} }
} }
} }

View file

@ -6,6 +6,9 @@ import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@immutable
class PopVisualNotification extends Notification {}
@immutable @immutable
class ShowImageNotification extends Notification {} class ShowImageNotification extends Notification {}
@ -13,10 +16,29 @@ class ShowImageNotification extends Notification {}
class ShowInfoPageNotification extends Notification {} class ShowInfoPageNotification extends Notification {}
@immutable @immutable
class TvShowLessInfoNotification extends Notification {} class ShowPreviousEntryNotification extends Notification {
final bool animate;
const ShowPreviousEntryNotification({required this.animate});
}
@immutable @immutable
class TvShowMoreInfoNotification extends Notification {} class ShowNextEntryNotification extends Notification {
final bool animate;
const ShowNextEntryNotification({required this.animate});
}
@immutable
class ShowEntryNotification extends Notification {
final bool animate;
final int index;
const ShowEntryNotification({
required this.animate,
required this.index,
});
}
@immutable @immutable
class ToggleOverlayNotification extends Notification { class ToggleOverlayNotification extends Notification {
@ -26,17 +48,10 @@ class ToggleOverlayNotification extends Notification {
} }
@immutable @immutable
class JumpToPreviousEntryNotification extends Notification {} class TvShowLessInfoNotification extends Notification {}
@immutable @immutable
class JumpToNextEntryNotification extends Notification {} class TvShowMoreInfoNotification extends Notification {}
@immutable
class JumpToEntryNotification extends Notification {
final int index;
const JumpToEntryNotification({required this.index});
}
@immutable @immutable
class VideoActionNotification extends Notification { class VideoActionNotification extends Notification {

View file

@ -60,13 +60,13 @@ class _ViewerThumbnailPreviewState extends State<ViewerThumbnailPreview> {
entryCount: entryCount, entryCount: entryCount,
entryBuilder: (index) => 0 <= index && index < entryCount ? entries[index] : null, entryBuilder: (index) => 0 <= index && index < entryCount ? entries[index] : null,
indexNotifier: _entryIndexNotifier, indexNotifier: _entryIndexNotifier,
onTap: (index) => JumpToEntryNotification(index: index).dispatch(context), onTap: (index) => ShowEntryNotification(animate: false, index: index).dispatch(context),
); );
} }
void _onScrollerIndexChanged() => _debouncer(() { void _onScrollerIndexChanged() => _debouncer(() {
if (mounted) { if (mounted) {
JumpToEntryNotification(index: _entryIndexNotifier.value).dispatch(context); ShowEntryNotification(animate: false, index: _entryIndexNotifier.value).dispatch(context);
} }
}); });
} }

View file

@ -401,20 +401,38 @@ class _EntryPageViewState extends State<EntryPageView> with SingleTickerProvider
onScaleStart: onScaleStart, onScaleStart: onScaleStart,
onScaleUpdate: onScaleUpdate, onScaleUpdate: onScaleUpdate,
onScaleEnd: onScaleEnd, onScaleEnd: onScaleEnd,
onFling: _onFling,
onTap: (c, s, a, p) => _onTap(alignment: a), onTap: (c, s, a, p) => _onTap(alignment: a),
onDoubleTap: onDoubleTap, onDoubleTap: onDoubleTap,
child: child, child: child,
); );
} }
void _onFling(AxisDirection direction) {
switch (direction) {
case AxisDirection.left:
const ShowPreviousEntryNotification(animate: true).dispatch(context);
break;
case AxisDirection.right:
const ShowNextEntryNotification(animate: true).dispatch(context);
break;
case AxisDirection.up:
PopVisualNotification().dispatch(context);
break;
case AxisDirection.down:
ShowInfoPageNotification().dispatch(context);
break;
}
}
void _onTap({Alignment? alignment}) { void _onTap({Alignment? alignment}) {
if (settings.viewerGestureSideTapNext && alignment != null) { if (settings.viewerGestureSideTapNext && alignment != null) {
final x = alignment.x; final x = alignment.x;
if (x < sideRatio) { if (x < sideRatio) {
JumpToPreviousEntryNotification().dispatch(context); const ShowPreviousEntryNotification(animate: false).dispatch(context);
return; return;
} else if (x > 1 - sideRatio) { } else if (x > 1 - sideRatio) {
JumpToNextEntryNotification().dispatch(context); const ShowNextEntryNotification(animate: false).dispatch(context);
return; return;
} }
} }

View file

@ -138,9 +138,9 @@ mixin AvesMagnifierControllerDelegate on State<MagnifierCore> {
controller.setScaleState(nextScaleState, source, childFocalPoint: childFocalPoint); controller.setScaleState(nextScaleState, source, childFocalPoint: childFocalPoint);
} }
CornersRange cornersX({double? scale}) { EdgeRange getXEdges({double? scale}) {
final boundaries = scaleBoundaries; final boundaries = scaleBoundaries;
if (boundaries == null) return const CornersRange(0, 0); if (boundaries == null) return const EdgeRange(0, 0);
final _scale = scale ?? this.scale!; final _scale = scale ?? this.scale!;
@ -152,12 +152,12 @@ mixin AvesMagnifierControllerDelegate on State<MagnifierCore> {
final minX = ((positionX - 1).abs() / 2) * widthDiff * -1; final minX = ((positionX - 1).abs() / 2) * widthDiff * -1;
final maxX = ((positionX + 1).abs() / 2) * widthDiff; final maxX = ((positionX + 1).abs() / 2) * widthDiff;
return CornersRange(minX, maxX); return EdgeRange(minX, maxX);
} }
CornersRange cornersY({double? scale}) { EdgeRange getYEdges({double? scale}) {
final boundaries = scaleBoundaries; final boundaries = scaleBoundaries;
if (boundaries == null) return const CornersRange(0, 0); if (boundaries == null) return const EdgeRange(0, 0);
final _scale = scale ?? this.scale!; final _scale = scale ?? this.scale!;
@ -169,7 +169,7 @@ mixin AvesMagnifierControllerDelegate on State<MagnifierCore> {
final minY = ((positionY - 1).abs() / 2) * heightDiff * -1; final minY = ((positionY - 1).abs() / 2) * heightDiff * -1;
final maxY = ((positionY + 1).abs() / 2) * heightDiff; final maxY = ((positionY + 1).abs() / 2) * heightDiff;
return CornersRange(minY, maxY); return EdgeRange(minY, maxY);
} }
Offset clampPosition({Offset? position, double? scale}) { Offset clampPosition({Offset? position, double? scale}) {
@ -187,14 +187,14 @@ mixin AvesMagnifierControllerDelegate on State<MagnifierCore> {
var finalX = 0.0; var finalX = 0.0;
if (screenWidth < computedWidth) { if (screenWidth < computedWidth) {
final cornersX = this.cornersX(scale: _scale); final range = getXEdges(scale: _scale);
finalX = _position.dx.clamp(cornersX.min, cornersX.max); finalX = _position.dx.clamp(range.min, range.max);
} }
var finalY = 0.0; var finalY = 0.0;
if (screenHeight < computedHeight) { if (screenHeight < computedHeight) {
final cornersY = this.cornersY(scale: _scale); final range = getYEdges(scale: _scale);
finalY = _position.dy.clamp(cornersY.min, cornersY.max); finalY = _position.dy.clamp(range.min, range.max);
} }
return Offset(finalX, finalY); return Offset(finalX, finalY);
@ -202,8 +202,8 @@ mixin AvesMagnifierControllerDelegate on State<MagnifierCore> {
} }
/// Simple class to store a min and a max value /// Simple class to store a min and a max value
class CornersRange { class EdgeRange {
const CornersRange(this.min, this.max); const EdgeRange(this.min, this.max);
final double min; final double min;
final double max; final double max;

View file

@ -5,11 +5,14 @@ import 'package:aves_magnifier/src/controller/controller_delegate.dart';
import 'package:aves_magnifier/src/controller/state.dart'; import 'package:aves_magnifier/src/controller/state.dart';
import 'package:aves_magnifier/src/core/gesture_detector.dart'; import 'package:aves_magnifier/src/core/gesture_detector.dart';
import 'package:aves_magnifier/src/magnifier.dart'; import 'package:aves_magnifier/src/magnifier.dart';
import 'package:aves_magnifier/src/pan/corner_hit_detector.dart'; import 'package:aves_magnifier/src/pan/edge_hit_detector.dart';
import 'package:aves_magnifier/src/scale/scale_boundaries.dart'; import 'package:aves_magnifier/src/scale/scale_boundaries.dart';
import 'package:aves_magnifier/src/scale/state.dart'; import 'package:aves_magnifier/src/scale/state.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
/// Internal widget in which controls all animations lifecycle, core responses /// Internal widget in which controls all animations lifecycle, core responses
/// to user gestures, updates to the controller state and mounts the entire Layout /// to user gestures, updates to the controller state and mounts the entire Layout
@ -21,6 +24,7 @@ class MagnifierCore extends StatefulWidget {
final MagnifierGestureScaleStartCallback? onScaleStart; final MagnifierGestureScaleStartCallback? onScaleStart;
final MagnifierGestureScaleUpdateCallback? onScaleUpdate; final MagnifierGestureScaleUpdateCallback? onScaleUpdate;
final MagnifierGestureScaleEndCallback? onScaleEnd; final MagnifierGestureScaleEndCallback? onScaleEnd;
final MagnifierGestureFlingCallback? onFling;
final MagnifierTapCallback? onTap; final MagnifierTapCallback? onTap;
final MagnifierDoubleTapCallback? onDoubleTap; final MagnifierDoubleTapCallback? onDoubleTap;
final Widget child; final Widget child;
@ -34,6 +38,7 @@ class MagnifierCore extends StatefulWidget {
this.onScaleStart, this.onScaleStart,
this.onScaleUpdate, this.onScaleUpdate,
this.onScaleEnd, this.onScaleEnd,
this.onFling,
this.onTap, this.onTap,
this.onDoubleTap, this.onDoubleTap,
required this.child, required this.child,
@ -43,7 +48,7 @@ class MagnifierCore extends StatefulWidget {
State<StatefulWidget> createState() => _MagnifierCoreState(); State<StatefulWidget> createState() => _MagnifierCoreState();
} }
class _MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateMixin, AvesMagnifierControllerDelegate, CornerHitDetector { class _MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateMixin, AvesMagnifierControllerDelegate, EdgeHitDetector {
Offset? _startFocalPoint, _lastViewportFocalPosition; Offset? _startFocalPoint, _lastViewportFocalPosition;
double? _startScale, _quickScaleLastY, _quickScaleLastDistance; double? _startScale, _quickScaleLastY, _quickScaleLastDistance;
late bool _dropped, _doubleTap, _quickScaleMoved; late bool _dropped, _doubleTap, _quickScaleMoved;
@ -57,6 +62,8 @@ class _MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateM
ScaleBoundaries? cachedScaleBoundaries; ScaleBoundaries? cachedScaleBoundaries;
static const _flingPointerKind = PointerDeviceKind.unknown;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -104,12 +111,21 @@ class _MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateM
controller.update(position: _positionAnimation.value, source: ChangeSource.animation); controller.update(position: _positionAnimation.value, source: ChangeSource.animation);
} }
Stopwatch? _scaleStopwatch;
VelocityTracker? _velocityTracker;
var _mayFlingLTRB = const Tuple4(false, false, false, false);
void onScaleStart(ScaleStartDetails details, bool doubleTap) { void onScaleStart(ScaleStartDetails details, bool doubleTap) {
final boundaries = scaleBoundaries; final boundaries = scaleBoundaries;
if (boundaries == null) return; if (boundaries == null) return;
widget.onScaleStart?.call(details, doubleTap, boundaries); widget.onScaleStart?.call(details, doubleTap, boundaries);
_scaleStopwatch = Stopwatch()..start();
_velocityTracker = VelocityTracker.withKind(_flingPointerKind);
_mayFlingLTRB = const Tuple4(true, true, true, true);
_updateMayFling();
_startScale = scale; _startScale = scale;
_startFocalPoint = details.localFocalPoint; _startFocalPoint = details.localFocalPoint;
_lastViewportFocalPosition = _startFocalPoint; _lastViewportFocalPosition = _startFocalPoint;
@ -130,6 +146,12 @@ class _MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateM
_dropped |= widget.onScaleUpdate?.call(details) ?? false; _dropped |= widget.onScaleUpdate?.call(details) ?? false;
if (_dropped) return; if (_dropped) return;
final elapsed = _scaleStopwatch?.elapsed;
if (elapsed != null) {
_velocityTracker?.addPosition(elapsed, details.focalPoint);
}
_updateMayFling();
double newScale; double newScale;
if (_doubleTap) { if (_doubleTap) {
// quick scale, aka one finger zoom // quick scale, aka one finger zoom
@ -168,6 +190,29 @@ class _MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateM
widget.onScaleEnd?.call(details); widget.onScaleEnd?.call(details);
_updateMayFling();
final estimate = _velocityTracker?.getVelocityEstimate();
final onFling = widget.onFling;
if (estimate != null && onFling != null) {
if (_isFlingGesture(estimate, _flingPointerKind, Axis.horizontal)) {
final left = _mayFlingLTRB.item1;
final right = _mayFlingLTRB.item3;
if (left) {
onFling(AxisDirection.left);
} else if (right) {
onFling(AxisDirection.right);
}
} else if (_isFlingGesture(estimate, _flingPointerKind, Axis.vertical)) {
final up = _mayFlingLTRB.item2;
final down = _mayFlingLTRB.item4;
if (up) {
onFling(AxisDirection.up);
} else if (down) {
onFling(AxisDirection.down);
}
}
}
final _position = controller.position; final _position = controller.position;
final _scale = controller.scale!; final _scale = controller.scale!;
final maxScale = boundaries.maxScale; final maxScale = boundaries.maxScale;
@ -208,6 +253,31 @@ class _MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateM
} }
} }
void _updateMayFling() {
final xHit = getXEdgeHit();
final yHit = getYEdgeHit();
_mayFlingLTRB = Tuple4(
_mayFlingLTRB.item1 && xHit.hasHitMin,
_mayFlingLTRB.item2 && yHit.hasHitMin,
_mayFlingLTRB.item3 && xHit.hasHitMax,
_mayFlingLTRB.item4 && yHit.hasHitMax,
);
}
bool _isFlingGesture(VelocityEstimate estimate, PointerDeviceKind kind, Axis axis) {
final gestureSettings = context.read<MediaQueryData>().gestureSettings;
const minVelocity = kMinFlingVelocity;
final minDistance = computeHitSlop(kind, gestureSettings);
final pps = estimate.pixelsPerSecond;
final offset = estimate.offset;
if (axis == Axis.horizontal) {
return pps.dx.abs() > minVelocity && offset.dx.abs() > minDistance;
} else {
return pps.dy.abs() > minVelocity && offset.dy.abs() > minDistance;
}
}
Duration _getAnimationDurationForVelocity({ Duration _getAnimationDurationForVelocity({
required Cubic curve, required Cubic curve,
required Tween<Offset> tween, required Tween<Offset> tween,

View file

@ -1,5 +1,5 @@
import 'package:aves_magnifier/src/core/scale_gesture_recognizer.dart'; import 'package:aves_magnifier/src/core/scale_gesture_recognizer.dart';
import 'package:aves_magnifier/src/pan/corner_hit_detector.dart'; import 'package:aves_magnifier/src/pan/edge_hit_detector.dart';
import 'package:aves_magnifier/src/pan/gesture_detector_scope.dart'; import 'package:aves_magnifier/src/pan/gesture_detector_scope.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -19,7 +19,7 @@ class MagnifierGestureDetector extends StatefulWidget {
this.child, this.child,
}); });
final CornerHitDetector hitDetector; final EdgeHitDetector hitDetector;
final void Function(ScaleStartDetails details, bool doubleTap)? onScaleStart; final void Function(ScaleStartDetails details, bool doubleTap)? onScaleStart;
final GestureScaleUpdateCallback? onScaleUpdate; final GestureScaleUpdateCallback? onScaleUpdate;
final GestureScaleEndCallback? onScaleEnd; final GestureScaleEndCallback? onScaleEnd;

View file

@ -1,12 +1,12 @@
import 'dart:math'; import 'dart:math';
import 'package:aves_magnifier/aves_magnifier.dart'; import 'package:aves_magnifier/aves_magnifier.dart';
import 'package:aves_magnifier/src/pan/corner_hit_detector.dart'; import 'package:aves_magnifier/src/pan/edge_hit_detector.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class MagnifierGestureRecognizer extends ScaleGestureRecognizer { class MagnifierGestureRecognizer extends ScaleGestureRecognizer {
final CornerHitDetector hitDetector; final EdgeHitDetector hitDetector;
final MagnifierGestureDetectorScope scope; final MagnifierGestureDetectorScope scope;
final ValueNotifier<TapDownDetails?> doubleTapDetails; final ValueNotifier<TapDownDetails?> doubleTapDetails;
@ -104,14 +104,15 @@ class MagnifierGestureRecognizer extends ScaleGestureRecognizer {
} }
final validateAxis = scope.axis; final validateAxis = scope.axis;
final canFling = scope.escapeByFling;
final move = _initialFocalPoint! - _currentFocalPoint!; final move = _initialFocalPoint! - _currentFocalPoint!;
bool shouldMove = scope.acceptPointerEvent?.call(move) ?? false; bool shouldMove = scope.acceptPointerEvent?.call(move) ?? false;
if (!shouldMove) { if (!shouldMove) {
if (validateAxis.length == 2) { if (validateAxis.length == 2) {
// the image is the descendant of gesture detector(s) handling drag in both directions // the image is the descendant of gesture detector(s) handling drag in both directions
final shouldMoveX = validateAxis.contains(Axis.horizontal) && hitDetector.shouldMoveX(move); final shouldMoveX = validateAxis.contains(Axis.horizontal) && hitDetector.shouldMoveX(move, canFling);
final shouldMoveY = validateAxis.contains(Axis.vertical) && hitDetector.shouldMoveY(move); final shouldMoveY = validateAxis.contains(Axis.vertical) && hitDetector.shouldMoveY(move, canFling);
if (shouldMoveX == shouldMoveY) { if (shouldMoveX == shouldMoveY) {
// consistently can/cannot pan the image in both direction the same way // consistently can/cannot pan the image in both direction the same way
shouldMove = shouldMoveX; shouldMove = shouldMoveX;
@ -122,7 +123,7 @@ class MagnifierGestureRecognizer extends ScaleGestureRecognizer {
} }
} else { } else {
// the image is the descendant of a gesture detector handling drag in one direction // the image is the descendant of a gesture detector handling drag in one direction
shouldMove = validateAxis.contains(Axis.vertical) ? hitDetector.shouldMoveY(move) : hitDetector.shouldMoveX(move); shouldMove = validateAxis.contains(Axis.vertical) ? hitDetector.shouldMoveY(move, canFling) : hitDetector.shouldMoveX(move, canFling);
} }
} }

View file

@ -32,6 +32,7 @@ class AvesMagnifier extends StatelessWidget {
this.onScaleStart, this.onScaleStart,
this.onScaleUpdate, this.onScaleUpdate,
this.onScaleEnd, this.onScaleEnd,
this.onFling,
this.onTap, this.onTap,
this.onDoubleTap, this.onDoubleTap,
required this.child, required this.child,
@ -58,6 +59,7 @@ class AvesMagnifier extends StatelessWidget {
final MagnifierGestureScaleStartCallback? onScaleStart; final MagnifierGestureScaleStartCallback? onScaleStart;
final MagnifierGestureScaleUpdateCallback? onScaleUpdate; final MagnifierGestureScaleUpdateCallback? onScaleUpdate;
final MagnifierGestureScaleEndCallback? onScaleEnd; final MagnifierGestureScaleEndCallback? onScaleEnd;
final MagnifierGestureFlingCallback? onFling;
final MagnifierTapCallback? onTap; final MagnifierTapCallback? onTap;
final MagnifierDoubleTapCallback? onDoubleTap; final MagnifierDoubleTapCallback? onDoubleTap;
final Widget child; final Widget child;
@ -82,6 +84,7 @@ class AvesMagnifier extends StatelessWidget {
onScaleStart: onScaleStart, onScaleStart: onScaleStart,
onScaleUpdate: onScaleUpdate, onScaleUpdate: onScaleUpdate,
onScaleEnd: onScaleEnd, onScaleEnd: onScaleEnd,
onFling: onFling,
onTap: onTap, onTap: onTap,
onDoubleTap: onDoubleTap, onDoubleTap: onDoubleTap,
child: child, child: child,
@ -101,3 +104,4 @@ typedef MagnifierDoubleTapCallback = bool Function(Alignment alignment);
typedef MagnifierGestureScaleStartCallback = void Function(ScaleStartDetails details, bool doubleTap, ScaleBoundaries boundaries); typedef MagnifierGestureScaleStartCallback = void Function(ScaleStartDetails details, bool doubleTap, ScaleBoundaries boundaries);
typedef MagnifierGestureScaleUpdateCallback = bool Function(ScaleUpdateDetails details); typedef MagnifierGestureScaleUpdateCallback = bool Function(ScaleUpdateDetails details);
typedef MagnifierGestureScaleEndCallback = void Function(ScaleEndDetails details); typedef MagnifierGestureScaleEndCallback = void Function(ScaleEndDetails details);
typedef MagnifierGestureFlingCallback = void Function(AxisDirection direction);

View file

@ -1,69 +0,0 @@
import 'package:aves_magnifier/src/controller/controller_delegate.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
mixin CornerHitDetector on AvesMagnifierControllerDelegate {
// the child width/height is not accurate for some image size & scale combos
// e.g. 3580.0 * 0.1005586592178771 yields 360.0
// but 4764.0 * 0.07556675062972293 yields 360.00000000000006
// so be sure to compare with `precisionErrorTolerance`
_CornerHit _hitCornersX() {
final boundaries = scaleBoundaries;
if (boundaries == null) return const _CornerHit(false, false);
final childWidth = boundaries.childSize.width * scale!;
final viewportWidth = boundaries.viewportSize.width;
if (viewportWidth + precisionErrorTolerance >= childWidth) {
return const _CornerHit(true, true);
}
final x = -position.dx;
final cornersX = this.cornersX();
return _CornerHit(x <= cornersX.min, x >= cornersX.max);
}
_CornerHit _hitCornersY() {
final boundaries = scaleBoundaries;
if (boundaries == null) return const _CornerHit(false, false);
final childHeight = boundaries.childSize.height * scale!;
final viewportHeight = boundaries.viewportSize.height;
if (viewportHeight + precisionErrorTolerance >= childHeight) {
return const _CornerHit(true, true);
}
final y = -position.dy;
final cornersY = this.cornersY();
return _CornerHit(y <= cornersY.min, y >= cornersY.max);
}
bool shouldMoveX(Offset move) {
final hitCornersX = _hitCornersX();
if (hitCornersX.hasHitAny && move != Offset.zero) {
if (hitCornersX.hasHitBoth) return false;
if (hitCornersX.hasHitMax) return move.dx < 0;
return move.dx > 0;
}
return true;
}
bool shouldMoveY(Offset move) {
final hitCornersY = _hitCornersY();
if (hitCornersY.hasHitAny && move != Offset.zero) {
if (hitCornersY.hasHitBoth) return false;
if (hitCornersY.hasHitMax) return move.dy < 0;
return move.dy > 0;
}
return true;
}
}
class _CornerHit {
const _CornerHit(this.hasHitMin, this.hasHitMax);
final bool hasHitMin;
final bool hasHitMax;
bool get hasHitAny => hasHitMin || hasHitMax;
bool get hasHitBoth => hasHitMin && hasHitMax;
}

View file

@ -0,0 +1,68 @@
import 'package:aves_magnifier/src/controller/controller_delegate.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
mixin EdgeHitDetector on AvesMagnifierControllerDelegate {
// the child width/height is not accurate for some image size & scale combos
// e.g. 3580.0 * 0.1005586592178771 yields 360.0
// but 4764.0 * 0.07556675062972293 yields 360.00000000000006
// so be sure to compare with `precisionErrorTolerance`
EdgeHit getXEdgeHit() {
final boundaries = scaleBoundaries;
if (boundaries == null) return const EdgeHit(false, false);
final childWidth = boundaries.childSize.width * scale!;
final viewportWidth = boundaries.viewportSize.width;
if (viewportWidth + precisionErrorTolerance >= childWidth) {
return const EdgeHit(true, true);
}
final x = -position.dx;
final range = getXEdges();
return EdgeHit(x <= range.min, x >= range.max);
}
EdgeHit getYEdgeHit() {
final boundaries = scaleBoundaries;
if (boundaries == null) return const EdgeHit(false, false);
final childHeight = boundaries.childSize.height * scale!;
final viewportHeight = boundaries.viewportSize.height;
if (viewportHeight + precisionErrorTolerance >= childHeight) {
return const EdgeHit(true, true);
}
final y = -position.dy;
final range = getYEdges();
return EdgeHit(y <= range.min, y >= range.max);
}
bool shouldMoveX(Offset move, bool canFling) {
final hit = getXEdgeHit();
return _shouldMove(hit, move.dx) || (canFling && !hit.hasHitBoth);
}
bool shouldMoveY(Offset move, bool canFling) {
final hit = getYEdgeHit();
return _shouldMove(hit, move.dy) || (canFling && !hit.hasHitBoth);
}
bool _shouldMove(EdgeHit hit, double move) {
if (hit.hasHitAny && move != 0) {
if (hit.hasHitBoth) return false;
if (hit.hasHitMax) return move < 0;
return move > 0;
}
return true;
}
}
class EdgeHit {
const EdgeHit(this.hasHitMin, this.hasHitMax);
final bool hasHitMin;
final bool hasHitMax;
bool get hasHitAny => hasHitMin || hasHitMax;
bool get hasHitBoth => hasHitMin && hasHitMax;
}

View file

@ -14,12 +14,18 @@ class MagnifierGestureDetectorScope extends InheritedWidget {
// <1: less reactive but gives the most leeway to other recognizers // <1: less reactive but gives the most leeway to other recognizers
// 1: will not be able to compete with a `HorizontalDragGestureRecognizer` up the widget tree // 1: will not be able to compete with a `HorizontalDragGestureRecognizer` up the widget tree
final double touchSlopFactor; final double touchSlopFactor;
// when zoomed in and hitting an edge, allow using a fling gesture to go to the previous/next page,
// instead of yielding to the outer scrollable right away
final bool escapeByFling;
final bool? Function(Offset move)? acceptPointerEvent; final bool? Function(Offset move)? acceptPointerEvent;
const MagnifierGestureDetectorScope({ const MagnifierGestureDetectorScope({
super.key, super.key,
required this.axis, required this.axis,
this.touchSlopFactor = .8, this.touchSlopFactor = .8,
this.escapeByFling = true,
this.acceptPointerEvent, this.acceptPointerEvent,
required Widget child, required Widget child,
}) : super(child: child); }) : super(child: child);

View file

@ -91,6 +91,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.99" version: "0.0.99"
tuple:
dependency: "direct main"
description:
name: tuple
sha256: "0ea99cd2f9352b2586583ab2ce6489d1f95a5f6de6fb9492faaf97ae2060f0aa"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:

View file

@ -10,6 +10,7 @@ dependencies:
sdk: flutter sdk: flutter
equatable: equatable:
provider: provider:
tuple:
dev_dependencies: dev_dependencies:
flutter_lints: flutter_lints:

View file

@ -917,14 +917,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.1.0" version: "5.1.0"
pinput: pin_code_fields:
dependency: "direct main" dependency: "direct main"
description: description:
name: pinput name: pin_code_fields
sha256: e6aabd1571dde622f9b942f62ac2c80f84b0b50f95fa209a93e78f7d621e1f82 sha256: c8652519d14688f3fe2a8288d86910a46aa0b9046d728f292d3bf6067c31b4c7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.23" version: "7.4.0"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -1162,14 +1162,6 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.99" version: "0.0.99"
smart_auth:
dependency: transitive
description:
name: smart_auth
sha256: "8cfaec55b77d5930ed1666bb7ae70db5bade099bb1422401386853b400962113"
url: "https://pub.dev"
source: hosted
version: "1.0.8"
smooth_page_indicator: smooth_page_indicator:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1339,14 +1331,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.1" version: "0.3.1"
universal_platform:
dependency: transitive
description:
name: universal_platform
sha256: d315be0f6641898b280ffa34e2ddb14f3d12b1a37882557869646e0cc363d0cc
url: "https://pub.dev"
source: hosted
version: "1.0.0+1"
url_launcher: url_launcher:
dependency: "direct main" dependency: "direct main"
description: description:

View file

@ -7,7 +7,7 @@ repository: https://github.com/deckerst/aves
# - play changelog: /whatsnew/whatsnew-en-US # - play changelog: /whatsnew/whatsnew-en-US
# - izzy changelog: /fastlane/metadata/android/en-US/changelogs/XX01.txt # - izzy changelog: /fastlane/metadata/android/en-US/changelogs/XX01.txt
# - libre changelog: /fastlane/metadata/android/en-US/changelogs/XX.txt # - libre changelog: /fastlane/metadata/android/en-US/changelogs/XX.txt
version: 1.8.0+91 version: 1.8.1+92
publish_to: none publish_to: none
environment: environment:
@ -83,7 +83,7 @@ dependencies:
pdf: pdf:
percent_indicator: percent_indicator:
permission_handler: permission_handler:
pinput: pin_code_fields:
printing: printing:
proj4dart: proj4dart:
provider: provider:

View file

@ -1,4 +1,4 @@
In v1.8.0: In v1.8.1:
- Android TV support (cont'd) - Android TV support (cont'd)
- hide your secrets in vaults - hide your secrets in vaults
- enjoy the app in Basque - enjoy the app in Basque