Merge branch 'develop'
This commit is contained in:
commit
b31ad98d22
99 changed files with 2270 additions and 684 deletions
4
.github/workflows/check.yml
vendored
4
.github/workflows/check.yml
vendored
|
@ -14,8 +14,8 @@ jobs:
|
|||
steps:
|
||||
- uses: subosito/flutter-action@v1
|
||||
with:
|
||||
channel: dev
|
||||
flutter-version: '2.1.0-12.1.pre'
|
||||
channel: beta
|
||||
flutter-version: '2.1.0-12.2.pre'
|
||||
|
||||
- name: Clone the repository.
|
||||
uses: actions/checkout@v2
|
||||
|
|
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
|
@ -16,8 +16,8 @@ jobs:
|
|||
|
||||
- uses: subosito/flutter-action@v1
|
||||
with:
|
||||
channel: dev
|
||||
flutter-version: '2.1.0-12.1.pre'
|
||||
channel: beta
|
||||
flutter-version: '2.1.0-12.2.pre'
|
||||
|
||||
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
|
||||
# https://issuetracker.google.com/issues/144111441
|
||||
|
@ -50,8 +50,8 @@ jobs:
|
|||
echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
|
||||
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
|
||||
rm release.keystore.asc
|
||||
flutter build apk --bundle-sksl-path shaders_2.1.0-12.1.pre.sksl.json
|
||||
flutter build appbundle --bundle-sksl-path shaders_2.1.0-12.1.pre.sksl.json
|
||||
flutter build apk --bundle-sksl-path shaders_2.1.0-12.2.pre.sksl.json
|
||||
flutter build appbundle --bundle-sksl-path shaders_2.1.0-12.2.pre.sksl.json
|
||||
rm $AVES_STORE_FILE
|
||||
env:
|
||||
AVES_STORE_FILE: ${{ github.workspace }}/key.jks
|
||||
|
|
15
CHANGELOG.md
15
CHANGELOG.md
|
@ -3,6 +3,21 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v1.3.7] - 2021-04-02
|
||||
### Added
|
||||
- Collection / Albums / Countries / Tags: added label when dragging scrollbar thumb
|
||||
- Albums: localized common album names
|
||||
- Collection: select shortcut icon image
|
||||
- Settings: custom viewer quick actions
|
||||
- Settings: option to hide videos from collection
|
||||
|
||||
### Changed
|
||||
- Upgraded Flutter to beta v2.1.0-12.2.pre
|
||||
|
||||
### Fixed
|
||||
- opening media shared by other apps as file media content
|
||||
- navigation stack when opening media shared by other apps
|
||||
|
||||
## [v1.3.6] - 2021-03-18
|
||||
### Added
|
||||
- Korean translation
|
||||
|
|
|
@ -112,9 +112,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
|
||||
else -> uri
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
contentUri = MediaStore.setRequireOriginal(contentUri)
|
||||
}
|
||||
contentUri = StorageUtils.getOriginalUri(contentUri)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,7 +8,6 @@ import android.media.MediaExtractor
|
|||
import android.media.MediaFormat
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
|
@ -684,9 +683,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
|
||||
else -> uri
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
contentUri = MediaStore.setRequireOriginal(contentUri)
|
||||
}
|
||||
contentUri = StorageUtils.getOriginalUri(contentUri)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -29,6 +29,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
"getInaccessibleDirectories" -> safe(call, result, ::getInaccessibleDirectories)
|
||||
"getRestrictedDirectories" -> safe(call, result, ::getRestrictedDirectories)
|
||||
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
|
||||
"deleteEmptyDirectories" -> safe(call, result, ::deleteEmptyDirectories)
|
||||
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
|
@ -136,6 +137,27 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
result.success(success)
|
||||
}
|
||||
|
||||
private fun deleteEmptyDirectories(call: MethodCall, result: MethodChannel.Result) {
|
||||
val dirPaths = call.argument<List<String>>("dirPaths")
|
||||
if (dirPaths == null) {
|
||||
result.error("deleteEmptyDirectories-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
var deleted = 0
|
||||
dirPaths.forEach {
|
||||
try {
|
||||
val dir = File(it)
|
||||
if (dir.isDirectory && dir.listFiles()?.isEmpty() == true && dir.delete()) {
|
||||
deleted++
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
result.success(deleted)
|
||||
}
|
||||
|
||||
private fun scanFile(call: MethodCall, result: MethodChannel.Result) {
|
||||
val path = call.argument<String>("path")
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
|
|
|
@ -293,9 +293,13 @@ object StorageUtils {
|
|||
// need a document URI (not a media content URI) to open a `DocumentFile` output stream
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isMediaStoreContentUri(mediaUri)) {
|
||||
// cleanest API to get it
|
||||
val docUri = MediaStore.getDocumentUri(context, mediaUri)
|
||||
if (docUri != null) {
|
||||
return DocumentFileCompat.fromSingleUri(context, docUri)
|
||||
try {
|
||||
val docUri = MediaStore.getDocumentUri(context, mediaUri)
|
||||
if (docUri != null) {
|
||||
return DocumentFileCompat.fromSingleUri(context, docUri)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get document URI for mediaUri=$mediaUri", e)
|
||||
}
|
||||
}
|
||||
// fallback for older APIs
|
||||
|
@ -401,13 +405,21 @@ object StorageUtils {
|
|||
return ContentResolver.SCHEME_CONTENT.equals(uri.scheme, ignoreCase = true) && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)
|
||||
}
|
||||
|
||||
fun openInputStream(context: Context, uri: Uri): InputStream? {
|
||||
var effectiveUri = uri
|
||||
fun getOriginalUri(uri: Uri): Uri {
|
||||
// we get a permission denial if we require original from a provider other than the media store
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
|
||||
effectiveUri = MediaStore.setRequireOriginal(uri)
|
||||
val path = uri.path
|
||||
path ?: return uri
|
||||
// from Android R, accessing the original URI for a file media content yields a `SecurityException`
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || path.startsWith("/external/images/") || path.startsWith("/external/video/")) {
|
||||
return MediaStore.setRequireOriginal(uri)
|
||||
}
|
||||
}
|
||||
return uri
|
||||
}
|
||||
|
||||
fun openInputStream(context: Context, uri: Uri): InputStream? {
|
||||
val effectiveUri = getOriginalUri(uri)
|
||||
return try {
|
||||
context.contentResolver.openInputStream(effectiveUri)
|
||||
} catch (e: FileNotFoundException) {
|
||||
|
@ -420,12 +432,7 @@ object StorageUtils {
|
|||
}
|
||||
|
||||
fun openMetadataRetriever(context: Context, uri: Uri): MediaMetadataRetriever? {
|
||||
var effectiveUri = uri
|
||||
// we get a permission denial if we require original from a provider other than the media store
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
|
||||
effectiveUri = MediaStore.setRequireOriginal(uri)
|
||||
}
|
||||
|
||||
val effectiveUri = getOriginalUri(uri)
|
||||
return try {
|
||||
MediaMetadataRetriever().apply {
|
||||
setDataSource(context, effectiveUri)
|
||||
|
|
|
@ -12,7 +12,7 @@ buildscript {
|
|||
}
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:4.1.2'
|
||||
classpath 'com.android.tools.build:gradle:4.1.3'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath 'com.google.gms:google-services:4.3.5'
|
||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.5.1'
|
||||
|
|
|
@ -7,6 +7,12 @@
|
|||
"@welcomeAnalyticsToggle": {},
|
||||
"welcomeTermsToggle": "I agree to the terms and conditions",
|
||||
"@welcomeTermsToggle": {},
|
||||
"itemCount": "{count, plural, =1{1 item} other{{count} items}}",
|
||||
"@itemCount": {
|
||||
"placeholders": {
|
||||
"count": {}
|
||||
}
|
||||
},
|
||||
|
||||
"applyButtonLabel": "APPLY",
|
||||
"@applyButtonLabel": {},
|
||||
|
@ -284,12 +290,6 @@
|
|||
"@aboutLicenses": {},
|
||||
"aboutLicensesBanner": "This app uses the following open-source packages and libraries.",
|
||||
"@aboutLicensesBanner": {},
|
||||
"aboutLicensesSortTooltip": "Sort",
|
||||
"@aboutLicensesSortTooltip": {},
|
||||
"aboutLicensesSortByName": "Sort by name",
|
||||
"@aboutLicensesSortByName": {},
|
||||
"aboutLicensesSortByLicense": "Sort by license",
|
||||
"@aboutLicensesSortByLicense": {},
|
||||
"aboutLicensesAndroidLibraries": "Android Libraries",
|
||||
"@aboutLicensesAndroidLibraries": {},
|
||||
"aboutLicensesFlutterPlugins": "Flutter Plugins",
|
||||
|
@ -444,6 +444,15 @@
|
|||
"albumPickPageTitleMove": "Move to Album",
|
||||
"@albumPickPageTitleMove": {},
|
||||
|
||||
"albumCamera": "Camera",
|
||||
"@albumCamera": {},
|
||||
"albumDownload": "Download",
|
||||
"@albumDownload": {},
|
||||
"albumScreenshots": "Screenshots",
|
||||
"@albumScreenshots": {},
|
||||
"albumScreenRecordings": "Screen recordings",
|
||||
"@albumScreenRecordings": {},
|
||||
|
||||
"albumPageTitle": "Albums",
|
||||
"@albumPageTitle": {},
|
||||
"albumEmpty": "No albums",
|
||||
|
@ -485,25 +494,12 @@
|
|||
"@settingsSectionNavigation": {},
|
||||
"settingsHome": "Home",
|
||||
"@settingsHome": {},
|
||||
"settingsDoubleBackExit": "Tap “back” twice to exit",
|
||||
"@settingsDoubleBackExit": {},
|
||||
|
||||
"settingsSectionDisplay": "Display",
|
||||
"@settingsSectionDisplay": {},
|
||||
"settingsLanguage": "Language",
|
||||
"@settingsLanguage": {},
|
||||
"settingsKeepScreenOnTile": "Keep screen on",
|
||||
"@settingsKeepScreenOnTile": {},
|
||||
"settingsKeepScreenOnTitle": "Keep Screen On",
|
||||
"@settingsKeepScreenOnTitle": {},
|
||||
"settingsRasterImageBackground": "Raster image background",
|
||||
"@settingsRasterImageBackground": {},
|
||||
"settingsVectorImageBackground": "Vector image background",
|
||||
"@settingsVectorImageBackground": {},
|
||||
"settingsCoordinateFormatTile": "Coordinate format",
|
||||
"@settingsCoordinateFormatTile": {},
|
||||
"settingsCoordinateFormatTitle": "Coordinate Format",
|
||||
"@settingsCoordinateFormatTitle": {},
|
||||
"settingsDoubleBackExit": "Tap “back” twice to exit",
|
||||
"@settingsDoubleBackExit": {},
|
||||
|
||||
"settingsSectionThumbnails": "Thumbnails",
|
||||
"@settingsSectionThumbnails": {},
|
||||
|
@ -516,6 +512,10 @@
|
|||
|
||||
"settingsSectionViewer": "Viewer",
|
||||
"@settingsSectionViewer": {},
|
||||
"settingsRasterImageBackground": "Raster image background",
|
||||
"@settingsRasterImageBackground": {},
|
||||
"settingsVectorImageBackground": "Vector image background",
|
||||
"@settingsVectorImageBackground": {},
|
||||
"settingsViewerShowMinimap": "Show minimap",
|
||||
"@settingsViewerShowMinimap": {},
|
||||
"settingsViewerShowInformation": "Show information",
|
||||
|
@ -525,15 +525,30 @@
|
|||
"settingsViewerShowShootingDetails": "Show shooting details",
|
||||
"@settingsViewerShowShootingDetails": {},
|
||||
|
||||
"settingsSectionSearch": "Search",
|
||||
"@settingsSectionSearch": {},
|
||||
"settingsSaveSearchHistory": "Save search history",
|
||||
"@settingsSaveSearchHistory": {},
|
||||
"settingsViewerQuickActionsTile": "Quick actions",
|
||||
"@settingsViewerQuickActionsTile": {},
|
||||
"settingsViewerQuickActionEditorTitle": "Quick Actions",
|
||||
"@settingsViewerQuickActionEditorTitle": {},
|
||||
"settingsViewerQuickActionEditorBanner": "Touch and hold to move buttons and select which actions are displayed in the viewer.",
|
||||
"@settingsViewerQuickActionEditorBanner": {},
|
||||
"settingsViewerQuickActionEditorDisplayedButtons": "Displayed Buttons",
|
||||
"@settingsViewerQuickActionEditorDisplayedButtons": {},
|
||||
"settingsViewerQuickActionEditorAvailableButtons": "Available Buttons",
|
||||
"@settingsViewerQuickActionEditorAvailableButtons": {},
|
||||
"settingsViewerQuickActionEmpty": "No buttons",
|
||||
"@settingsViewerQuickActionEmpty": {},
|
||||
|
||||
"settingsSectionVideo": "Video",
|
||||
"@settingsSectionVideo": {},
|
||||
"settingsVideoShowVideos": "Show videos",
|
||||
"@settingsVideoShowVideos": {},
|
||||
|
||||
"settingsSectionPrivacy": "Privacy",
|
||||
"@settingsSectionPrivacy": {},
|
||||
"settingsEnableAnalytics": "Allow anonymous analytics and crash reporting",
|
||||
"@settingsEnableAnalytics": {},
|
||||
"settingsSaveSearchHistory": "Save search history",
|
||||
"@settingsSaveSearchHistory": {},
|
||||
|
||||
"settingsHiddenFiltersTile": "Hidden filters",
|
||||
"@settingsHiddenFiltersTile": {},
|
||||
|
@ -555,6 +570,15 @@
|
|||
"settingsStorageAccessRevokeTooltip": "Revoke",
|
||||
"@settingsStorageAccessRevokeTooltip": {},
|
||||
|
||||
"settingsSectionLanguage": "Language & Formats",
|
||||
"@settingsSectionLanguage": {},
|
||||
"settingsLanguage": "Language",
|
||||
"@settingsLanguage": {},
|
||||
"settingsCoordinateFormatTile": "Coordinate format",
|
||||
"@settingsCoordinateFormatTile": {},
|
||||
"settingsCoordinateFormatTitle": "Coordinate Format",
|
||||
"@settingsCoordinateFormatTitle": {},
|
||||
|
||||
"statsPageTitle": "Stats",
|
||||
"@statsPageTitle": {},
|
||||
"statsImage": "{count, plural, =1{image} other{images}}",
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
"welcomeMessage": "아베스 사용을 환영합니다",
|
||||
"welcomeAnalyticsToggle": "진단 데이터를 보내는 것에 동의합니다 (선택)",
|
||||
"welcomeTermsToggle": "이용약관에 동의합니다",
|
||||
"itemCount": "{count, plural, other{{count}개}}",
|
||||
|
||||
"applyButtonLabel": "확인",
|
||||
"deleteButtonLabel": "삭제",
|
||||
|
@ -131,9 +132,6 @@
|
|||
"aboutCreditsWorldAtlas2": "의 TopoJSON 파일(ISC 라이선스)을 이용합니다.",
|
||||
"aboutLicenses": "오픈 소스 라이선스",
|
||||
"aboutLicensesBanner": "이 앱은 다음의 오픈 소스 패키지와 라이브러리를 이용합니다.",
|
||||
"aboutLicensesSortTooltip": "정렬",
|
||||
"aboutLicensesSortByName": "이름순 정렬",
|
||||
"aboutLicensesSortByLicense": "라이선스순 정렬",
|
||||
"aboutLicensesAndroidLibraries": "안드로이드 라이브러리",
|
||||
"aboutLicensesFlutterPlugins": "플러터 플러그인",
|
||||
"aboutLicensesFlutterPackages": "플러터 패키지",
|
||||
|
@ -200,6 +198,11 @@
|
|||
"albumPickPageTitleExport": "앨범으로 내보내기",
|
||||
"albumPickPageTitleMove": "앨범으로 이동",
|
||||
|
||||
"albumCamera": "카메라",
|
||||
"albumDownload": "다운로드",
|
||||
"albumScreenshots": "스크린샷",
|
||||
"albumScreenRecordings": "화면 녹화 파일",
|
||||
|
||||
"albumPageTitle": "앨범",
|
||||
"albumEmpty": "앨범이 없습니다",
|
||||
"createAlbumTooltip": "새 앨범 만들기",
|
||||
|
@ -223,16 +226,9 @@
|
|||
|
||||
"settingsSectionNavigation": "탐색",
|
||||
"settingsHome": "홈",
|
||||
"settingsDoubleBackExit": "뒤로가기 두번 눌러 앱 종료하기",
|
||||
|
||||
"settingsSectionDisplay": "디스플레이",
|
||||
"settingsLanguage": "언어",
|
||||
"settingsKeepScreenOnTile": "화면 자동 꺼짐 방지",
|
||||
"settingsKeepScreenOnTitle": "화면 자동 꺼짐 방지",
|
||||
"settingsRasterImageBackground": "래스터 그래픽스 배경",
|
||||
"settingsVectorImageBackground": "벡터 그래픽스 배경",
|
||||
"settingsCoordinateFormatTile": "좌표 표현",
|
||||
"settingsCoordinateFormatTitle": "좌표 표현",
|
||||
"settingsDoubleBackExit": "뒤로가기 두번 눌러 앱 종료하기",
|
||||
|
||||
"settingsSectionThumbnails": "섬네일",
|
||||
"settingsThumbnailShowLocationIcon": "위치 아이콘 표시",
|
||||
|
@ -240,16 +236,26 @@
|
|||
"settingsThumbnailShowVideoDuration": "동영상 길이 표시",
|
||||
|
||||
"settingsSectionViewer": "뷰어",
|
||||
"settingsRasterImageBackground": "래스터 그래픽스 배경",
|
||||
"settingsVectorImageBackground": "벡터 그래픽스 배경",
|
||||
"settingsViewerShowMinimap": "미니맵 표시",
|
||||
"settingsViewerShowInformation": "상세 정보 표시",
|
||||
"settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시",
|
||||
"settingsViewerShowShootingDetails": "촬영 정보 표시",
|
||||
|
||||
"settingsSectionSearch": "검색",
|
||||
"settingsSaveSearchHistory": "검색기록",
|
||||
"settingsViewerQuickActionsTile": "빠른 작업",
|
||||
"settingsViewerQuickActionEditorTitle": "빠른 작업",
|
||||
"settingsViewerQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 뷰어에 표시될 버튼을 선택하세요.",
|
||||
"settingsViewerQuickActionEditorDisplayedButtons": "표시될 버튼",
|
||||
"settingsViewerQuickActionEditorAvailableButtons": "추가 가능한 버튼",
|
||||
"settingsViewerQuickActionEmpty": "버튼이 없습니다",
|
||||
|
||||
"settingsSectionVideo": "동영상",
|
||||
"settingsVideoShowVideos": "미디어에 동영상 표시",
|
||||
|
||||
"settingsSectionPrivacy": "개인정보 보호",
|
||||
"settingsEnableAnalytics": "진단 데이터 보내기",
|
||||
"settingsSaveSearchHistory": "검색기록",
|
||||
|
||||
"settingsHiddenFiltersTile": "숨겨진 필터",
|
||||
"settingsHiddenFiltersTitle": "숨겨진 필터",
|
||||
|
@ -262,6 +268,11 @@
|
|||
"settingsStorageAccessEmpty": "접근 허용이 없습니다",
|
||||
"settingsStorageAccessRevokeTooltip": "취소",
|
||||
|
||||
"settingsSectionLanguage": "언어 및 표시 형식",
|
||||
"settingsLanguage": "언어",
|
||||
"settingsCoordinateFormatTile": "좌표 표현",
|
||||
"settingsCoordinateFormatTitle": "좌표 표현",
|
||||
|
||||
"statsPageTitle": "통계",
|
||||
"statsImage": "{count, plural, other{사진}}",
|
||||
"statsVideo": "{count, plural, other{동영상}}",
|
||||
|
|
|
@ -52,7 +52,7 @@ extension ExtraEntryAction on EntryAction {
|
|||
// in app actions
|
||||
case EntryAction.toggleFavourite:
|
||||
// different data depending on toggle state
|
||||
return null;
|
||||
return context.l10n.entryActionAddFavourite;
|
||||
case EntryAction.delete:
|
||||
return context.l10n.entryActionDelete;
|
||||
case EntryAction.export:
|
||||
|
@ -93,7 +93,7 @@ extension ExtraEntryAction on EntryAction {
|
|||
// in app actions
|
||||
case EntryAction.toggleFavourite:
|
||||
// different data depending on toggle state
|
||||
return null;
|
||||
return AIcons.favourite;
|
||||
case EntryAction.delete:
|
||||
return AIcons.delete;
|
||||
case EntryAction.export:
|
||||
|
|
|
@ -18,7 +18,6 @@ import 'package:country_code/country_code.dart';
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong/latlong.dart';
|
||||
import 'package:path/path.dart' as ppath;
|
||||
|
||||
import '../ref/mime_types.dart';
|
||||
|
||||
|
@ -186,17 +185,17 @@ class AvesEntry {
|
|||
String get path => _path;
|
||||
|
||||
String get directory {
|
||||
_directory ??= path != null ? ppath.dirname(path) : null;
|
||||
_directory ??= path != null ? pContext.dirname(path) : null;
|
||||
return _directory;
|
||||
}
|
||||
|
||||
String get filenameWithoutExtension {
|
||||
_filename ??= path != null ? ppath.basenameWithoutExtension(path) : null;
|
||||
_filename ??= path != null ? pContext.basenameWithoutExtension(path) : null;
|
||||
return _filename;
|
||||
}
|
||||
|
||||
String get extension {
|
||||
_extension ??= path != null ? ppath.extension(path) : null;
|
||||
_extension ??= path != null ? pContext.extension(path) : null;
|
||||
return _extension;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import 'package:aves/image_providers/app_icon_image_provider.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_icons.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
class AlbumFilter extends CollectionFilter {
|
||||
static const type = 'album';
|
||||
|
@ -14,9 +14,9 @@ class AlbumFilter extends CollectionFilter {
|
|||
static final Map<String, Color> _appColors = {};
|
||||
|
||||
final String album;
|
||||
final String uniqueName;
|
||||
final String displayName;
|
||||
|
||||
const AlbumFilter(this.album, this.uniqueName);
|
||||
const AlbumFilter(this.album, this.displayName);
|
||||
|
||||
AlbumFilter.fromMap(Map<String, dynamic> json)
|
||||
: this(
|
||||
|
@ -28,14 +28,14 @@ class AlbumFilter extends CollectionFilter {
|
|||
Map<String, dynamic> toMap() => {
|
||||
'type': type,
|
||||
'album': album,
|
||||
'uniqueName': uniqueName,
|
||||
'uniqueName': displayName,
|
||||
};
|
||||
|
||||
@override
|
||||
EntryFilter get test => (entry) => entry.directory == album;
|
||||
|
||||
@override
|
||||
String get universalLabel => uniqueName ?? album.split(separator).last;
|
||||
String get universalLabel => displayName ?? pContext.split(album).last;
|
||||
|
||||
@override
|
||||
String getTooltip(BuildContext context) => album;
|
||||
|
|
|
@ -8,7 +8,9 @@ import 'package:flutter/widgets.dart';
|
|||
class FavouriteFilter extends CollectionFilter {
|
||||
static const type = 'favourite';
|
||||
|
||||
const FavouriteFilter();
|
||||
static const instance = FavouriteFilter._private();
|
||||
|
||||
const FavouriteFilter._private();
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
|
|
|
@ -31,7 +31,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
|||
case AlbumFilter.type:
|
||||
return AlbumFilter.fromMap(jsonMap);
|
||||
case FavouriteFilter.type:
|
||||
return FavouriteFilter();
|
||||
return FavouriteFilter.instance;
|
||||
case LocationFilter.type:
|
||||
return LocationFilter.fromMap(jsonMap);
|
||||
case TypeFilter.type:
|
||||
|
|
|
@ -14,6 +14,9 @@ class MimeFilter extends CollectionFilter {
|
|||
String _label;
|
||||
IconData _icon;
|
||||
|
||||
static final image = MimeFilter(MimeTypes.anyImage);
|
||||
static final video = MimeFilter(MimeTypes.anyVideo);
|
||||
|
||||
MimeFilter(this.mime) {
|
||||
var lowMime = mime.toLowerCase();
|
||||
if (lowMime.endsWith('/*')) {
|
||||
|
|
|
@ -7,30 +7,35 @@ import 'package:flutter/widgets.dart';
|
|||
class TypeFilter extends CollectionFilter {
|
||||
static const type = 'type';
|
||||
|
||||
static const animated = 'animated'; // subset of `image/gif` and `image/webp`
|
||||
static const geotiff = 'geotiff'; // subset of `image/tiff`
|
||||
static const panorama = 'panorama'; // subset of images
|
||||
static const sphericalVideo = 'spherical_video'; // subset of videos
|
||||
static const _animated = 'animated'; // subset of `image/gif` and `image/webp`
|
||||
static const _geotiff = 'geotiff'; // subset of `image/tiff`
|
||||
static const _panorama = 'panorama'; // subset of images
|
||||
static const _sphericalVideo = 'spherical_video'; // subset of videos
|
||||
|
||||
final String itemType;
|
||||
EntryFilter _test;
|
||||
IconData _icon;
|
||||
|
||||
TypeFilter(this.itemType) {
|
||||
static final animated = TypeFilter._private(_animated);
|
||||
static final geotiff = TypeFilter._private(_geotiff);
|
||||
static final panorama = TypeFilter._private(_panorama);
|
||||
static final sphericalVideo = TypeFilter._private(_sphericalVideo);
|
||||
|
||||
TypeFilter._private(this.itemType) {
|
||||
switch (itemType) {
|
||||
case animated:
|
||||
case _animated:
|
||||
_test = (entry) => entry.isAnimated;
|
||||
_icon = AIcons.animated;
|
||||
break;
|
||||
case panorama:
|
||||
case _panorama:
|
||||
_test = (entry) => entry.isImage && entry.is360;
|
||||
_icon = AIcons.threesixty;
|
||||
break;
|
||||
case sphericalVideo:
|
||||
case _sphericalVideo:
|
||||
_test = (entry) => entry.isVideo && entry.is360;
|
||||
_icon = AIcons.threesixty;
|
||||
break;
|
||||
case geotiff:
|
||||
case _geotiff:
|
||||
_test = (entry) => entry.isGeotiff;
|
||||
_icon = AIcons.geo;
|
||||
break;
|
||||
|
@ -38,7 +43,7 @@ class TypeFilter extends CollectionFilter {
|
|||
}
|
||||
|
||||
TypeFilter.fromMap(Map<String, dynamic> json)
|
||||
: this(
|
||||
: this._private(
|
||||
json['itemType'],
|
||||
);
|
||||
|
||||
|
@ -57,13 +62,13 @@ class TypeFilter extends CollectionFilter {
|
|||
@override
|
||||
String getLabel(BuildContext context) {
|
||||
switch (itemType) {
|
||||
case animated:
|
||||
case _animated:
|
||||
return context.l10n.filterTypeAnimatedLabel;
|
||||
case panorama:
|
||||
case _panorama:
|
||||
return context.l10n.filterTypePanoramaLabel;
|
||||
case sphericalVideo:
|
||||
case _sphericalVideo:
|
||||
return context.l10n.filterTypeSphericalVideoLabel;
|
||||
case geotiff:
|
||||
case _geotiff:
|
||||
return context.l10n.filterTypeGeotiffLabel;
|
||||
default:
|
||||
return itemType;
|
||||
|
|
|
@ -5,8 +5,8 @@ import 'package:aves/model/entry.dart';
|
|||
import 'package:aves/model/favourites.dart';
|
||||
import 'package:aves/model/metadata.dart';
|
||||
import 'package:aves/model/metadata_db_upgrade.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
abstract class MetadataDb {
|
||||
|
@ -82,7 +82,7 @@ abstract class MetadataDb {
|
|||
class SqfliteMetadataDb implements MetadataDb {
|
||||
Future<Database> _database;
|
||||
|
||||
Future<String> get path async => join(await getDatabasesPath(), 'metadata.db');
|
||||
Future<String> get path async => pContext.join(await getDatabasesPath(), 'metadata.db');
|
||||
|
||||
static const entryTable = 'entry';
|
||||
static const dateTakenTable = 'dateTaken';
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/settings/screen_on.dart';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
|
@ -46,6 +47,7 @@ class Settings extends ChangeNotifier {
|
|||
static const showOverlayMinimapKey = 'show_overlay_minimap';
|
||||
static const showOverlayInfoKey = 'show_overlay_info';
|
||||
static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details';
|
||||
static const viewerQuickActionsKey = 'viewer_quick_actions';
|
||||
|
||||
// info
|
||||
static const infoMapStyleKey = 'info_map_style';
|
||||
|
@ -63,6 +65,12 @@ class Settings extends ChangeNotifier {
|
|||
// version
|
||||
static const lastVersionCheckDateKey = 'last_version_check_date';
|
||||
|
||||
// defaults
|
||||
static const viewerQuickActionsDefault = [
|
||||
EntryAction.toggleFavourite,
|
||||
EntryAction.share,
|
||||
];
|
||||
|
||||
Future<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
}
|
||||
|
@ -211,6 +219,10 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set showOverlayShootingDetails(bool newValue) => setAndNotify(showOverlayShootingDetailsKey, newValue);
|
||||
|
||||
List<EntryAction> get viewerQuickActions => getEnumListOrDefault(viewerQuickActionsKey, viewerQuickActionsDefault, EntryAction.values);
|
||||
|
||||
set viewerQuickActions(List<EntryAction> newValue) => setAndNotify(viewerQuickActionsKey, newValue.map((v) => v.toString()).toList());
|
||||
|
||||
// info
|
||||
|
||||
EntryMapStyle get infoMapStyle => getEnumOrDefault(infoMapStyleKey, EntryMapStyle.stamenWatercolor, EntryMapStyle.values);
|
||||
|
@ -258,16 +270,16 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
T getEnumOrDefault<T>(String key, T defaultValue, Iterable<T> values) {
|
||||
final valueString = _prefs.getString(key);
|
||||
for (final element in values) {
|
||||
if (element.toString() == valueString) {
|
||||
return element;
|
||||
for (final v in values) {
|
||||
if (v.toString() == valueString) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
List<T> getEnumListOrDefault<T>(String key, List<T> defaultValue, Iterable<T> values) {
|
||||
return _prefs.getStringList(key)?.map((s) => values.firstWhere((el) => el.toString() == s, orElse: () => null))?.where((el) => el != null)?.toList() ?? defaultValue;
|
||||
return _prefs.getStringList(key)?.map((s) => values.firstWhere((v) => v.toString() == s, orElse: () => null))?.where((v) => v != null)?.toList() ?? defaultValue;
|
||||
}
|
||||
|
||||
void setAndNotify(String key, dynamic newValue, {bool notify = true}) {
|
||||
|
|
|
@ -2,10 +2,11 @@ import 'package:aves/model/entry.dart';
|
|||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
mixin AlbumMixin on SourceBase {
|
||||
final Set<String> _directories = {};
|
||||
|
@ -13,8 +14,8 @@ mixin AlbumMixin on SourceBase {
|
|||
List<String> get rawAlbums => List.unmodifiable(_directories);
|
||||
|
||||
int compareAlbumsByName(String a, String b) {
|
||||
final ua = getUniqueAlbumName(null, a);
|
||||
final ub = getUniqueAlbumName(null, b);
|
||||
final ua = getAlbumDisplayName(null, a);
|
||||
final ub = getAlbumDisplayName(null, b);
|
||||
final c = compareAsciiUpperCase(ua, ub);
|
||||
if (c != 0) return c;
|
||||
final va = androidFileUtils.getStorageVolume(a)?.path ?? '';
|
||||
|
@ -24,36 +25,50 @@ mixin AlbumMixin on SourceBase {
|
|||
|
||||
void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent());
|
||||
|
||||
String getUniqueAlbumName(BuildContext context, String dirPath) {
|
||||
String unique(String dirPath, [bool Function(String) test]) {
|
||||
final otherAlbums = _directories.where(test ?? (_) => true).where((item) => item != dirPath);
|
||||
final parts = dirPath.split(separator);
|
||||
var partCount = 0;
|
||||
String testName;
|
||||
do {
|
||||
testName = separator + parts.skip(parts.length - ++partCount).join(separator);
|
||||
} while (otherAlbums.any((item) => item.endsWith(testName)));
|
||||
final uniqueName = parts.skip(parts.length - partCount).join(separator);
|
||||
return uniqueName;
|
||||
String getAlbumDisplayName(BuildContext context, String dirPath) {
|
||||
assert(!dirPath.endsWith(pContext.separator));
|
||||
|
||||
if (context != null) {
|
||||
final type = androidFileUtils.getAlbumType(dirPath);
|
||||
if (type == AlbumType.camera) return context.l10n.albumCamera;
|
||||
if (type == AlbumType.download) return context.l10n.albumDownload;
|
||||
if (type == AlbumType.screenshots) return context.l10n.albumScreenshots;
|
||||
if (type == AlbumType.screenRecordings) return context.l10n.albumScreenRecordings;
|
||||
}
|
||||
|
||||
final dir = VolumeRelativeDirectory.fromPath(dirPath);
|
||||
if (dir == null) return dirPath;
|
||||
|
||||
final uniqueNameInDevice = unique(dirPath);
|
||||
final relativeDir = dir.relativeDir;
|
||||
if (relativeDir.isEmpty) return uniqueNameInDevice;
|
||||
if (relativeDir.isEmpty) {
|
||||
final volume = androidFileUtils.getStorageVolume(dirPath);
|
||||
return volume.getDescription(context);
|
||||
}
|
||||
|
||||
String unique(String dirPath, Set<String> others) {
|
||||
final parts = pContext.split(dirPath);
|
||||
for (var i = parts.length - 1; i > 0; i--) {
|
||||
final testName = pContext.joinAll(['', ...parts.skip(i)]);
|
||||
if (others.every((item) => !item.endsWith(testName))) return testName;
|
||||
}
|
||||
return dirPath;
|
||||
}
|
||||
|
||||
final otherAlbumsOnDevice = _directories.where((item) => item != dirPath).toSet();
|
||||
final uniqueNameInDevice = unique(dirPath, otherAlbumsOnDevice);
|
||||
if (uniqueNameInDevice.length < relativeDir.length) {
|
||||
return uniqueNameInDevice;
|
||||
}
|
||||
|
||||
final volumePath = dir.volumePath;
|
||||
String trimVolumePath(String path) => path.substring(dir.volumePath.length);
|
||||
final otherAlbumsOnVolume = otherAlbumsOnDevice.where((path) => path.startsWith(volumePath)).map(trimVolumePath).toSet();
|
||||
final uniqueNameInVolume = unique(trimVolumePath(dirPath), otherAlbumsOnVolume);
|
||||
final volume = androidFileUtils.getStorageVolume(dirPath);
|
||||
if (volume.isPrimary) {
|
||||
return uniqueNameInVolume;
|
||||
} else {
|
||||
final uniqueNameInVolume = unique(dirPath, (item) => item.startsWith(dir.volumePath));
|
||||
final volume = androidFileUtils.getStorageVolume(dirPath);
|
||||
if (volume.isPrimary) {
|
||||
return uniqueNameInVolume;
|
||||
} else {
|
||||
return '$uniqueNameInVolume (${volume.getDescription(context)})';
|
||||
}
|
||||
return '$uniqueNameInVolume (${volume.getDescription(context)})';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -44,7 +44,7 @@ class AppShortcutService {
|
|||
await platform.invokeMethod('pin', <String, dynamic>{
|
||||
'label': label,
|
||||
'iconBytes': iconBytes,
|
||||
'filters': filters.map((filter) => filter.toJson()).toList(),
|
||||
'filters': filters.where((filter) => filter != null).map((filter) => filter.toJson()).toList(),
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('pin failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
|
|
|
@ -3,25 +3,31 @@ import 'package:aves/model/metadata_db.dart';
|
|||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:aves/services/media_store_service.dart';
|
||||
import 'package:aves/services/metadata_service.dart';
|
||||
import 'package:aves/services/storage_service.dart';
|
||||
import 'package:aves/services/time_service.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
final getIt = GetIt.instance;
|
||||
|
||||
final pContext = getIt<p.Context>();
|
||||
final availability = getIt<AvesAvailability>();
|
||||
final metadataDb = getIt<MetadataDb>();
|
||||
|
||||
final imageFileService = getIt<ImageFileService>();
|
||||
final mediaStoreService = getIt<MediaStoreService>();
|
||||
final metadataService = getIt<MetadataService>();
|
||||
final storageService = getIt<StorageService>();
|
||||
final timeService = getIt<TimeService>();
|
||||
|
||||
void initPlatformServices() {
|
||||
getIt.registerLazySingleton<p.Context>(() => p.Context());
|
||||
getIt.registerLazySingleton<AvesAvailability>(() => LiveAvesAvailability());
|
||||
getIt.registerLazySingleton<MetadataDb>(() => SqfliteMetadataDb());
|
||||
|
||||
getIt.registerLazySingleton<ImageFileService>(() => PlatformImageFileService());
|
||||
getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService());
|
||||
getIt.registerLazySingleton<MetadataService>(() => PlatformMetadataService());
|
||||
getIt.registerLazySingleton<StorageService>(() => PlatformStorageService());
|
||||
getIt.registerLazySingleton<TimeService>(() => PlatformTimeService());
|
||||
}
|
||||
|
|
|
@ -5,11 +5,35 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:streams_channel/streams_channel.dart';
|
||||
|
||||
class AndroidFileService {
|
||||
abstract class StorageService {
|
||||
Future<Set<StorageVolume>> getStorageVolumes();
|
||||
|
||||
Future<int> getFreeSpace(StorageVolume volume);
|
||||
|
||||
Future<List<String>> getGrantedDirectories();
|
||||
|
||||
Future<void> revokeDirectoryAccess(String path);
|
||||
|
||||
Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(Iterable<String> dirPaths);
|
||||
|
||||
Future<Set<VolumeRelativeDirectory>> getRestrictedDirectories();
|
||||
|
||||
// returns whether user granted access to volume root at `volumePath`
|
||||
Future<bool> requestVolumeAccess(String volumePath);
|
||||
|
||||
// returns number of deleted directories
|
||||
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths);
|
||||
|
||||
// returns media URI
|
||||
Future<Uri> scanFile(String path, String mimeType);
|
||||
}
|
||||
|
||||
class PlatformStorageService implements StorageService {
|
||||
static const platform = MethodChannel('deckers.thibault/aves/storage');
|
||||
static final StreamsChannel storageAccessChannel = StreamsChannel('deckers.thibault/aves/storageaccessstream');
|
||||
|
||||
static Future<Set<StorageVolume>> getStorageVolumes() async {
|
||||
@override
|
||||
Future<Set<StorageVolume>> getStorageVolumes() async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getStorageVolumes');
|
||||
return (result as List).cast<Map>().map((map) => StorageVolume.fromMap(map)).toSet();
|
||||
|
@ -19,7 +43,8 @@ class AndroidFileService {
|
|||
return {};
|
||||
}
|
||||
|
||||
static Future<int> getFreeSpace(StorageVolume volume) async {
|
||||
@override
|
||||
Future<int> getFreeSpace(StorageVolume volume) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getFreeSpace', <String, dynamic>{
|
||||
'path': volume.path,
|
||||
|
@ -31,7 +56,8 @@ class AndroidFileService {
|
|||
return 0;
|
||||
}
|
||||
|
||||
static Future<List<String>> getGrantedDirectories() async {
|
||||
@override
|
||||
Future<List<String>> getGrantedDirectories() async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getGrantedDirectories');
|
||||
return (result as List).cast<String>();
|
||||
|
@ -41,7 +67,8 @@ class AndroidFileService {
|
|||
return [];
|
||||
}
|
||||
|
||||
static Future<void> revokeDirectoryAccess(String path) async {
|
||||
@override
|
||||
Future<void> revokeDirectoryAccess(String path) async {
|
||||
try {
|
||||
await platform.invokeMethod('revokeDirectoryAccess', <String, dynamic>{
|
||||
'path': path,
|
||||
|
@ -52,7 +79,8 @@ class AndroidFileService {
|
|||
return;
|
||||
}
|
||||
|
||||
static Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
|
||||
@override
|
||||
Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getInaccessibleDirectories', <String, dynamic>{
|
||||
'dirPaths': dirPaths.toList(),
|
||||
|
@ -64,7 +92,8 @@ class AndroidFileService {
|
|||
return null;
|
||||
}
|
||||
|
||||
static Future<Set<VolumeRelativeDirectory>> getRestrictedDirectories() async {
|
||||
@override
|
||||
Future<Set<VolumeRelativeDirectory>> getRestrictedDirectories() async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getRestrictedDirectories');
|
||||
return (result as List).cast<Map>().map((map) => VolumeRelativeDirectory.fromMap(map)).toSet();
|
||||
|
@ -75,7 +104,8 @@ class AndroidFileService {
|
|||
}
|
||||
|
||||
// returns whether user granted access to volume root at `volumePath`
|
||||
static Future<bool> requestVolumeAccess(String volumePath) async {
|
||||
@override
|
||||
Future<bool> requestVolumeAccess(String volumePath) async {
|
||||
try {
|
||||
final completer = Completer<bool>();
|
||||
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
|
@ -95,8 +125,22 @@ class AndroidFileService {
|
|||
return false;
|
||||
}
|
||||
|
||||
// returns number of deleted directories
|
||||
@override
|
||||
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths) async {
|
||||
try {
|
||||
return await platform.invokeMethod('deleteEmptyDirectories', <String, dynamic>{
|
||||
'dirPaths': dirPaths.toList(),
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('deleteEmptyDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// returns media URI
|
||||
static Future<Uri> scanFile(String path, String mimeType) async {
|
||||
@override
|
||||
Future<Uri> scanFile(String path, String mimeType) async {
|
||||
debugPrint('scanFile with path=$path, mimeType=$mimeType');
|
||||
try {
|
||||
final uriString = await platform.invokeMethod('scanFile', <String, dynamic>{
|
|
@ -1,14 +1,18 @@
|
|||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
class Durations {
|
||||
// Flutter animations (with margin)
|
||||
static const popupMenuAnimation = Duration(milliseconds: 300 + 10); // ref `_kMenuDuration` used in `_PopupMenuRoute`
|
||||
static const dialogTransitionAnimation = Duration(milliseconds: 150 + 10); // ref `transitionDuration` used in `DialogRoute`
|
||||
static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 10); // ref `_kToggleDuration` used in `ToggleableStateMixin`
|
||||
|
||||
// common animations
|
||||
static const iconAnimation = Duration(milliseconds: 300);
|
||||
static const sweeperOpacityAnimation = Duration(milliseconds: 150);
|
||||
static const sweepingAnimation = Duration(milliseconds: 650);
|
||||
static const popupMenuAnimation = Duration(milliseconds: 300); // ref _PopupMenuRoute._kMenuDuration
|
||||
static const dialogTransitionAnimation = Duration(milliseconds: 150); // ref `transitionDuration` in `showDialog()`
|
||||
|
||||
static const staggeredAnimation = Duration(milliseconds: 375);
|
||||
static const staggeredAnimationPageTarget = Duration(milliseconds: 900);
|
||||
static const dialogFieldReachAnimation = Duration(milliseconds: 300);
|
||||
|
||||
static const appBarTitleAnimation = Duration(milliseconds: 300);
|
||||
|
@ -41,6 +45,10 @@ class Durations {
|
|||
static const mapStyleSwitchAnimation = Duration(milliseconds: 300);
|
||||
static const xmpStructArrayCardTransition = Duration(milliseconds: 300);
|
||||
|
||||
// settings animations
|
||||
static const quickActionListAnimation = Duration(milliseconds: 200);
|
||||
static const quickActionHighlightAnimation = Duration(milliseconds: 200);
|
||||
|
||||
// delays & refresh intervals
|
||||
static const opToastDisplay = Duration(seconds: 3);
|
||||
static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100);
|
||||
|
|
|
@ -13,8 +13,12 @@ class AIcons {
|
|||
static const IconData date = Icons.calendar_today_outlined;
|
||||
static const IconData disc = Icons.fiber_manual_record;
|
||||
static const IconData error = Icons.error_outline;
|
||||
static const IconData grid = Icons.grid_on_outlined;
|
||||
static const IconData home = Icons.home_outlined;
|
||||
static const IconData language = Icons.translate_outlined;
|
||||
static const IconData location = Icons.place_outlined;
|
||||
static const IconData locationOff = Icons.location_off_outlined;
|
||||
static const IconData privacy = MdiIcons.shieldAccountOutline;
|
||||
static const IconData raw = Icons.camera_outlined;
|
||||
static const IconData shooting = Icons.camera_outlined;
|
||||
static const IconData removableStorage = Icons.sd_storage_outlined;
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/services/android_file_service.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
|
||||
|
||||
|
@ -21,13 +20,13 @@ class AndroidFileUtils {
|
|||
AndroidFileUtils._private();
|
||||
|
||||
Future<void> init() async {
|
||||
storageVolumes = await AndroidFileService.getStorageVolumes();
|
||||
storageVolumes = await storageService.getStorageVolumes();
|
||||
// path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files'
|
||||
primaryStorage = storageVolumes.firstWhere((volume) => volume.isPrimary).path;
|
||||
dcimPath = join(primaryStorage, 'DCIM');
|
||||
downloadPath = join(primaryStorage, 'Download');
|
||||
moviesPath = join(primaryStorage, 'Movies');
|
||||
picturesPath = join(primaryStorage, 'Pictures');
|
||||
dcimPath = pContext.join(primaryStorage, 'DCIM');
|
||||
downloadPath = pContext.join(primaryStorage, 'Download');
|
||||
moviesPath = pContext.join(primaryStorage, 'Movies');
|
||||
picturesPath = pContext.join(primaryStorage, 'Pictures');
|
||||
}
|
||||
|
||||
Future<void> initAppNames() async {
|
||||
|
@ -60,7 +59,7 @@ class AndroidFileUtils {
|
|||
if (isScreenRecordingsPath(albumPath)) return AlbumType.screenRecordings;
|
||||
if (isScreenshotsPath(albumPath)) return AlbumType.screenshots;
|
||||
|
||||
final dir = albumPath.split(separator).last;
|
||||
final dir = pContext.split(albumPath).last;
|
||||
if (albumPath.startsWith(primaryStorage) && _potentialAppDirs.contains(dir)) return AlbumType.app;
|
||||
}
|
||||
return AlbumType.regular;
|
||||
|
@ -68,7 +67,7 @@ class AndroidFileUtils {
|
|||
|
||||
String getAlbumAppPackageName(String albumPath) {
|
||||
if (albumPath == null) return null;
|
||||
final dir = albumPath.split(separator).last;
|
||||
final dir = pContext.split(albumPath).last;
|
||||
final package = _launcherPackages.firstWhere((package) => package.potentialDirs.contains(dir), orElse: () => null);
|
||||
return package?.packageName;
|
||||
}
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import 'package:aves/ref/brand_colors.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/basic/link_chip.dart';
|
||||
import 'package:aves/widgets/common/basic/menu_row.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -15,7 +13,6 @@ class Licenses extends StatefulWidget {
|
|||
|
||||
class _LicensesState extends State<Licenses> {
|
||||
final ValueNotifier<String> _expandedNotifier = ValueNotifier(null);
|
||||
LicenseSort _sort = LicenseSort.name;
|
||||
List<Dependency> _platform, _flutterPlugins, _flutterPackages, _dartPackages;
|
||||
|
||||
@override
|
||||
|
@ -29,17 +26,7 @@ class _LicensesState extends State<Licenses> {
|
|||
}
|
||||
|
||||
void _sortPackages() {
|
||||
int compare(Dependency a, Dependency b) {
|
||||
switch (_sort) {
|
||||
case LicenseSort.license:
|
||||
final c = compareAsciiUpperCase(a.license, b.license);
|
||||
return c != 0 ? c : compareAsciiUpperCase(a.name, b.name);
|
||||
case LicenseSort.name:
|
||||
default:
|
||||
return compareAsciiUpperCase(a.name, b.name);
|
||||
}
|
||||
}
|
||||
|
||||
int compare(Dependency a, Dependency b) => compareAsciiUpperCase(a.name, b.name);
|
||||
_platform.sort(compare);
|
||||
_flutterPlugins.sort(compare);
|
||||
_flutterPackages.sort(compare);
|
||||
|
@ -103,44 +90,22 @@ class _LicensesState extends State<Licenses> {
|
|||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsetsDirectional.only(start: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(context.l10n.aboutLicenses, style: Constants.titleTextStyle),
|
||||
),
|
||||
PopupMenuButton<LicenseSort>(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: LicenseSort.name,
|
||||
child: MenuRow(text: context.l10n.aboutLicensesSortByName, checked: _sort == LicenseSort.name),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: LicenseSort.license,
|
||||
child: MenuRow(text: context.l10n.aboutLicensesSortByLicense, checked: _sort == LicenseSort.license),
|
||||
),
|
||||
],
|
||||
onSelected: (newSort) {
|
||||
_sort = newSort;
|
||||
_sortPackages();
|
||||
setState(() {});
|
||||
},
|
||||
tooltip: context.l10n.aboutLicensesSortTooltip,
|
||||
icon: Icon(AIcons.sort),
|
||||
),
|
||||
],
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: 48),
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Text(context.l10n.aboutLicenses, style: Constants.titleTextStyle),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(context.l10n.aboutLicensesBanner),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 8),
|
||||
Text(context.l10n.aboutLicensesBanner),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -179,5 +144,3 @@ class LicenseRow extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum LicenseSort { license, name }
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:async';
|
|||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/actions/collection_actions.dart';
|
||||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
|
@ -27,6 +28,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:pedantic/pedantic.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class CollectionAppBar extends StatefulWidget {
|
||||
final ValueNotifier<double> appBarHeightNotifier;
|
||||
|
@ -318,6 +320,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
title: context.l10n.collectionGroupTitle,
|
||||
),
|
||||
);
|
||||
// wait for the dialog to hide as applying the change may block the UI
|
||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||
if (value != null) {
|
||||
settings.collectionGroupFactor = value;
|
||||
collection.group(value);
|
||||
|
@ -336,6 +340,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
title: context.l10n.collectionSortTitle,
|
||||
),
|
||||
);
|
||||
// wait for the dialog to hide as applying the change may block the UI
|
||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||
if (value != null) {
|
||||
settings.collectionSortFactor = value;
|
||||
collection.sort(value);
|
||||
|
@ -347,22 +353,25 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
Future<void> _showShortcutDialog(BuildContext context) async {
|
||||
final filters = collection.filters;
|
||||
var defaultName;
|
||||
if (filters.isEmpty) {
|
||||
defaultName = context.l10n.collectionPageTitle;
|
||||
} else {
|
||||
if (filters.isNotEmpty) {
|
||||
// we compute the default name beforehand
|
||||
// because some filter labels need localization
|
||||
final sortedFilters = List<CollectionFilter>.from(filters)..sort();
|
||||
defaultName = sortedFilters.first.getLabel(context);
|
||||
}
|
||||
final name = await showDialog<String>(
|
||||
final result = await showDialog<Tuple2<AvesEntry, String>>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AddShortcutDialog(defaultName: defaultName);
|
||||
},
|
||||
builder: (context) => AddShortcutDialog(
|
||||
collection: collection,
|
||||
defaultName: defaultName,
|
||||
),
|
||||
);
|
||||
final coverEntry = result.item1;
|
||||
final name = result.item2;
|
||||
|
||||
if (name == null || name.isEmpty) return;
|
||||
|
||||
final iconEntry = collection.sortedEntries.isNotEmpty ? collection.sortedEntries.first : null;
|
||||
unawaited(AppShortcutService.pin(name, iconEntry, filters));
|
||||
unawaited(AppShortcutService.pin(name, coverEntry, filters));
|
||||
}
|
||||
|
||||
void _goToSearch() {
|
||||
|
|
|
@ -11,6 +11,7 @@ import 'package:aves/ref/mime_types.dart';
|
|||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/collection/app_bar.dart';
|
||||
import 'package:aves/widgets/collection/draggable_thumb_label.dart';
|
||||
import 'package:aves/widgets/collection/grid/section_layout.dart';
|
||||
import 'package:aves/widgets/collection/grid/selector.dart';
|
||||
import 'package:aves/widgets/collection/grid/thumbnail.dart';
|
||||
|
@ -29,9 +30,9 @@ import 'package:aves/widgets/common/providers/tile_extent_controller_provider.da
|
|||
import 'package:aves/widgets/common/scaling.dart';
|
||||
import 'package:aves/widgets/common/tile_extent_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class CollectionGrid extends StatefulWidget {
|
||||
final String settingsRouteKey;
|
||||
|
@ -74,23 +75,34 @@ class _CollectionGridContent extends StatelessWidget {
|
|||
builder: (context, tileExtent, child) {
|
||||
return ThumbnailTheme(
|
||||
extent: tileExtent,
|
||||
child: SectionedEntryListLayoutProvider(
|
||||
collection: collection,
|
||||
scrollableWidth: context.select<TileExtentController, double>((controller) => controller.viewportSize.width),
|
||||
tileExtent: tileExtent,
|
||||
columnCount: context.select<TileExtentController, int>((controller) => controller.getEffectiveColumnCountForExtent(tileExtent)),
|
||||
tileBuilder: (entry) => InteractiveThumbnail(
|
||||
key: ValueKey(entry.contentId),
|
||||
collection: collection,
|
||||
entry: entry,
|
||||
tileExtent: tileExtent,
|
||||
isScrollingNotifier: _isScrollingNotifier,
|
||||
),
|
||||
child: _CollectionSectionedContent(
|
||||
collection: collection,
|
||||
isScrollingNotifier: _isScrollingNotifier,
|
||||
scrollController: PrimaryScrollController.of(context),
|
||||
),
|
||||
child: Selector<TileExtentController, Tuple2<double, int>>(
|
||||
selector: (context, c) => Tuple2(c.viewportSize.width, c.columnCount),
|
||||
builder: (context, c, child) {
|
||||
final scrollableWidth = c.item1;
|
||||
final columnCount = c.item2;
|
||||
// do not listen for animation delay change
|
||||
final controller = Provider.of<TileExtentController>(context, listen: false);
|
||||
final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget);
|
||||
return SectionedEntryListLayoutProvider(
|
||||
collection: collection,
|
||||
scrollableWidth: scrollableWidth,
|
||||
tileExtent: tileExtent,
|
||||
columnCount: columnCount,
|
||||
tileBuilder: (entry) => InteractiveThumbnail(
|
||||
key: ValueKey(entry.contentId),
|
||||
collection: collection,
|
||||
entry: entry,
|
||||
tileExtent: tileExtent,
|
||||
isScrollingNotifier: _isScrollingNotifier,
|
||||
),
|
||||
tileAnimationDelay: tileAnimationDelay,
|
||||
child: _CollectionSectionedContent(
|
||||
collection: collection,
|
||||
isScrollingNotifier: _isScrollingNotifier,
|
||||
scrollController: PrimaryScrollController.of(context),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -266,10 +278,10 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scrollView = _buildScrollView(widget.appBar, widget.collection);
|
||||
return _buildDraggableScrollView(scrollView);
|
||||
return _buildDraggableScrollView(scrollView, widget.collection);
|
||||
}
|
||||
|
||||
Widget _buildDraggableScrollView(ScrollView scrollView) {
|
||||
Widget _buildDraggableScrollView(ScrollView scrollView, CollectionLens collection) {
|
||||
return ValueListenableBuilder<double>(
|
||||
valueListenable: widget.appBarHeightNotifier,
|
||||
builder: (context, appBarHeight, child) => Selector<MediaQueryData, double>(
|
||||
|
@ -287,6 +299,10 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> {
|
|||
top: appBarHeight,
|
||||
bottom: mqPaddingBottom,
|
||||
),
|
||||
labelTextBuilder: (offsetY) => CollectionDraggableThumbLabel(
|
||||
collection: collection,
|
||||
offsetY: offsetY,
|
||||
),
|
||||
child: scrollView,
|
||||
),
|
||||
child: child,
|
||||
|
|
60
lib/widgets/collection/draggable_thumb_label.dart
Normal file
60
lib/widgets/collection/draggable_thumb_label.dart
Normal file
|
@ -0,0 +1,60 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/utils/file_utils.dart';
|
||||
import 'package:aves/widgets/common/grid/draggable_thumb_label.dart';
|
||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class CollectionDraggableThumbLabel extends StatelessWidget {
|
||||
final CollectionLens collection;
|
||||
final double offsetY;
|
||||
|
||||
const CollectionDraggableThumbLabel({
|
||||
@required this.collection,
|
||||
@required this.offsetY,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DraggableThumbLabel<AvesEntry>(
|
||||
offsetY: offsetY,
|
||||
lineBuilder: (context, entry) {
|
||||
switch (collection.sortFactor) {
|
||||
case EntrySortFactor.date:
|
||||
switch (collection.groupFactor) {
|
||||
case EntryGroupFactor.album:
|
||||
return [
|
||||
DraggableThumbLabel.formatMonthThumbLabel(context, entry.bestDate),
|
||||
if (_hasMultipleSections(context)) context.read<CollectionSource>().getAlbumDisplayName(context, entry.directory),
|
||||
];
|
||||
case EntryGroupFactor.month:
|
||||
case EntryGroupFactor.none:
|
||||
return [
|
||||
DraggableThumbLabel.formatMonthThumbLabel(context, entry.bestDate),
|
||||
];
|
||||
case EntryGroupFactor.day:
|
||||
return [
|
||||
DraggableThumbLabel.formatDayThumbLabel(context, entry.bestDate),
|
||||
];
|
||||
}
|
||||
break;
|
||||
case EntrySortFactor.name:
|
||||
return [
|
||||
if (_hasMultipleSections(context)) context.read<CollectionSource>().getAlbumDisplayName(context, entry.directory),
|
||||
entry.bestTitle,
|
||||
];
|
||||
case EntrySortFactor.size:
|
||||
return [
|
||||
formatFilesize(entry.sizeBytes, round: 0),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool _hasMultipleSections(BuildContext context) => context.read<SectionedListLayout<AvesEntry>>().sections.length > 1;
|
||||
}
|
|
@ -7,7 +7,6 @@ import 'package:aves/model/entry.dart';
|
|||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/services/android_file_service.dart';
|
||||
import 'package:aves/services/image_op_events.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
|
@ -69,9 +68,10 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
if (moveType == MoveType.move) {
|
||||
// check whether moving is possible given OS restrictions,
|
||||
// before asking to pick a destination album
|
||||
final restrictedDirs = await AndroidFileService.getRestrictedDirectories();
|
||||
final restrictedDirs = await storageService.getRestrictedDirectories();
|
||||
for (final selectionDir in selectionDirs) {
|
||||
final dir = VolumeRelativeDirectory.fromPath(selectionDir);
|
||||
if (dir == null) return;
|
||||
if (restrictedDirs.contains(dir)) {
|
||||
await showRestrictedDirectoryDialog(context, dir);
|
||||
return;
|
||||
|
@ -124,19 +124,25 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
final count = movedCount;
|
||||
showFeedback(context, copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count));
|
||||
}
|
||||
|
||||
// cleanup
|
||||
if (moveType == MoveType.move) {
|
||||
await storageService.deleteEmptyDirectories(selectionDirs);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showDeleteDialog(BuildContext context) async {
|
||||
final count = selection.length;
|
||||
final selectionDirs = selection.where((e) => e.path != null).map((e) => e.directory).toSet();
|
||||
final todoCount = selection.length;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesDialog(
|
||||
context: context,
|
||||
content: Text(context.l10n.deleteEntriesConfirmationDialogMessage(count)),
|
||||
content: Text(context.l10n.deleteEntriesConfirmationDialogMessage(todoCount)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
@ -152,14 +158,13 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
);
|
||||
if (confirmed == null || !confirmed) return;
|
||||
|
||||
if (!await checkStoragePermission(context, selection)) return;
|
||||
if (!await checkStoragePermissionForAlbums(context, selectionDirs)) return;
|
||||
|
||||
final selectionCount = selection.length;
|
||||
source.pauseMonitoring();
|
||||
showOpReport<ImageOpEvent>(
|
||||
context: context,
|
||||
opStream: imageFileService.delete(selection),
|
||||
itemCount: selectionCount,
|
||||
itemCount: todoCount,
|
||||
onDone: (processed) async {
|
||||
final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet();
|
||||
await source.removeEntries(deletedUris);
|
||||
|
@ -167,10 +172,13 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
source.resumeMonitoring();
|
||||
|
||||
final deletedCount = deletedUris.length;
|
||||
if (deletedCount < selectionCount) {
|
||||
final count = selectionCount - deletedCount;
|
||||
if (deletedCount < todoCount) {
|
||||
final count = todoCount - deletedCount;
|
||||
showFeedback(context, context.l10n.collectionDeleteFailureFeedback(count));
|
||||
}
|
||||
|
||||
// cleanup
|
||||
await storageService.deleteEmptyDirectories(selectionDirs);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
@ -46,7 +46,7 @@ class AlbumSectionHeader extends StatelessWidget {
|
|||
return SectionHeader.getPreferredHeight(
|
||||
context: context,
|
||||
maxWidth: maxWidth,
|
||||
title: source.getUniqueAlbumName(context, directory),
|
||||
title: source.getAlbumDisplayName(context, directory),
|
||||
hasLeading: androidFileUtils.getAlbumType(directory) != AlbumType.regular,
|
||||
hasTrailing: androidFileUtils.isOnRemovableStorage(directory),
|
||||
);
|
||||
|
|
|
@ -60,7 +60,7 @@ class CollectionSectionHeader extends StatelessWidget {
|
|||
return AlbumSectionHeader(
|
||||
key: ValueKey(sectionKey),
|
||||
directory: directory,
|
||||
albumName: source.getUniqueAlbumName(context, directory),
|
||||
albumName: source.getAlbumDisplayName(context, directory),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -15,12 +15,14 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<AvesE
|
|||
@required int columnCount,
|
||||
@required double tileExtent,
|
||||
@required Widget Function(AvesEntry entry) tileBuilder,
|
||||
@required Duration tileAnimationDelay,
|
||||
@required Widget child,
|
||||
}) : super(
|
||||
scrollableWidth: scrollableWidth,
|
||||
columnCount: columnCount,
|
||||
tileExtent: tileExtent,
|
||||
tileBuilder: tileBuilder,
|
||||
tileAnimationDelay: tileAnimationDelay,
|
||||
child: child,
|
||||
);
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/services/android_file_service.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
|
@ -11,9 +11,9 @@ mixin PermissionAwareMixin {
|
|||
}
|
||||
|
||||
Future<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> albumPaths) async {
|
||||
final restrictedDirs = await AndroidFileService.getRestrictedDirectories();
|
||||
final restrictedDirs = await storageService.getRestrictedDirectories();
|
||||
while (true) {
|
||||
final dirs = await AndroidFileService.getInaccessibleDirectories(albumPaths);
|
||||
final dirs = await storageService.getInaccessibleDirectories(albumPaths);
|
||||
if (dirs == null) return false;
|
||||
if (dirs.isEmpty) return true;
|
||||
|
||||
|
@ -49,7 +49,7 @@ mixin PermissionAwareMixin {
|
|||
// abort if the user cancels in Flutter
|
||||
if (confirmed == null || !confirmed) return false;
|
||||
|
||||
final granted = await AndroidFileService.requestVolumeAccess(dir.volumePath);
|
||||
final granted = await storageService.requestVolumeAccess(dir.volumePath);
|
||||
if (!granted) {
|
||||
// abort if the user denies access from the native dialog
|
||||
return false;
|
||||
|
|
|
@ -3,7 +3,7 @@ import 'dart:math';
|
|||
|
||||
import 'package:aves/model/actions/move_type.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/services/android_file_service.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/file_utils.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
|
@ -20,7 +20,7 @@ mixin SizeAwareMixin {
|
|||
MoveType moveType,
|
||||
) async {
|
||||
final destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum);
|
||||
final free = await AndroidFileService.getFreeSpace(destinationVolume);
|
||||
final free = await storageService.getFreeSpace(destinationVolume);
|
||||
int needed;
|
||||
int sumSize(sum, entry) => sum + entry.sizeBytes;
|
||||
switch (moveType) {
|
||||
|
|
|
@ -62,7 +62,7 @@ class DraggableScrollbar extends StatefulWidget {
|
|||
@required this.controller,
|
||||
this.padding,
|
||||
this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
|
||||
this.scrollbarTimeToFade = const Duration(milliseconds: 600),
|
||||
this.scrollbarTimeToFade = const Duration(milliseconds: 1000),
|
||||
this.labelTextBuilder,
|
||||
@required this.child,
|
||||
}) : assert(controller != null),
|
||||
|
@ -91,6 +91,7 @@ class DraggableScrollbar extends StatefulWidget {
|
|||
backgroundColor: backgroundColor,
|
||||
child: labelText,
|
||||
),
|
||||
SizedBox(width: 24),
|
||||
scrollThumb,
|
||||
],
|
||||
);
|
||||
|
@ -122,7 +123,7 @@ class ScrollLabel extends StatelessWidget {
|
|||
child: Material(
|
||||
elevation: 4.0,
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.all(Radius.circular(16.0)),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
|
@ -133,6 +134,7 @@ class ScrollLabel extends StatelessWidget {
|
|||
class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProviderStateMixin {
|
||||
final ValueNotifier<double> _thumbOffsetNotifier = ValueNotifier(0), _viewOffsetNotifier = ValueNotifier(0);
|
||||
bool _isDragInProcess = false;
|
||||
Offset _longPressLastGlobalPosition;
|
||||
|
||||
AnimationController _thumbAnimationController;
|
||||
Animation<double> _thumbAnimation;
|
||||
|
@ -193,9 +195,19 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
|
|||
),
|
||||
RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onVerticalDragStart: _onVerticalDragStart,
|
||||
onVerticalDragUpdate: _onVerticalDragUpdate,
|
||||
onVerticalDragEnd: _onVerticalDragEnd,
|
||||
onLongPressStart: (details) {
|
||||
_longPressLastGlobalPosition = details.globalPosition;
|
||||
_onVerticalDragStart();
|
||||
},
|
||||
onLongPressMoveUpdate: (details) {
|
||||
final dy = (details.globalPosition - _longPressLastGlobalPosition).dy;
|
||||
_longPressLastGlobalPosition = details.globalPosition;
|
||||
_onVerticalDragUpdate(dy);
|
||||
},
|
||||
onLongPressEnd: (_) => _onVerticalDragEnd(),
|
||||
onVerticalDragStart: (_) => _onVerticalDragStart(),
|
||||
onVerticalDragUpdate: (details) => _onVerticalDragUpdate(details.delta.dy),
|
||||
onVerticalDragEnd: (_) => _onVerticalDragEnd(),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: _thumbOffsetNotifier,
|
||||
builder: (context, thumbOffset, child) => Container(
|
||||
|
@ -244,17 +256,18 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
|
|||
}
|
||||
}
|
||||
|
||||
void _onVerticalDragStart(DragStartDetails details) {
|
||||
void _onVerticalDragStart() {
|
||||
_labelAnimationController.forward();
|
||||
_fadeoutTimer?.cancel();
|
||||
_showThumb();
|
||||
setState(() => _isDragInProcess = true);
|
||||
}
|
||||
|
||||
void _onVerticalDragUpdate(DragUpdateDetails details) {
|
||||
void _onVerticalDragUpdate(double deltaY) {
|
||||
_showThumb();
|
||||
if (_isDragInProcess) {
|
||||
// thumb offset
|
||||
_thumbOffsetNotifier.value = (_thumbOffsetNotifier.value + details.delta.dy).clamp(thumbMinScrollExtent, thumbMaxScrollExtent);
|
||||
_thumbOffsetNotifier.value = (_thumbOffsetNotifier.value + deltaY).clamp(thumbMinScrollExtent, thumbMaxScrollExtent);
|
||||
|
||||
// scroll offset
|
||||
final min = controller.position.minScrollExtent;
|
||||
|
@ -263,7 +276,7 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
|
|||
}
|
||||
}
|
||||
|
||||
void _onVerticalDragEnd(DragEndDetails details) {
|
||||
void _onVerticalDragEnd() {
|
||||
_scheduleFadeout();
|
||||
setState(() => _isDragInProcess = false);
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ class LinkChip extends StatelessWidget {
|
|||
final Color color;
|
||||
final TextStyle textStyle;
|
||||
|
||||
static const borderRadius = BorderRadius.all(Radius.circular(8));
|
||||
static final borderRadius = BorderRadius.circular(8);
|
||||
|
||||
const LinkChip({
|
||||
Key key,
|
||||
|
|
67
lib/widgets/common/grid/draggable_thumb_label.dart
Normal file
67
lib/widgets/common/grid/draggable_thumb_label.dart
Normal file
|
@ -0,0 +1,67 @@
|
|||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class DraggableThumbLabel<T> extends StatelessWidget {
|
||||
final double offsetY;
|
||||
final List<String> Function(BuildContext context, T item) lineBuilder;
|
||||
|
||||
const DraggableThumbLabel({
|
||||
@required this.offsetY,
|
||||
@required this.lineBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sll = context.read<SectionedListLayout<T>>();
|
||||
final sectionLayout = sll.getSectionAt(offsetY);
|
||||
if (sectionLayout == null) return SizedBox();
|
||||
|
||||
final section = sll.sections[sectionLayout.sectionKey];
|
||||
final dy = offsetY - (sectionLayout.minOffset + sectionLayout.headerExtent);
|
||||
final itemIndex = dy < 0 ? 0 : (dy ~/ (sll.tileExtent + sll.spacing)) * sll.columnCount;
|
||||
final item = section[itemIndex];
|
||||
if (item == null) return SizedBox();
|
||||
|
||||
final lines = lineBuilder(context, item);
|
||||
if (lines.isEmpty) return SizedBox();
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 140),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
child: lines.length > 1
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: lines.map(_buildText).toList(),
|
||||
)
|
||||
: _buildText(lines.first),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildText(String text) => Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
),
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
);
|
||||
|
||||
static String formatMonthThumbLabel(BuildContext context, DateTime date) {
|
||||
final l10n = context.l10n;
|
||||
if (date == null) return l10n.sectionUnknown;
|
||||
return DateFormat.yMMM(l10n.localeName).format(date);
|
||||
}
|
||||
|
||||
static String formatDayThumbLabel(BuildContext context, DateTime date) {
|
||||
final l10n = context.l10n;
|
||||
if (date == null) return l10n.sectionUnknown;
|
||||
return DateFormat.yMMMd(l10n.localeName).format(date);
|
||||
}
|
||||
}
|
|
@ -12,6 +12,7 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
|
|||
final int columnCount;
|
||||
final double spacing, tileExtent;
|
||||
final Widget Function(T item) tileBuilder;
|
||||
final Duration tileAnimationDelay;
|
||||
final Widget child;
|
||||
|
||||
const SectionedListLayoutProvider({
|
||||
|
@ -20,6 +21,7 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
|
|||
this.spacing = 0,
|
||||
@required this.tileExtent,
|
||||
@required this.tileBuilder,
|
||||
this.tileAnimationDelay,
|
||||
@required this.child,
|
||||
}) : assert(scrollableWidth != 0);
|
||||
|
||||
|
@ -40,6 +42,7 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
|
|||
final _showHeaders = showHeaders;
|
||||
final _sections = sections;
|
||||
final sectionKeys = _sections.keys.toList();
|
||||
final animate = tileAnimationDelay > Duration.zero;
|
||||
|
||||
final sectionLayouts = <SectionLayout>[];
|
||||
var currentIndex = 0, currentOffset = 0.0;
|
||||
|
@ -76,6 +79,7 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
|
|||
listIndex - sectionFirstIndex,
|
||||
sectionKey,
|
||||
headerExtent,
|
||||
animate,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -97,10 +101,11 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
|
|||
int sectionChildIndex,
|
||||
SectionKey sectionKey,
|
||||
double headerExtent,
|
||||
bool animate,
|
||||
) {
|
||||
if (sectionChildIndex == 0) {
|
||||
final header = headerExtent > 0 ? buildHeader(context, sectionKey, headerExtent) : SizedBox.shrink();
|
||||
return _buildAnimation(sectionGridIndex, header);
|
||||
return animate ? _buildAnimation(sectionGridIndex, header) : header;
|
||||
}
|
||||
sectionChildIndex--;
|
||||
|
||||
|
@ -113,7 +118,7 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
|
|||
final itemGridIndex = sectionGridIndex + i - minItemIndex;
|
||||
final item = tileBuilder(section[i]);
|
||||
if (i != minItemIndex) children.add(SizedBox(width: spacing));
|
||||
children.add(_buildAnimation(itemGridIndex, item));
|
||||
children.add(animate ? _buildAnimation(itemGridIndex, item) : item);
|
||||
}
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
@ -126,7 +131,7 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
|
|||
position: index,
|
||||
columnCount: columnCount,
|
||||
duration: Durations.staggeredAnimation,
|
||||
delay: Durations.staggeredAnimationDelay,
|
||||
delay: tileAnimationDelay ?? Durations.staggeredAnimationDelay,
|
||||
child: SlideAnimation(
|
||||
verticalOffset: 50.0,
|
||||
child: FadeInAnimation(
|
||||
|
@ -189,9 +194,11 @@ class SectionedListLayout<T> {
|
|||
return Rect.fromLTWH(left, top, tileExtent, tileExtent);
|
||||
}
|
||||
|
||||
SectionLayout getSectionAt(double offsetY) => sectionLayouts.firstWhere((sl) => offsetY < sl.maxOffset, orElse: () => null);
|
||||
|
||||
T getItemAt(Offset position) {
|
||||
var dy = position.dy;
|
||||
final sectionLayout = sectionLayouts.firstWhere((sl) => dy < sl.maxOffset, orElse: () => null);
|
||||
final sectionLayout = getSectionAt(dy);
|
||||
if (sectionLayout == null) return null;
|
||||
|
||||
final section = sections[sectionLayout.sectionKey];
|
||||
|
|
|
@ -3,37 +3,53 @@ import 'package:expansion_tile_card/expansion_tile_card.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class AvesExpansionTile extends StatelessWidget {
|
||||
final String value;
|
||||
final Widget leading;
|
||||
final String title;
|
||||
final Color color;
|
||||
final ValueNotifier<String> expandedNotifier;
|
||||
final bool initiallyExpanded;
|
||||
final bool initiallyExpanded, showHighlight;
|
||||
final List<Widget> children;
|
||||
|
||||
const AvesExpansionTile({
|
||||
String value,
|
||||
this.leading,
|
||||
@required this.title,
|
||||
this.color,
|
||||
this.expandedNotifier,
|
||||
this.initiallyExpanded = false,
|
||||
this.showHighlight = true,
|
||||
@required this.children,
|
||||
});
|
||||
}): value = value ?? title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = children?.isNotEmpty == true;
|
||||
Widget titleChild = HighlightTitle(
|
||||
title,
|
||||
color: color,
|
||||
enabled: enabled,
|
||||
showHighlight: showHighlight,
|
||||
);
|
||||
if (leading != null) {
|
||||
titleChild = Row(
|
||||
children: [
|
||||
leading,
|
||||
SizedBox(width: 8),
|
||||
Expanded(child: titleChild),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
// color used by the `ExpansionTileCard` for selected text and icons
|
||||
accentColor: Colors.white,
|
||||
),
|
||||
child: ExpansionTileCard(
|
||||
key: Key('tilecard-$title'),
|
||||
value: title,
|
||||
key: Key('tilecard-$value'),
|
||||
value: value,
|
||||
expandedNotifier: expandedNotifier,
|
||||
title: HighlightTitle(
|
||||
title,
|
||||
color: color,
|
||||
enabled: enabled,
|
||||
),
|
||||
title: titleChild,
|
||||
expandable: enabled,
|
||||
initiallyExpanded: initiallyExpanded,
|
||||
finalPadding: EdgeInsets.symmetric(vertical: 6.0),
|
||||
|
|
|
@ -44,7 +44,7 @@ class AvesFilterChip extends StatefulWidget {
|
|||
this.showGenericIcon = true,
|
||||
this.background,
|
||||
this.details,
|
||||
this.borderRadius = const BorderRadius.all(Radius.circular(defaultRadius)),
|
||||
this.borderRadius,
|
||||
this.padding = 6.0,
|
||||
this.heroType = HeroType.onTap,
|
||||
this.onTap,
|
||||
|
@ -96,8 +96,6 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
|
||||
CollectionFilter get filter => widget.filter;
|
||||
|
||||
BorderRadius get borderRadius => widget.borderRadius;
|
||||
|
||||
double get padding => widget.padding;
|
||||
|
||||
FilterCallback get onTap => widget.onTap;
|
||||
|
@ -197,6 +195,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
);
|
||||
}
|
||||
|
||||
final borderRadius = widget.borderRadius ?? BorderRadius.circular(AvesFilterChip.defaultRadius);
|
||||
Widget chip = Container(
|
||||
constraints: BoxConstraints(
|
||||
minWidth: AvesFilterChip.minChipWidth,
|
||||
|
|
|
@ -9,6 +9,7 @@ class HighlightTitle extends StatelessWidget {
|
|||
final Color color;
|
||||
final double fontSize;
|
||||
final bool enabled, selectable;
|
||||
final bool showHighlight;
|
||||
|
||||
const HighlightTitle(
|
||||
this.title, {
|
||||
|
@ -16,6 +17,7 @@ class HighlightTitle extends StatelessWidget {
|
|||
this.fontSize = 18,
|
||||
this.enabled = true,
|
||||
this.selectable = false,
|
||||
this.showHighlight = true,
|
||||
}) : assert(title != null);
|
||||
|
||||
static const disabledColor = Colors.grey;
|
||||
|
@ -38,9 +40,11 @@ class HighlightTitle extends StatelessWidget {
|
|||
return Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Container(
|
||||
decoration: HighlightDecoration(
|
||||
color: enabled ? color ?? stringToColor(title) : disabledColor,
|
||||
),
|
||||
decoration: showHighlight
|
||||
? HighlightDecoration(
|
||||
color: enabled ? color ?? stringToColor(title) : disabledColor,
|
||||
)
|
||||
: null,
|
||||
margin: EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: selectable
|
||||
? SelectableText(
|
||||
|
|
|
@ -12,7 +12,7 @@ ScrollThumbBuilder avesScrollThumbBuilder({
|
|||
final scrollThumb = Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
height: height,
|
||||
margin: EdgeInsets.only(right: .5),
|
||||
|
@ -23,7 +23,7 @@ ScrollThumbBuilder avesScrollThumbBuilder({
|
|||
width: 20.0,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TileExtentController {
|
||||
|
@ -45,7 +46,7 @@ class TileExtentController {
|
|||
? oldUserPreferredExtent
|
||||
: currentExtent;
|
||||
|
||||
final columnCount = getEffectiveColumnCountForExtent(targetExtent);
|
||||
final columnCount = _effectiveColumnCountForExtent(targetExtent);
|
||||
final newExtent = _extentForColumnCount(columnCount);
|
||||
|
||||
if (userPreferredExtent > 0 || oldUserPreferredExtent == 0) {
|
||||
|
@ -67,15 +68,24 @@ class TileExtentController {
|
|||
|
||||
int _effectiveColumnCountMax() => _columnCountForExtent(extentMin).floor();
|
||||
|
||||
double get effectiveExtentMin => _extentForColumnCount(_effectiveColumnCountMax());
|
||||
|
||||
double get effectiveExtentMax => _extentForColumnCount(_effectiveColumnCountMin());
|
||||
|
||||
int getEffectiveColumnCountForExtent(double extent) {
|
||||
int _effectiveColumnCountForExtent(double extent) {
|
||||
if (extent > 0) {
|
||||
final columnCount = _columnCountForExtent(extent);
|
||||
return columnCount.clamp(_effectiveColumnCountMin(), _effectiveColumnCountMax()).round();
|
||||
}
|
||||
return columnCountDefault;
|
||||
}
|
||||
|
||||
double get effectiveExtentMin => _extentForColumnCount(_effectiveColumnCountMax());
|
||||
|
||||
double get effectiveExtentMax => _extentForColumnCount(_effectiveColumnCountMin());
|
||||
|
||||
int get columnCount => _effectiveColumnCountForExtent(extentNotifier.value);
|
||||
|
||||
Duration getTileAnimationDelay(Duration pageTarget) {
|
||||
final extent = extentNotifier.value;
|
||||
final columnCount = ((viewportSize.width + spacing) / (extent + spacing)).round();
|
||||
final rowCount = (viewportSize.height + spacing) ~/ (extent + spacing);
|
||||
return pageTarget ~/ (columnCount + rowCount) * timeDilation;
|
||||
}
|
||||
}
|
||||
|
|
119
lib/widgets/common/video/fijkplayer.dart
Normal file
119
lib/widgets/common/video/fijkplayer.dart
Normal file
|
@ -0,0 +1,119 @@
|
|||
// import 'dart:async';
|
||||
//
|
||||
// import 'package:aves/model/entry.dart';
|
||||
// import 'package:aves/utils/change_notifier.dart';
|
||||
// import 'package:aves/widgets/common/video/video.dart';
|
||||
// import 'package:fijkplayer/fijkplayer.dart';
|
||||
// import 'package:flutter/material.dart';
|
||||
//
|
||||
// class FijkPlayerAvesVideoController extends AvesVideoController {
|
||||
// FijkPlayer _instance;
|
||||
// final List<StreamSubscription> _subscriptions = [];
|
||||
// final StreamController<FijkValue> _valueStreamController = StreamController.broadcast();
|
||||
// final AChangeNotifier _playFinishNotifier = AChangeNotifier();
|
||||
//
|
||||
// Stream<FijkValue> get _valueStream => _valueStreamController.stream;
|
||||
//
|
||||
// FijkPlayerAvesVideoController() {
|
||||
// _instance = FijkPlayer();
|
||||
// _instance.addListener(_onValueChanged);
|
||||
// _subscriptions.add(_valueStream.where((value) => value.completed).listen((_) => _playFinishNotifier.notifyListeners()));
|
||||
// }
|
||||
//
|
||||
// @override
|
||||
// void dispose() {
|
||||
// _instance.removeListener(_onValueChanged);
|
||||
// _valueStreamController.close();
|
||||
// _subscriptions
|
||||
// ..forEach((sub) => sub.cancel())
|
||||
// ..clear();
|
||||
// _instance.release();
|
||||
// }
|
||||
//
|
||||
// void _onValueChanged() => _valueStreamController.add(_instance.value);
|
||||
//
|
||||
// // enable autoplay, even when seeking on uninitialized player, otherwise the texture is not updated
|
||||
// // as a workaround, pausing after a brief duration is possible, but fiddly
|
||||
// @override
|
||||
// Future<void> setDataSource(String uri) => _instance.setDataSource(uri, autoPlay: true);
|
||||
//
|
||||
// @override
|
||||
// Future<void> refreshVideoInfo() => null;
|
||||
//
|
||||
// @override
|
||||
// Future<void> play() => _instance.start();
|
||||
//
|
||||
// @override
|
||||
// Future<void> pause() => _instance.pause();
|
||||
//
|
||||
// @override
|
||||
// Future<void> seekTo(int targetMillis) => _instance.seekTo(targetMillis);
|
||||
//
|
||||
// @override
|
||||
// Future<void> seekToProgress(double progress) => _instance.seekTo((duration * progress).toInt());
|
||||
//
|
||||
// @override
|
||||
// Listenable get playCompletedListenable => _playFinishNotifier;
|
||||
//
|
||||
// @override
|
||||
// VideoStatus get status => _instance.state.toAves;
|
||||
//
|
||||
// @override
|
||||
// Stream<VideoStatus> get statusStream => _valueStream.map((value) => value.state.toAves);
|
||||
//
|
||||
// @override
|
||||
// bool get isVideoReady => _instance.value.videoRenderStart;
|
||||
//
|
||||
// @override
|
||||
// Stream<bool> get isVideoReadyStream => _valueStream.map((value) => value.videoRenderStart);
|
||||
//
|
||||
// // we check whether video info is ready instead of checking for `noDatasource` status,
|
||||
// // as the controller could also be uninitialized with the `pause` status
|
||||
// // (e.g. when switching between video entries without playing them the first time)
|
||||
// @override
|
||||
// bool get isPlayable => _instance.isPlayable();
|
||||
//
|
||||
// @override
|
||||
// int get duration => _instance.value.duration.inMilliseconds;
|
||||
//
|
||||
// @override
|
||||
// int get currentPosition => _instance.currentPos.inMilliseconds;
|
||||
//
|
||||
// @override
|
||||
// Stream<int> get positionStream => _instance.onCurrentPosUpdate.map((pos) => pos.inMilliseconds);
|
||||
//
|
||||
// @override
|
||||
// Widget buildPlayerWidget(AvesEntry entry) => FijkView(
|
||||
// player: _instance,
|
||||
// panelBuilder: (player, data, context, viewSize, texturePos) => SizedBox(),
|
||||
// color: Colors.transparent,
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// extension ExtraIjkStatus on FijkState {
|
||||
// VideoStatus get toAves {
|
||||
// switch (this) {
|
||||
// case FijkState.idle:
|
||||
// return VideoStatus.idle;
|
||||
// case FijkState.initialized:
|
||||
// return VideoStatus.initialized;
|
||||
// case FijkState.asyncPreparing:
|
||||
// return VideoStatus.preparing;
|
||||
// case FijkState.prepared:
|
||||
// return VideoStatus.prepared;
|
||||
// case FijkState.started:
|
||||
// return VideoStatus.playing;
|
||||
// case FijkState.paused:
|
||||
// return VideoStatus.paused;
|
||||
// case FijkState.completed:
|
||||
// return VideoStatus.completed;
|
||||
// case FijkState.stopped:
|
||||
// return VideoStatus.stopped;
|
||||
// case FijkState.end:
|
||||
// return VideoStatus.disposed;
|
||||
// case FijkState.error:
|
||||
// return VideoStatus.error;
|
||||
// }
|
||||
// return VideoStatus.idle;
|
||||
// }
|
||||
// }
|
144
lib/widgets/common/video/flutter_ijkplayer.dart
Normal file
144
lib/widgets/common/video/flutter_ijkplayer.dart
Normal file
|
@ -0,0 +1,144 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/widgets/common/video/video.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||
|
||||
class FlutterIjkPlayerAvesVideoController extends AvesVideoController {
|
||||
IjkMediaController _instance;
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
final AChangeNotifier _playFinishNotifier = AChangeNotifier();
|
||||
|
||||
FlutterIjkPlayerAvesVideoController() {
|
||||
_instance = IjkMediaController();
|
||||
_subscriptions.add(_instance.playFinishStream.listen((_) => _playFinishNotifier.notifyListeners()));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscriptions
|
||||
..forEach((sub) => sub.cancel())
|
||||
..clear();
|
||||
_instance?.dispose();
|
||||
}
|
||||
|
||||
// enable autoplay, even when seeking on uninitialized player, otherwise the texture is not updated
|
||||
// as a workaround, pausing after a brief duration is possible, but fiddly
|
||||
@override
|
||||
Future<void> setDataSource(String uri) => _instance.setDataSource(DataSource.photoManagerUrl(uri), autoPlay: true);
|
||||
|
||||
@override
|
||||
Future<void> refreshVideoInfo() => _instance.refreshVideoInfo();
|
||||
|
||||
@override
|
||||
Future<void> play() => _instance.play();
|
||||
|
||||
@override
|
||||
Future<void> pause() => _instance.pause();
|
||||
|
||||
@override
|
||||
Future<void> seekTo(int targetMillis) => _instance.seekTo(targetMillis / 1000.0);
|
||||
|
||||
@override
|
||||
Future<void> seekToProgress(double progress) => _instance.seekToProgress(progress);
|
||||
|
||||
@override
|
||||
Listenable get playCompletedListenable => _playFinishNotifier;
|
||||
|
||||
@override
|
||||
VideoStatus get status => _instance.ijkStatus.toAves;
|
||||
|
||||
@override
|
||||
Stream<VideoStatus> get statusStream => _instance.ijkStatusStream.map((status) => status.toAves);
|
||||
|
||||
// we check whether video info is ready instead of checking for `noDatasource` status,
|
||||
// as the controller could also be uninitialized with the `pause` status
|
||||
// (e.g. when switching between video entries without playing them the first time)
|
||||
@override
|
||||
bool get isPlayable => _videoInfo.hasData;
|
||||
|
||||
@override
|
||||
bool get isVideoReady => _instance.textureId != null;
|
||||
|
||||
@override
|
||||
Stream<bool> get isVideoReadyStream => _instance.textureIdStream.map((id) => id != null);
|
||||
|
||||
// `videoInfo` is never null (even if `toString` prints `null`)
|
||||
// check presence with `hasData` instead
|
||||
VideoInfo get _videoInfo => _instance.videoInfo;
|
||||
|
||||
@override
|
||||
int get duration => _videoInfo.durationMillis;
|
||||
|
||||
@override
|
||||
int get currentPosition => _videoInfo.currentPositionMillis;
|
||||
|
||||
@override
|
||||
Stream<int> get positionStream => _instance.videoInfoStream.map((info) => info.currentPositionMillis);
|
||||
|
||||
@override
|
||||
Widget buildPlayerWidget(AvesEntry entry) => IjkPlayer(
|
||||
mediaController: _instance,
|
||||
controllerWidgetBuilder: (controller) => SizedBox.shrink(),
|
||||
statusWidgetBuilder: (context, controller, status) => SizedBox.shrink(),
|
||||
textureBuilder: (context, controller, info) {
|
||||
var id = controller.textureId;
|
||||
var child = id != null
|
||||
? Texture(
|
||||
textureId: id,
|
||||
)
|
||||
: Container(
|
||||
color: Colors.black,
|
||||
);
|
||||
|
||||
final degree = entry.rotationDegrees ?? 0;
|
||||
if (degree != 0) {
|
||||
child = RotatedBox(
|
||||
quarterTurns: degree ~/ 90,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: entry.displayAspectRatio,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
}
|
||||
|
||||
extension ExtraVideoInfo on VideoInfo {
|
||||
int get durationMillis => duration == null ? null : (duration * 1000).toInt();
|
||||
|
||||
int get currentPositionMillis => currentPosition == null ? null : (currentPosition * 1000).toInt();
|
||||
}
|
||||
|
||||
extension ExtraIjkStatus on IjkStatus {
|
||||
VideoStatus get toAves {
|
||||
switch (this) {
|
||||
case IjkStatus.noDatasource:
|
||||
return VideoStatus.idle;
|
||||
case IjkStatus.preparing:
|
||||
return VideoStatus.preparing;
|
||||
case IjkStatus.prepared:
|
||||
return VideoStatus.prepared;
|
||||
case IjkStatus.playing:
|
||||
return VideoStatus.playing;
|
||||
case IjkStatus.pause:
|
||||
return VideoStatus.paused;
|
||||
case IjkStatus.complete:
|
||||
return VideoStatus.completed;
|
||||
case IjkStatus.disposed:
|
||||
return VideoStatus.disposed;
|
||||
case IjkStatus.setDatasourceFail:
|
||||
case IjkStatus.error:
|
||||
return VideoStatus.error;
|
||||
}
|
||||
return VideoStatus.idle;
|
||||
}
|
||||
}
|
73
lib/widgets/common/video/video.dart
Normal file
73
lib/widgets/common/video/video.dart
Normal file
|
@ -0,0 +1,73 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
// import 'package:aves/widgets/common/video/fijkplayer.dart';
|
||||
import 'package:aves/widgets/common/video/flutter_ijkplayer.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
abstract class AvesVideoController {
|
||||
AvesVideoController();
|
||||
|
||||
factory AvesVideoController.flutterIjkPlayer() => FlutterIjkPlayerAvesVideoController();
|
||||
|
||||
// factory AvesVideoController.fijkPlayer() => FijkPlayerAvesVideoController();
|
||||
|
||||
void dispose();
|
||||
|
||||
Future<void> setDataSource(String uri);
|
||||
|
||||
Future<void> refreshVideoInfo();
|
||||
|
||||
Future<void> play();
|
||||
|
||||
Future<void> pause();
|
||||
|
||||
Future<void> seekTo(int targetMillis);
|
||||
|
||||
Future<void> seekToProgress(double progress);
|
||||
|
||||
Listenable get playCompletedListenable;
|
||||
|
||||
VideoStatus get status;
|
||||
|
||||
Stream<VideoStatus> get statusStream;
|
||||
|
||||
bool get isPlayable;
|
||||
|
||||
bool get isPlaying => status == VideoStatus.playing;
|
||||
|
||||
bool get isVideoReady;
|
||||
|
||||
Stream<bool> get isVideoReadyStream;
|
||||
|
||||
int get duration;
|
||||
|
||||
int get currentPosition;
|
||||
|
||||
double get progress => (currentPosition ?? 0).toDouble() / (duration ?? 1);
|
||||
|
||||
Stream<int> get positionStream;
|
||||
|
||||
Widget buildPlayerWidget(AvesEntry entry);
|
||||
}
|
||||
|
||||
class AvesVideoInfo {
|
||||
// in millis
|
||||
int duration, currentPosition;
|
||||
|
||||
AvesVideoInfo({
|
||||
this.duration,
|
||||
this.currentPosition,
|
||||
});
|
||||
}
|
||||
|
||||
enum VideoStatus {
|
||||
idle,
|
||||
initialized,
|
||||
preparing,
|
||||
prepared,
|
||||
playing,
|
||||
paused,
|
||||
completed,
|
||||
stopped,
|
||||
disposed,
|
||||
error,
|
||||
}
|
|
@ -37,6 +37,7 @@ class DebugSettingsSection extends StatelessWidget {
|
|||
'tileExtent - Countries': '${settings.getTileExtent(CountryListPage.routeName)}',
|
||||
'tileExtent - Tags': '${settings.getTileExtent(TagListPage.routeName)}',
|
||||
'infoMapZoom': '${settings.infoMapZoom}',
|
||||
'viewerQuickActions': '${settings.viewerQuickActions}',
|
||||
'pinnedFilters': toMultiline(settings.pinnedFilters),
|
||||
'hiddenFilters': toMultiline(settings.hiddenFilters),
|
||||
'searchHistory': toMultiline(settings.searchHistory),
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:aves/services/android_file_service.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/file_utils.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
|
@ -17,7 +17,7 @@ class _DebugStorageSectionState extends State<DebugStorageSection> with Automati
|
|||
void initState() {
|
||||
super.initState();
|
||||
androidFileUtils.storageVolumes.forEach((volume) async {
|
||||
final byteCount = await AndroidFileService.getFreeSpace(volume);
|
||||
final byteCount = await storageService.getFreeSpace(volume);
|
||||
setState(() => _freeSpaceByVolume[volume.path] = byteCount);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,12 +1,24 @@
|
|||
import 'package:aves/model/covers.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/raster.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/vector.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/dialogs/item_pick_dialog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
import 'aves_dialog.dart';
|
||||
|
||||
class AddShortcutDialog extends StatefulWidget {
|
||||
final CollectionLens collection;
|
||||
final String defaultName;
|
||||
|
||||
const AddShortcutDialog({
|
||||
@required this.collection,
|
||||
@required this.defaultName,
|
||||
});
|
||||
|
||||
|
@ -17,10 +29,20 @@ class AddShortcutDialog extends StatefulWidget {
|
|||
class _AddShortcutDialogState extends State<AddShortcutDialog> {
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
|
||||
AvesEntry _coverEntry;
|
||||
|
||||
CollectionLens get collection => widget.collection;
|
||||
|
||||
Set<CollectionFilter> get filters => collection.filters;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final entries = collection.sortedEntries;
|
||||
if (entries.isNotEmpty) {
|
||||
final coverEntries = filters.map(covers.coverContentId).where((id) => id != null).map((id) => entries.firstWhere((entry) => entry.contentId == id, orElse: () => null)).where((entry) => entry != null);
|
||||
_coverEntry = coverEntries.isNotEmpty ? coverEntries.first : entries.first;
|
||||
}
|
||||
_nameController.text = widget.defaultName;
|
||||
_validate();
|
||||
}
|
||||
|
@ -33,40 +55,101 @@ class _AddShortcutDialogState extends State<AddShortcutDialog> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AvesDialog(
|
||||
context: context,
|
||||
content: TextField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.addShortcutDialogLabel,
|
||||
),
|
||||
autofocus: true,
|
||||
maxLength: 25,
|
||||
onChanged: (_) => _validate(),
|
||||
onSubmitted: (_) => _submit(context),
|
||||
return MediaQueryDataProvider(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final shortestSide = context.select<MediaQueryData, double>((mq) => mq.size.shortestSide);
|
||||
final extent = (shortestSide / 3.0).clamp(60.0, 160.0);
|
||||
return AvesDialog(
|
||||
context: context,
|
||||
scrollableContent: [
|
||||
if (_coverEntry != null)
|
||||
Container(
|
||||
alignment: Alignment.center,
|
||||
padding: EdgeInsets.only(top: 16),
|
||||
child: _buildCover(_coverEntry, extent),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 24),
|
||||
child: TextField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.addShortcutDialogLabel,
|
||||
),
|
||||
autofocus: true,
|
||||
maxLength: 25,
|
||||
onChanged: (_) => _validate(),
|
||||
onSubmitted: (_) => _submit(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _isValidNotifier,
|
||||
builder: (context, isValid, child) {
|
||||
return TextButton(
|
||||
onPressed: isValid ? () => _submit(context) : null,
|
||||
child: Text(context.l10n.addShortcutButtonLabel),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _isValidNotifier,
|
||||
builder: (context, isValid, child) {
|
||||
return TextButton(
|
||||
onPressed: isValid ? () => _submit(context) : null,
|
||||
child: Text(context.l10n.addShortcutButtonLabel),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCover(AvesEntry entry, double extent) {
|
||||
return GestureDetector(
|
||||
onTap: _pickEntry,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child: SizedBox(
|
||||
width: extent,
|
||||
height: extent,
|
||||
child: entry.isSvg
|
||||
? VectorImageThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
)
|
||||
: RasterImageThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickEntry() async {
|
||||
final entry = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: RouteSettings(name: ItemPickDialog.routeName),
|
||||
builder: (context) => ItemPickDialog(
|
||||
CollectionLens(
|
||||
source: collection.source,
|
||||
filters: filters,
|
||||
),
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
if (entry != null) {
|
||||
_coverEntry = entry;
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _validate() async {
|
||||
final name = _nameController.text ?? '';
|
||||
_isValidNotifier.value = name.isNotEmpty;
|
||||
}
|
||||
|
||||
void _submit(BuildContext context) => Navigator.pop(context, _nameController.text);
|
||||
void _submit(BuildContext context) => Navigator.pop(context, Tuple2<AvesEntry, String>(_coverEntry, _nameController.text));
|
||||
}
|
||||
|
|
|
@ -53,11 +53,7 @@ class _AvesSelectionDialogState<T> extends State<AvesSelectionDialog<T>> {
|
|||
key: Key(value.toString()),
|
||||
value: value,
|
||||
groupValue: _selectedValue,
|
||||
onChanged: (v) {
|
||||
_selectedValue = v;
|
||||
Navigator.pop(context, _selectedValue);
|
||||
setState(() {});
|
||||
},
|
||||
onChanged: (v) => Navigator.pop(context, v),
|
||||
reselectable: true,
|
||||
title: Text(
|
||||
title,
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
import 'aves_dialog.dart';
|
||||
|
||||
|
@ -143,7 +143,7 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
|||
|
||||
String _buildAlbumPath(String name) {
|
||||
if (name == null || name.isEmpty) return '';
|
||||
return join(_selectedVolume.path, 'Pictures', name);
|
||||
return pContext.join(_selectedVolume.path, 'Pictures', name);
|
||||
}
|
||||
|
||||
Future<void> _validate() async {
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
import '../dialogs/aves_dialog.dart';
|
||||
|
||||
|
@ -22,7 +22,7 @@ class _RenameAlbumDialogState extends State<RenameAlbumDialog> {
|
|||
|
||||
String get album => widget.album;
|
||||
|
||||
String get initialValue => path.basename(album);
|
||||
String get initialValue => pContext.basename(album);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -75,7 +75,7 @@ class _RenameAlbumDialogState extends State<RenameAlbumDialog> {
|
|||
|
||||
String _buildAlbumPath(String name) {
|
||||
if (name == null || name.isEmpty) return '';
|
||||
return path.join(path.dirname(album), name);
|
||||
return pContext.join(pContext.dirname(album), name);
|
||||
}
|
||||
|
||||
Future<void> _validate() async {
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
import 'aves_dialog.dart';
|
||||
|
||||
|
@ -69,7 +69,7 @@ class _RenameEntryDialogState extends State<RenameEntryDialog> {
|
|||
|
||||
String _buildEntryPath(String name) {
|
||||
if (name == null || name.isEmpty) return '';
|
||||
return path.join(entry.directory, name + entry.extension);
|
||||
return pContext.join(entry.directory, name + entry.extension);
|
||||
}
|
||||
|
||||
Future<void> _validate() async {
|
||||
|
|
|
@ -15,13 +15,13 @@ class AlbumTile extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final source = context.read<CollectionSource>();
|
||||
final uniqueName = source.getUniqueAlbumName(context, album);
|
||||
final displayName = source.getAlbumDisplayName(context, album);
|
||||
return CollectionNavTile(
|
||||
leading: IconUtils.getAlbumIcon(
|
||||
context: context,
|
||||
album: album,
|
||||
),
|
||||
title: uniqueName,
|
||||
title: displayName,
|
||||
trailing: androidFileUtils.isOnRemovableStorage(album)
|
||||
? Icon(
|
||||
AIcons.removableStorage,
|
||||
|
@ -29,7 +29,7 @@ class AlbumTile extends StatelessWidget {
|
|||
color: Colors.grey,
|
||||
)
|
||||
: null,
|
||||
filter: AlbumFilter(album, uniqueName),
|
||||
filter: AlbumFilter(album, displayName),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,11 +2,11 @@ import 'dart:ui';
|
|||
|
||||
import 'package:aves/model/filters/favourite.dart';
|
||||
import 'package:aves/model/filters/mime.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/album.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/location.dart';
|
||||
import 'package:aves/model/source/tag.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
|
@ -45,11 +45,14 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hiddenFilters = settings.hiddenFilters;
|
||||
final showVideos = !hiddenFilters.contains(MimeFilter.video);
|
||||
final showFavourites = !hiddenFilters.contains(FavouriteFilter.instance);
|
||||
final drawerItems = <Widget>[
|
||||
_buildHeader(context),
|
||||
allCollectionTile,
|
||||
videoTile,
|
||||
favouriteTile,
|
||||
if (showVideos) videoTile,
|
||||
if (showFavourites) favouriteTile,
|
||||
_buildSpecialAlbumSection(),
|
||||
Divider(),
|
||||
albumListTile,
|
||||
|
@ -153,13 +156,13 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
Widget get videoTile => CollectionNavTile(
|
||||
leading: Icon(AIcons.video),
|
||||
title: context.l10n.drawerCollectionVideos,
|
||||
filter: MimeFilter(MimeTypes.anyVideo),
|
||||
filter: MimeFilter.video,
|
||||
);
|
||||
|
||||
Widget get favouriteTile => CollectionNavTile(
|
||||
leading: Icon(AIcons.favourite),
|
||||
title: context.l10n.drawerCollectionFavourites,
|
||||
filter: FavouriteFilter(),
|
||||
filter: FavouriteFilter.instance,
|
||||
);
|
||||
|
||||
Widget get albumListTile => NavTile(
|
||||
|
|
|
@ -62,12 +62,13 @@ class _AlbumPickPageState extends State<AlbumPickPage> {
|
|||
appBar: appBar,
|
||||
appBarHeight: AlbumPickAppBar.preferredHeight,
|
||||
filterSections: AlbumListPage.getAlbumEntries(context, source),
|
||||
sortFactor: settings.albumSortFactor,
|
||||
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
|
||||
queryNotifier: _queryNotifier,
|
||||
applyQuery: (filters, query) {
|
||||
if (query == null || query.isEmpty) return filters;
|
||||
query = query.toUpperCase();
|
||||
return filters.where((item) => item.filter.uniqueName.toUpperCase().contains(query)).toList();
|
||||
return filters.where((item) => item.filter.displayName.toUpperCase().contains(query)).toList();
|
||||
},
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.album,
|
||||
|
|
|
@ -34,17 +34,23 @@ class AlbumListPage extends StatelessWidget {
|
|||
builder: (context, snapshot) => FilterNavigationPage<AlbumFilter>(
|
||||
source: source,
|
||||
title: context.l10n.albumPageTitle,
|
||||
sortFactor: settings.albumSortFactor,
|
||||
groupable: true,
|
||||
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
|
||||
chipSetActionDelegate: AlbumChipSetActionDelegate(),
|
||||
chipActionDelegate: AlbumChipActionDelegate(),
|
||||
chipActionsBuilder: (filter) => [
|
||||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||
ChipAction.setCover,
|
||||
ChipAction.rename,
|
||||
ChipAction.delete,
|
||||
ChipAction.hide,
|
||||
],
|
||||
chipActionsBuilder: (filter) {
|
||||
final dir = VolumeRelativeDirectory.fromPath(filter.album);
|
||||
// do not allow renaming volume root
|
||||
final canRename = dir != null && dir.relativeDir.isNotEmpty;
|
||||
return [
|
||||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||
ChipAction.setCover,
|
||||
if (canRename) ChipAction.rename,
|
||||
ChipAction.delete,
|
||||
ChipAction.hide,
|
||||
];
|
||||
},
|
||||
filterSections: getAlbumEntries(context, source),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.album,
|
||||
|
@ -60,7 +66,7 @@ class AlbumListPage extends StatelessWidget {
|
|||
// common with album selection page to move/copy entries
|
||||
|
||||
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> getAlbumEntries(BuildContext context, CollectionSource source) {
|
||||
final filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getUniqueAlbumName(context, album))).toSet();
|
||||
final filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getAlbumDisplayName(context, album))).toSet();
|
||||
|
||||
final sorted = FilterNavigationPage.sort(settings.albumSortFactor, source, filters);
|
||||
return _group(context, sorted);
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:aves/model/actions/chip_actions.dart';
|
||||
import 'package:aves/model/actions/move_type.dart';
|
||||
import 'package:aves/model/covers.dart';
|
||||
|
@ -7,7 +9,6 @@ import 'package:aves/model/filters/filters.dart';
|
|||
import 'package:aves/model/highlight.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/services/android_file_service.dart';
|
||||
import 'package:aves/services/image_op_events.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
|
@ -22,7 +23,6 @@ import 'package:aves/widgets/filter_grids/albums_page.dart';
|
|||
import 'package:aves/widgets/filter_grids/countries_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/tags_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
|
@ -132,16 +132,19 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
|
|||
}
|
||||
|
||||
Future<void> _showDeleteDialog(BuildContext context, AlbumFilter filter) async {
|
||||
final l10n = context.l10n;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final source = context.read<CollectionSource>();
|
||||
final selection = source.visibleEntries.where(filter.test).toSet();
|
||||
final count = selection.length;
|
||||
final album = filter.album;
|
||||
final todoEntries = source.visibleEntries.where(filter.test).toSet();
|
||||
final todoCount = todoEntries.length;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesDialog(
|
||||
context: context,
|
||||
content: Text(context.l10n.deleteAlbumConfirmationDialogMessage(count)),
|
||||
content: Text(l10n.deleteAlbumConfirmationDialogMessage(todoCount)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
@ -149,7 +152,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
|
|||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.deleteButtonLabel),
|
||||
child: Text(l10n.deleteButtonLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -157,41 +160,50 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
|
|||
);
|
||||
if (confirmed == null || !confirmed) return;
|
||||
|
||||
if (!await checkStoragePermission(context, selection)) return;
|
||||
if (!await checkStoragePermissionForAlbums(context, {album})) return;
|
||||
|
||||
final selectionCount = selection.length;
|
||||
source.pauseMonitoring();
|
||||
showOpReport<ImageOpEvent>(
|
||||
context: context,
|
||||
opStream: imageFileService.delete(selection),
|
||||
itemCount: selectionCount,
|
||||
opStream: imageFileService.delete(todoEntries),
|
||||
itemCount: todoCount,
|
||||
onDone: (processed) async {
|
||||
final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet();
|
||||
await source.removeEntries(deletedUris);
|
||||
source.resumeMonitoring();
|
||||
|
||||
final deletedCount = deletedUris.length;
|
||||
if (deletedCount < selectionCount) {
|
||||
final count = selectionCount - deletedCount;
|
||||
showFeedback(context, context.l10n.collectionDeleteFailureFeedback(count));
|
||||
if (deletedCount < todoCount) {
|
||||
final count = todoCount - deletedCount;
|
||||
showFeedbackWithMessenger(messenger, l10n.collectionDeleteFailureFeedback(count));
|
||||
}
|
||||
|
||||
// cleanup
|
||||
await storageService.deleteEmptyDirectories({album});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showRenameDialog(BuildContext context, AlbumFilter filter) async {
|
||||
final l10n = context.l10n;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final source = context.read<CollectionSource>();
|
||||
final album = filter.album;
|
||||
final todoEntries = source.visibleEntries.where(filter.test).toSet();
|
||||
final todoCount = todoEntries.length;
|
||||
|
||||
final dir = VolumeRelativeDirectory.fromPath(album);
|
||||
// do not allow renaming volume root
|
||||
if (dir == null || dir.relativeDir.isEmpty) return;
|
||||
|
||||
// check whether renaming is possible given OS restrictions,
|
||||
// before asking to input a new name
|
||||
final restrictedDirs = await AndroidFileService.getRestrictedDirectories();
|
||||
final dir = VolumeRelativeDirectory.fromPath(album);
|
||||
final restrictedDirs = await storageService.getRestrictedDirectories();
|
||||
if (restrictedDirs.contains(dir)) {
|
||||
await showRestrictedDirectoryDialog(context, dir);
|
||||
return;
|
||||
}
|
||||
|
||||
final source = context.read<CollectionSource>();
|
||||
final newName = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => RenameAlbumDialog(album),
|
||||
|
@ -200,15 +212,15 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
|
|||
|
||||
if (!await checkStoragePermissionForAlbums(context, {album})) return;
|
||||
|
||||
final todoEntries = source.visibleEntries.where(filter.test).toSet();
|
||||
final destinationAlbum = path.join(path.dirname(album), newName);
|
||||
|
||||
final destinationAlbumParent = pContext.dirname(album);
|
||||
final destinationAlbum = pContext.join(destinationAlbumParent, newName);
|
||||
if (!await checkFreeSpaceForMove(context, todoEntries, destinationAlbum, MoveType.move)) return;
|
||||
|
||||
final l10n = context.l10n;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
if (!(await File(destinationAlbum).exists())) {
|
||||
// access to the destination parent is required to create the underlying destination folder
|
||||
if (!await checkStoragePermissionForAlbums(context, {destinationAlbumParent})) return;
|
||||
}
|
||||
|
||||
final todoCount = todoEntries.length;
|
||||
source.pauseMonitoring();
|
||||
showOpReport<MoveOpEvent>(
|
||||
context: context,
|
||||
|
@ -226,6 +238,9 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
|
|||
} else {
|
||||
showFeedbackWithMessenger(messenger, l10n.genericSuccessFeedback);
|
||||
}
|
||||
|
||||
// cleanup
|
||||
await storageService.deleteEmptyDirectories({album});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,10 +2,12 @@ import 'package:aves/model/actions/chip_actions.dart';
|
|||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
||||
import 'package:aves/widgets/stats/stats.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
abstract class ChipSetActionDelegate {
|
||||
|
@ -39,6 +41,8 @@ abstract class ChipSetActionDelegate {
|
|||
title: context.l10n.chipSortTitle,
|
||||
),
|
||||
);
|
||||
// wait for the dialog to hide as applying the change may block the UI
|
||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||
if (factor != null) {
|
||||
sortFactor = factor;
|
||||
}
|
||||
|
@ -90,6 +94,8 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate {
|
|||
title: context.l10n.albumGroupTitle,
|
||||
),
|
||||
);
|
||||
// wait for the dialog to hide as applying the change may block the UI
|
||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||
if (factor != null) {
|
||||
settings.albumGroupFactor = factor;
|
||||
}
|
||||
|
|
|
@ -93,7 +93,7 @@ class DecoratedFilterChip extends StatelessWidget {
|
|||
);
|
||||
final radius = min<double>(AvesFilterChip.defaultRadius, extent / 4);
|
||||
final titlePadding = min<double>(4.0, extent / 32);
|
||||
final borderRadius = BorderRadius.all(Radius.circular(radius));
|
||||
final borderRadius = BorderRadius.circular(radius);
|
||||
Widget child = AvesFilterChip(
|
||||
filter: filter,
|
||||
showGenericIcon: false,
|
||||
|
|
42
lib/widgets/filter_grids/common/draggable_thumb_label.dart
Normal file
42
lib/widgets/filter_grids/common/draggable_thumb_label.dart
Normal file
|
@ -0,0 +1,42 @@
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/grid/draggable_thumb_label.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class FilterDraggableThumbLabel<T extends CollectionFilter> extends StatelessWidget {
|
||||
final ChipSortFactor sortFactor;
|
||||
final double offsetY;
|
||||
|
||||
const FilterDraggableThumbLabel({
|
||||
@required this.sortFactor,
|
||||
@required this.offsetY,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DraggableThumbLabel<FilterGridItem<T>>(
|
||||
offsetY: offsetY,
|
||||
lineBuilder: (context, filterGridItem) {
|
||||
switch (sortFactor) {
|
||||
case ChipSortFactor.count:
|
||||
return [
|
||||
context.l10n.itemCount(context.read<CollectionSource>().count(filterGridItem.filter)),
|
||||
];
|
||||
break;
|
||||
case ChipSortFactor.date:
|
||||
return [
|
||||
DraggableThumbLabel.formatMonthThumbLabel(context, filterGridItem.entry.bestDate),
|
||||
];
|
||||
case ChipSortFactor.name:
|
||||
return [
|
||||
filterGridItem.filter.getLabel(context),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@ import 'package:aves/model/covers.dart';
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/highlight.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/common/basic/draggable_scrollbar.dart';
|
||||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
|
@ -20,12 +21,14 @@ import 'package:aves/widgets/common/scaling.dart';
|
|||
import 'package:aves/widgets/common/tile_extent_controller.dart';
|
||||
import 'package:aves/widgets/drawer/app_drawer.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/draggable_thumb_label.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/section_layout.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
typedef QueryTest<T extends CollectionFilter> = Iterable<FilterGridItem<T>> Function(Iterable<FilterGridItem<T>> filters, String query);
|
||||
|
||||
|
@ -34,6 +37,7 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
final Widget appBar;
|
||||
final double appBarHeight;
|
||||
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
|
||||
final ChipSortFactor sortFactor;
|
||||
final bool showHeaders;
|
||||
final ValueNotifier<String> queryNotifier;
|
||||
final QueryTest<T> applyQuery;
|
||||
|
@ -47,6 +51,7 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
@required this.appBar,
|
||||
this.appBarHeight = kToolbarHeight,
|
||||
@required this.filterSections,
|
||||
@required this.sortFactor,
|
||||
@required this.showHeaders,
|
||||
@required this.queryNotifier,
|
||||
this.applyQuery,
|
||||
|
@ -72,6 +77,7 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
appBar: appBar,
|
||||
appBarHeight: appBarHeight,
|
||||
filterSections: filterSections,
|
||||
sortFactor: sortFactor,
|
||||
showHeaders: showHeaders,
|
||||
queryNotifier: queryNotifier,
|
||||
applyQuery: applyQuery,
|
||||
|
@ -95,6 +101,7 @@ class FilterGrid<T extends CollectionFilter> extends StatefulWidget {
|
|||
final Widget appBar;
|
||||
final double appBarHeight;
|
||||
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
|
||||
final ChipSortFactor sortFactor;
|
||||
final bool showHeaders;
|
||||
final ValueNotifier<String> queryNotifier;
|
||||
final QueryTest<T> applyQuery;
|
||||
|
@ -108,6 +115,7 @@ class FilterGrid<T extends CollectionFilter> extends StatefulWidget {
|
|||
@required this.appBar,
|
||||
@required this.appBarHeight,
|
||||
@required this.filterSections,
|
||||
@required this.sortFactor,
|
||||
@required this.showHeaders,
|
||||
@required this.queryNotifier,
|
||||
@required this.applyQuery,
|
||||
|
@ -137,6 +145,7 @@ class _FilterGridState<T extends CollectionFilter> extends State<FilterGrid<T>>
|
|||
appBar: widget.appBar,
|
||||
appBarHeight: widget.appBarHeight,
|
||||
filterSections: widget.filterSections,
|
||||
sortFactor: widget.sortFactor,
|
||||
showHeaders: widget.showHeaders,
|
||||
queryNotifier: widget.queryNotifier,
|
||||
applyQuery: widget.applyQuery,
|
||||
|
@ -151,6 +160,7 @@ class _FilterGridState<T extends CollectionFilter> extends State<FilterGrid<T>>
|
|||
class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
|
||||
final Widget appBar;
|
||||
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
|
||||
final ChipSortFactor sortFactor;
|
||||
final bool showHeaders;
|
||||
final ValueNotifier<String> queryNotifier;
|
||||
final Widget Function() emptyBuilder;
|
||||
|
@ -165,6 +175,7 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
|
|||
@required this.appBar,
|
||||
@required double appBarHeight,
|
||||
@required this.filterSections,
|
||||
@required this.sortFactor,
|
||||
@required this.showHeaders,
|
||||
@required this.queryNotifier,
|
||||
@required this.applyQuery,
|
||||
|
@ -197,38 +208,48 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
|
|||
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
|
||||
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
|
||||
builder: (context, tileExtent, child) {
|
||||
final columnCount = context.select<TileExtentController, int>((controller) => controller.getEffectiveColumnCountForExtent(tileExtent));
|
||||
final tileSpacing = context.select<TileExtentController, double>((controller) => controller.spacing);
|
||||
return SectionedFilterListLayoutProvider<T>(
|
||||
sections: visibleFilterSections,
|
||||
showHeaders: showHeaders,
|
||||
scrollableWidth: context.select<TileExtentController, double>((controller) => controller.viewportSize.width),
|
||||
tileExtent: tileExtent,
|
||||
columnCount: columnCount,
|
||||
spacing: tileSpacing,
|
||||
tileBuilder: (gridItem) {
|
||||
final filter = gridItem.filter;
|
||||
final entry = gridItem.entry;
|
||||
return MetaData(
|
||||
metaData: ScalerMetadata(FilterGridItem<T>(filter, entry)),
|
||||
child: DecoratedFilterChip(
|
||||
key: Key(filter.key),
|
||||
filter: filter,
|
||||
extent: tileExtent,
|
||||
pinned: pinnedFilters.contains(filter),
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: _FilterSectionedContent<T>(
|
||||
appBar: appBar,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
visibleFilterSections: visibleFilterSections,
|
||||
emptyBuilder: emptyBuilder,
|
||||
scrollController: PrimaryScrollController.of(context),
|
||||
),
|
||||
);
|
||||
return Selector<TileExtentController, Tuple3<double, int, double>>(
|
||||
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
|
||||
builder: (context, c, child) {
|
||||
final scrollableWidth = c.item1;
|
||||
final columnCount = c.item2;
|
||||
final tileSpacing = c.item3;
|
||||
// do not listen for animation delay change
|
||||
final controller = Provider.of<TileExtentController>(context, listen: false);
|
||||
final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget);
|
||||
return SectionedFilterListLayoutProvider<T>(
|
||||
sections: visibleFilterSections,
|
||||
showHeaders: showHeaders,
|
||||
scrollableWidth: scrollableWidth,
|
||||
tileExtent: tileExtent,
|
||||
columnCount: columnCount,
|
||||
spacing: tileSpacing,
|
||||
tileBuilder: (gridItem) {
|
||||
final filter = gridItem.filter;
|
||||
final entry = gridItem.entry;
|
||||
return MetaData(
|
||||
metaData: ScalerMetadata(FilterGridItem<T>(filter, entry)),
|
||||
child: DecoratedFilterChip(
|
||||
key: Key(filter.key),
|
||||
filter: filter,
|
||||
extent: tileExtent,
|
||||
pinned: pinnedFilters.contains(filter),
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
),
|
||||
);
|
||||
},
|
||||
tileAnimationDelay: tileAnimationDelay,
|
||||
child: _FilterSectionedContent<T>(
|
||||
appBar: appBar,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
visibleFilterSections: visibleFilterSections,
|
||||
sortFactor: sortFactor,
|
||||
emptyBuilder: emptyBuilder,
|
||||
scrollController: PrimaryScrollController.of(context),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
return sectionedListLayoutProvider;
|
||||
|
@ -241,6 +262,7 @@ class _FilterSectionedContent<T extends CollectionFilter> extends StatefulWidget
|
|||
final Widget appBar;
|
||||
final ValueNotifier<double> appBarHeightNotifier;
|
||||
final Map<ChipSectionKey, List<FilterGridItem<T>>> visibleFilterSections;
|
||||
final ChipSortFactor sortFactor;
|
||||
final Widget Function() emptyBuilder;
|
||||
final ScrollController scrollController;
|
||||
|
||||
|
@ -248,6 +270,7 @@ class _FilterSectionedContent<T extends CollectionFilter> extends StatefulWidget
|
|||
@required this.appBar,
|
||||
@required this.appBarHeightNotifier,
|
||||
@required this.visibleFilterSections,
|
||||
@required this.sortFactor,
|
||||
@required this.emptyBuilder,
|
||||
@required this.scrollController,
|
||||
});
|
||||
|
@ -282,6 +305,7 @@ class _FilterSectionedContentState<T extends CollectionFilter> extends State<_Fi
|
|||
scrollableKey: _scrollableKey,
|
||||
appBar: appBar,
|
||||
appBarHeightNotifier: appBarHeightNotifier,
|
||||
sortFactor: widget.sortFactor,
|
||||
emptyBuilder: emptyBuilder,
|
||||
scrollController: scrollController,
|
||||
),
|
||||
|
@ -380,6 +404,7 @@ class _FilterScrollView<T extends CollectionFilter> extends StatelessWidget {
|
|||
final GlobalKey scrollableKey;
|
||||
final Widget appBar;
|
||||
final ValueNotifier<double> appBarHeightNotifier;
|
||||
final ChipSortFactor sortFactor;
|
||||
final Widget Function() emptyBuilder;
|
||||
final ScrollController scrollController;
|
||||
|
||||
|
@ -387,6 +412,7 @@ class _FilterScrollView<T extends CollectionFilter> extends StatelessWidget {
|
|||
@required this.scrollableKey,
|
||||
@required this.appBar,
|
||||
@required this.appBarHeightNotifier,
|
||||
@required this.sortFactor,
|
||||
@required this.emptyBuilder,
|
||||
@required this.scrollController,
|
||||
});
|
||||
|
@ -413,6 +439,10 @@ class _FilterScrollView<T extends CollectionFilter> extends StatelessWidget {
|
|||
top: appBarHeightNotifier.value,
|
||||
bottom: mqPaddingBottom,
|
||||
),
|
||||
labelTextBuilder: (offsetY) => FilterDraggableThumbLabel<T>(
|
||||
sortFactor: sortFactor,
|
||||
offsetY: offsetY,
|
||||
),
|
||||
child: scrollView,
|
||||
),
|
||||
);
|
||||
|
|
|
@ -28,6 +28,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
final CollectionSource source;
|
||||
final String title;
|
||||
final ChipSetActionDelegate chipSetActionDelegate;
|
||||
final ChipSortFactor sortFactor;
|
||||
final bool groupable, showHeaders;
|
||||
final ChipActionDelegate chipActionDelegate;
|
||||
final List<ChipAction> Function(T filter) chipActionsBuilder;
|
||||
|
@ -37,6 +38,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
const FilterNavigationPage({
|
||||
@required this.source,
|
||||
@required this.title,
|
||||
@required this.sortFactor,
|
||||
this.groupable = false,
|
||||
this.showHeaders = false,
|
||||
@required this.chipSetActionDelegate,
|
||||
|
@ -64,6 +66,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
floating: true,
|
||||
),
|
||||
filterSections: filterSections,
|
||||
sortFactor: sortFactor,
|
||||
showHeaders: showHeaders,
|
||||
queryNotifier: ValueNotifier(''),
|
||||
emptyBuilder: () => ValueListenableBuilder<SourceState>(
|
||||
|
|
|
@ -14,6 +14,7 @@ class SectionedFilterListLayoutProvider<T extends CollectionFilter> extends Sect
|
|||
double spacing = 0,
|
||||
@required double tileExtent,
|
||||
@required Widget Function(FilterGridItem<T> gridItem) tileBuilder,
|
||||
@required Duration tileAnimationDelay,
|
||||
@required Widget child,
|
||||
}) : super(
|
||||
scrollableWidth: scrollableWidth,
|
||||
|
@ -21,6 +22,7 @@ class SectionedFilterListLayoutProvider<T extends CollectionFilter> extends Sect
|
|||
spacing: spacing,
|
||||
tileExtent: tileExtent,
|
||||
tileBuilder: tileBuilder,
|
||||
tileAnimationDelay: tileAnimationDelay,
|
||||
child: child,
|
||||
);
|
||||
|
||||
|
|
|
@ -31,6 +31,7 @@ class CountryListPage extends StatelessWidget {
|
|||
builder: (context, snapshot) => FilterNavigationPage<LocationFilter>(
|
||||
source: source,
|
||||
title: context.l10n.countryPageTitle,
|
||||
sortFactor: settings.countrySortFactor,
|
||||
chipSetActionDelegate: CountryChipSetActionDelegate(),
|
||||
chipActionDelegate: ChipActionDelegate(),
|
||||
chipActionsBuilder: (filter) => [
|
||||
|
|
|
@ -31,6 +31,7 @@ class TagListPage extends StatelessWidget {
|
|||
builder: (context, snapshot) => FilterNavigationPage<TagFilter>(
|
||||
source: source,
|
||||
title: context.l10n.tagPageTitle,
|
||||
sortFactor: settings.tagSortFactor,
|
||||
chipSetActionDelegate: TagChipSetActionDelegate(),
|
||||
chipActionDelegate: ChipActionDelegate(),
|
||||
chipActionsBuilder: (filter) => [
|
||||
|
|
|
@ -106,7 +106,13 @@ class _HomePageState extends State<HomePage> {
|
|||
unawaited(source.refresh());
|
||||
}
|
||||
|
||||
unawaited(Navigator.pushReplacement(context, _getRedirectRoute(appMode)));
|
||||
// `pushReplacement` is not enough in some edge cases
|
||||
// e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode
|
||||
unawaited(Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
_getRedirectRoute(appMode),
|
||||
(route) => false,
|
||||
));
|
||||
}
|
||||
|
||||
Future<AvesEntry> _initViewerEntry({@required String uri, @required String mimeType}) async {
|
||||
|
|
|
@ -30,14 +30,14 @@ class CollectionSearchDelegate {
|
|||
|
||||
static const searchHistoryCount = 10;
|
||||
static final typeFilters = [
|
||||
FavouriteFilter(),
|
||||
MimeFilter(MimeTypes.anyImage),
|
||||
MimeFilter(MimeTypes.anyVideo),
|
||||
FavouriteFilter.instance,
|
||||
MimeFilter.image,
|
||||
MimeFilter.video,
|
||||
TypeFilter.animated,
|
||||
TypeFilter.panorama,
|
||||
TypeFilter.sphericalVideo,
|
||||
TypeFilter.geotiff,
|
||||
MimeFilter(MimeTypes.svg),
|
||||
TypeFilter(TypeFilter.animated),
|
||||
TypeFilter(TypeFilter.panorama),
|
||||
TypeFilter(TypeFilter.sphericalVideo),
|
||||
TypeFilter(TypeFilter.geotiff),
|
||||
];
|
||||
|
||||
CollectionSearchDelegate({@required this.source, this.parentCollection});
|
||||
|
@ -87,7 +87,14 @@ class CollectionSearchDelegate {
|
|||
selector: (context, s) => s.hiddenFilters,
|
||||
builder: (context, hiddenFilters, child) {
|
||||
bool notHidden(CollectionFilter filter) => !hiddenFilters.contains(filter);
|
||||
|
||||
final visibleTypeFilters = typeFilters.where(notHidden).toList();
|
||||
if (hiddenFilters.contains(MimeFilter.video)) {
|
||||
[MimeFilter.image, TypeFilter.sphericalVideo].forEach(visibleTypeFilters.remove);
|
||||
}
|
||||
|
||||
final history = settings.searchHistory.where(notHidden).toList();
|
||||
|
||||
return ListView(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
children: [
|
||||
|
@ -95,7 +102,7 @@ class CollectionSearchDelegate {
|
|||
context: context,
|
||||
filters: [
|
||||
queryFilter,
|
||||
...typeFilters.where(notHidden),
|
||||
...visibleTypeFilters,
|
||||
].where((f) => f != null && containQuery(f.getLabel(context))).toList(),
|
||||
// usually perform hero animation only on tapped chips,
|
||||
// but we also need to animate the query chip when it is selected by submitting the search query
|
||||
|
@ -110,8 +117,14 @@ class CollectionSearchDelegate {
|
|||
StreamBuilder(
|
||||
stream: source.eventBus.on<AlbumsChangedEvent>(),
|
||||
builder: (context, snapshot) {
|
||||
// filter twice: full path, and then unique name
|
||||
final filters = source.rawAlbums.where(containQuery).map((s) => AlbumFilter(s, source.getUniqueAlbumName(context, s))).where((f) => containQuery(f.uniqueName)).toList()..sort();
|
||||
final filters = source.rawAlbums
|
||||
.map((album) => AlbumFilter(
|
||||
album,
|
||||
source.getAlbumDisplayName(context, album),
|
||||
))
|
||||
.where((filter) => containQuery(filter.album) || containQuery(filter.displayName))
|
||||
.toList()
|
||||
..sort();
|
||||
return _buildFilterRow(
|
||||
context: context,
|
||||
title: context.l10n.searchSectionAlbums,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:aves/services/android_file_service.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/empty.dart';
|
||||
|
@ -39,7 +39,7 @@ class _StorageAccessPageState extends State<StorageAccessPage> {
|
|||
_load();
|
||||
}
|
||||
|
||||
void _load() => _pathLoader = AndroidFileService.getGrantedDirectories();
|
||||
void _load() => _pathLoader = storageService.getGrantedDirectories();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -87,7 +87,7 @@ class _StorageAccessPageState extends State<StorageAccessPage> {
|
|||
trailing: IconButton(
|
||||
icon: Icon(AIcons.clear),
|
||||
onPressed: () async {
|
||||
await AndroidFileService.revokeDirectoryAccess(path);
|
||||
await storageService.revokeDirectoryAccess(path);
|
||||
_load();
|
||||
setState(() {});
|
||||
},
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import 'dart:collection';
|
||||
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_localized_locales/flutter_localized_locales.dart';
|
||||
|
@ -30,6 +32,8 @@ class LanguageTile extends StatelessWidget {
|
|||
title: context.l10n.settingsLanguage,
|
||||
),
|
||||
);
|
||||
// wait for the dialog to hide as applying the change may block the UI
|
||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||
if (value != null) {
|
||||
settings.locale = value == _systemLocaleOption ? null : value;
|
||||
}
|
||||
|
|
103
lib/widgets/settings/quick_actions/available_actions.dart
Normal file
103
lib/widgets/settings/quick_actions/available_actions.dart
Normal file
|
@ -0,0 +1,103 @@
|
|||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/settings/quick_actions/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AvailableActionPanel extends StatelessWidget {
|
||||
final List<EntryAction> quickActions;
|
||||
final Listenable quickActionsChangeNotifier;
|
||||
final ValueNotifier<bool> panelHighlight;
|
||||
final ValueNotifier<EntryAction> draggedQuickAction;
|
||||
final ValueNotifier<EntryAction> draggedAvailableAction;
|
||||
final bool Function(EntryAction action) removeQuickAction;
|
||||
|
||||
const AvailableActionPanel({
|
||||
@required this.quickActions,
|
||||
@required this.quickActionsChangeNotifier,
|
||||
@required this.panelHighlight,
|
||||
@required this.draggedQuickAction,
|
||||
@required this.draggedAvailableAction,
|
||||
@required this.removeQuickAction,
|
||||
});
|
||||
|
||||
static const allActions = [
|
||||
EntryAction.info,
|
||||
EntryAction.toggleFavourite,
|
||||
EntryAction.share,
|
||||
EntryAction.delete,
|
||||
EntryAction.rename,
|
||||
EntryAction.export,
|
||||
EntryAction.print,
|
||||
EntryAction.viewSource,
|
||||
EntryAction.flip,
|
||||
EntryAction.rotateCCW,
|
||||
EntryAction.rotateCW,
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DragTarget<EntryAction>(
|
||||
onWillAccept: (data) {
|
||||
if (draggedQuickAction.value != null) {
|
||||
_setPanelHighlight(true);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
onAcceptWithDetails: (details) {
|
||||
removeQuickAction(draggedQuickAction.value);
|
||||
_setDraggedQuickAction(null);
|
||||
_setPanelHighlight(false);
|
||||
},
|
||||
onLeave: (data) => _setPanelHighlight(false),
|
||||
builder: (context, accepted, rejected) {
|
||||
return AnimatedBuilder(
|
||||
animation: Listenable.merge([quickActionsChangeNotifier, draggedAvailableAction]),
|
||||
builder: (context, child) => Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.spaceEvenly,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: allActions.map((action) {
|
||||
final dragged = action == draggedAvailableAction.value;
|
||||
final enabled = dragged || !quickActions.contains(action);
|
||||
Widget child = ActionButton(
|
||||
action: action,
|
||||
enabled: enabled,
|
||||
);
|
||||
if (dragged) {
|
||||
child = DraggedPlaceholder(child: child);
|
||||
}
|
||||
if (enabled) {
|
||||
child = _buildDraggable(action, child);
|
||||
}
|
||||
return child;
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDraggable(EntryAction action, Widget child) => LongPressDraggable<EntryAction>(
|
||||
data: action,
|
||||
maxSimultaneousDrags: 1,
|
||||
onDragStarted: () => _setDraggedAvailableAction(action),
|
||||
onDragEnd: (details) => _setDraggedAvailableAction(null),
|
||||
feedback: MediaQueryDataProvider(
|
||||
child: ActionButton(
|
||||
action: action,
|
||||
showCaption: false,
|
||||
),
|
||||
),
|
||||
childWhenDragging: child,
|
||||
child: child,
|
||||
);
|
||||
|
||||
void _setDraggedQuickAction(EntryAction action) => draggedQuickAction.value = action;
|
||||
|
||||
void _setDraggedAvailableAction(EntryAction action) => draggedAvailableAction.value = action;
|
||||
|
||||
void _setPanelHighlight(bool flag) => panelHighlight.value = flag;
|
||||
}
|
95
lib/widgets/settings/quick_actions/common.dart
Normal file
95
lib/widgets/settings/quick_actions/common.dart
Normal file
|
@ -0,0 +1,95 @@
|
|||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/viewer/overlay/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ActionPanel extends StatelessWidget {
|
||||
final bool highlight;
|
||||
final Widget child;
|
||||
|
||||
const ActionPanel({
|
||||
this.highlight = false,
|
||||
@required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = highlight ? Theme.of(context).accentColor : Colors.blueGrey;
|
||||
return AnimatedContainer(
|
||||
foregroundDecoration: BoxDecoration(
|
||||
color: color.withOpacity(.2),
|
||||
border: Border.all(
|
||||
color: color,
|
||||
width: highlight ? 2 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
margin: EdgeInsets.all(16),
|
||||
duration: Durations.quickActionHighlightAnimation,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ActionButton extends StatelessWidget {
|
||||
final EntryAction action;
|
||||
final bool enabled, showCaption;
|
||||
|
||||
const ActionButton({
|
||||
@required this.action,
|
||||
this.enabled = true,
|
||||
this.showCaption = true,
|
||||
});
|
||||
|
||||
static const padding = 8.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textStyle = Theme.of(context).textTheme.caption;
|
||||
return SizedBox(
|
||||
width: OverlayButton.getSize(context) + padding * 2,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: padding),
|
||||
OverlayButton(
|
||||
child: IconButton(
|
||||
icon: Icon(action.getIcon()),
|
||||
onPressed: enabled ? () {} : null,
|
||||
),
|
||||
),
|
||||
if (showCaption) ...[
|
||||
SizedBox(height: padding),
|
||||
Text(
|
||||
action.getText(context),
|
||||
style: enabled ? textStyle : textStyle.copyWith(color: textStyle.color.withOpacity(.2)),
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
SizedBox(height: padding),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DraggedPlaceholder extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const DraggedPlaceholder({
|
||||
@required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Opacity(
|
||||
opacity: .2,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
307
lib/widgets/settings/quick_actions/editor.dart
Normal file
307
lib/widgets/settings/quick_actions/editor.dart
Normal file
|
@ -0,0 +1,307 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/settings/quick_actions/available_actions.dart';
|
||||
import 'package:aves/widgets/settings/quick_actions/common.dart';
|
||||
import 'package:aves/widgets/settings/quick_actions/quick_actions.dart';
|
||||
import 'package:aves/widgets/viewer/overlay/common.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class QuickActionsTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(context.l10n.settingsViewerQuickActionsTile),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: RouteSettings(name: QuickActionEditorPage.routeName),
|
||||
builder: (context) => QuickActionEditorPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class QuickActionEditorPage extends StatefulWidget {
|
||||
static const routeName = '/settings/quick_actions';
|
||||
|
||||
@override
|
||||
_QuickActionEditorPageState createState() => _QuickActionEditorPageState();
|
||||
}
|
||||
|
||||
class _QuickActionEditorPageState extends State<QuickActionEditorPage> {
|
||||
final GlobalKey<AnimatedListState> _animatedListKey = GlobalKey(debugLabel: 'quick-actions-animated-list');
|
||||
Timer _targetLeavingTimer;
|
||||
List<EntryAction> _quickActions;
|
||||
final ValueNotifier<EntryAction> _draggedQuickAction = ValueNotifier(null);
|
||||
final ValueNotifier<EntryAction> _draggedAvailableAction = ValueNotifier(null);
|
||||
final ValueNotifier<bool> _quickActionHighlight = ValueNotifier(false);
|
||||
final ValueNotifier<bool> _availableActionHighlight = ValueNotifier(false);
|
||||
final AChangeNotifier _quickActionsChangeNotifier = AChangeNotifier();
|
||||
|
||||
// use a flag to prevent quick action target accept/leave when already animating reorder
|
||||
// as dragging a button against axis direction messes index resolution while items pop in and out
|
||||
bool _reordering = false;
|
||||
|
||||
static const quickActionVerticalPadding = 16.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_quickActions = settings.viewerQuickActions.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopLeavingTimer();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onQuickActionTargetLeave() {
|
||||
_stopLeavingTimer();
|
||||
final action = _draggedAvailableAction.value;
|
||||
_targetLeavingTimer = Timer(Durations.quickActionListAnimation + Duration(milliseconds: 50), () {
|
||||
_removeQuickAction(action);
|
||||
_quickActionHighlight.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final header = QuickActionButton(
|
||||
placement: QuickActionPlacement.header,
|
||||
panelHighlight: _quickActionHighlight,
|
||||
draggedQuickAction: _draggedQuickAction,
|
||||
draggedAvailableAction: _draggedAvailableAction,
|
||||
insertAction: _insertQuickAction,
|
||||
removeAction: _removeQuickAction,
|
||||
onTargetLeave: _onQuickActionTargetLeave,
|
||||
);
|
||||
final footer = QuickActionButton(
|
||||
placement: QuickActionPlacement.footer,
|
||||
panelHighlight: _quickActionHighlight,
|
||||
draggedQuickAction: _draggedQuickAction,
|
||||
draggedAvailableAction: _draggedAvailableAction,
|
||||
insertAction: _insertQuickAction,
|
||||
removeAction: _removeQuickAction,
|
||||
onTargetLeave: _onQuickActionTargetLeave,
|
||||
);
|
||||
return MediaQueryDataProvider(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.settingsViewerQuickActionEditorTitle),
|
||||
),
|
||||
body: WillPopScope(
|
||||
onWillPop: () {
|
||||
settings.viewerQuickActions = _quickActions;
|
||||
return SynchronousFuture(true);
|
||||
},
|
||||
child: SafeArea(
|
||||
child: ListView(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(AIcons.info),
|
||||
SizedBox(width: 16),
|
||||
Expanded(child: Text(context.l10n.settingsViewerQuickActionEditorBanner)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
context.l10n.settingsViewerQuickActionEditorDisplayedButtons,
|
||||
style: Constants.titleTextStyle,
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _quickActionHighlight,
|
||||
builder: (context, highlight, child) => ActionPanel(
|
||||
highlight: highlight,
|
||||
child: child,
|
||||
),
|
||||
child: Container(
|
||||
height: OverlayButton.getSize(context) + quickActionVerticalPadding * 2,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: .5,
|
||||
child: header,
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: FractionallySizedBox(
|
||||
alignment: Alignment.centerRight,
|
||||
widthFactor: .5,
|
||||
child: footer,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
alignment: Alignment.center,
|
||||
child: AnimatedList(
|
||||
key: _animatedListKey,
|
||||
initialItemCount: _quickActions.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
itemBuilder: (context, index, animation) {
|
||||
if (index >= _quickActions.length) return null;
|
||||
final action = _quickActions[index];
|
||||
return QuickActionButton(
|
||||
placement: QuickActionPlacement.action,
|
||||
action: action,
|
||||
panelHighlight: _quickActionHighlight,
|
||||
draggedQuickAction: _draggedQuickAction,
|
||||
draggedAvailableAction: _draggedAvailableAction,
|
||||
insertAction: _insertQuickAction,
|
||||
removeAction: _removeQuickAction,
|
||||
onTargetLeave: _onQuickActionTargetLeave,
|
||||
child: _buildQuickActionButton(action, animation),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _quickActionsChangeNotifier,
|
||||
builder: (context, child) => _quickActions.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
context.l10n.settingsViewerQuickActionEmpty,
|
||||
style: Theme.of(context).textTheme.caption,
|
||||
),
|
||||
)
|
||||
: SizedBox(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
context.l10n.settingsViewerQuickActionEditorAvailableButtons,
|
||||
style: Constants.titleTextStyle,
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _availableActionHighlight,
|
||||
builder: (context, highlight, child) => ActionPanel(
|
||||
highlight: highlight,
|
||||
child: child,
|
||||
),
|
||||
child: AvailableActionPanel(
|
||||
quickActions: _quickActions,
|
||||
quickActionsChangeNotifier: _quickActionsChangeNotifier,
|
||||
panelHighlight: _availableActionHighlight,
|
||||
draggedQuickAction: _draggedQuickAction,
|
||||
draggedAvailableAction: _draggedAvailableAction,
|
||||
removeQuickAction: _removeQuickAction,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _stopLeavingTimer() => _targetLeavingTimer?.cancel();
|
||||
|
||||
bool _insertQuickAction(EntryAction action, QuickActionPlacement placement, EntryAction overAction) {
|
||||
if (action == null) return false;
|
||||
_stopLeavingTimer();
|
||||
if (_reordering) return false;
|
||||
|
||||
final currentIndex = _quickActions.indexOf(action);
|
||||
final contained = currentIndex != -1;
|
||||
int targetIndex;
|
||||
switch (placement) {
|
||||
case QuickActionPlacement.header:
|
||||
targetIndex = 0;
|
||||
break;
|
||||
case QuickActionPlacement.footer:
|
||||
targetIndex = _quickActions.length - (contained ? 1 : 0);
|
||||
break;
|
||||
case QuickActionPlacement.action:
|
||||
targetIndex = _quickActions.indexOf(overAction);
|
||||
break;
|
||||
}
|
||||
if (currentIndex == targetIndex) return false;
|
||||
|
||||
_reordering = true;
|
||||
_removeQuickAction(action);
|
||||
_quickActions.insert(targetIndex, action);
|
||||
_animatedListKey.currentState.insertItem(
|
||||
targetIndex,
|
||||
duration: Durations.quickActionListAnimation,
|
||||
);
|
||||
_quickActionsChangeNotifier.notifyListeners();
|
||||
Future.delayed(Durations.quickActionListAnimation).then((value) => _reordering = false);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _removeQuickAction(EntryAction action) {
|
||||
if (!_quickActions.contains(action)) return false;
|
||||
|
||||
final index = _quickActions.indexOf(action);
|
||||
_quickActions.removeAt(index);
|
||||
_animatedListKey.currentState.removeItem(
|
||||
index,
|
||||
(context, animation) => DraggedPlaceholder(child: _buildQuickActionButton(action, animation)),
|
||||
duration: Durations.quickActionListAnimation,
|
||||
);
|
||||
_quickActionsChangeNotifier.notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
Widget _buildQuickActionButton(EntryAction action, Animation<double> animation) {
|
||||
animation = animation.drive(CurveTween(curve: Curves.easeInOut));
|
||||
Widget child = FadeTransition(
|
||||
opacity: animation,
|
||||
child: SizeTransition(
|
||||
axis: Axis.horizontal,
|
||||
sizeFactor: animation,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: _QuickActionEditorPageState.quickActionVerticalPadding, horizontal: 4),
|
||||
child: OverlayButton(
|
||||
child: IconButton(
|
||||
icon: Icon(action.getIcon()),
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
child = AnimatedBuilder(
|
||||
animation: Listenable.merge([_draggedQuickAction, _draggedAvailableAction]),
|
||||
builder: (context, child) {
|
||||
final dragged = _draggedQuickAction.value == action || _draggedAvailableAction.value == action;
|
||||
if (dragged) {
|
||||
child = DraggedPlaceholder(child: child);
|
||||
}
|
||||
return child;
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
80
lib/widgets/settings/quick_actions/quick_actions.dart
Normal file
80
lib/widgets/settings/quick_actions/quick_actions.dart
Normal file
|
@ -0,0 +1,80 @@
|
|||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/settings/quick_actions/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum QuickActionPlacement { header, action, footer }
|
||||
|
||||
class QuickActionButton extends StatelessWidget {
|
||||
final QuickActionPlacement placement;
|
||||
final EntryAction action;
|
||||
final ValueNotifier<bool> panelHighlight;
|
||||
final ValueNotifier<EntryAction> draggedQuickAction;
|
||||
final ValueNotifier<EntryAction> draggedAvailableAction;
|
||||
final bool Function(EntryAction action, QuickActionPlacement placement, EntryAction overAction) insertAction;
|
||||
final bool Function(EntryAction action) removeAction;
|
||||
final VoidCallback onTargetLeave;
|
||||
final Widget child;
|
||||
|
||||
const QuickActionButton({
|
||||
@required this.placement,
|
||||
this.action,
|
||||
@required this.panelHighlight,
|
||||
@required this.draggedQuickAction,
|
||||
@required this.draggedAvailableAction,
|
||||
@required this.insertAction,
|
||||
@required this.removeAction,
|
||||
@required this.onTargetLeave,
|
||||
this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var child = this.child;
|
||||
child = _buildDragTarget(child);
|
||||
if (action != null) {
|
||||
child = _buildDraggable(child);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
DragTarget<EntryAction> _buildDragTarget(Widget child) {
|
||||
return DragTarget<EntryAction>(
|
||||
onWillAccept: (data) {
|
||||
if (draggedQuickAction.value != null) {
|
||||
insertAction(draggedQuickAction.value, placement, action);
|
||||
}
|
||||
if (draggedAvailableAction.value != null) {
|
||||
insertAction(draggedAvailableAction.value, placement, action);
|
||||
_setPanelHighlight(true);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
onAcceptWithDetails: (details) => _setPanelHighlight(false),
|
||||
onLeave: (data) => onTargetLeave(),
|
||||
builder: (context, accepted, rejected) => child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDraggable(Widget child) => LongPressDraggable(
|
||||
data: action,
|
||||
maxSimultaneousDrags: 1,
|
||||
onDragStarted: () => _setDraggedQuickAction(action),
|
||||
// `onDragEnd` is only called when the widget is mounted,
|
||||
// so we rely on `onDraggableCanceled` and `onDragCompleted` instead
|
||||
onDraggableCanceled: (velocity, offset) => _setDraggedQuickAction(null),
|
||||
onDragCompleted: () => _setDraggedQuickAction(null),
|
||||
feedback: MediaQueryDataProvider(
|
||||
child: ActionButton(
|
||||
action: action,
|
||||
showCaption: false,
|
||||
),
|
||||
),
|
||||
childWhenDragging: child,
|
||||
child: child,
|
||||
);
|
||||
|
||||
void _setDraggedQuickAction(EntryAction action) => draggedQuickAction.value = action;
|
||||
|
||||
void _setPanelHighlight(bool flag) => panelHighlight.value = flag;
|
||||
}
|
|
@ -1,18 +1,25 @@
|
|||
import 'package:aves/model/filters/mime.dart';
|
||||
import 'package:aves/model/settings/coordinate_format.dart';
|
||||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/model/settings/home_page.dart';
|
||||
import 'package:aves/model/settings/screen_on.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/color_utils.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.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/dialogs/aves_selection_dialog.dart';
|
||||
import 'package:aves/widgets/settings/access_grants.dart';
|
||||
import 'package:aves/widgets/settings/entry_background.dart';
|
||||
import 'package:aves/widgets/settings/hidden_filters.dart';
|
||||
import 'package:aves/widgets/settings/language.dart';
|
||||
import 'package:aves/widgets/settings/quick_actions/editor.dart';
|
||||
import 'package:decorated_icon/decorated_icon.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -58,11 +65,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
),
|
||||
children: [
|
||||
_buildNavigationSection(context),
|
||||
_buildDisplaySection(context),
|
||||
_buildThumbnailsSection(context),
|
||||
_buildViewerSection(context),
|
||||
_buildSearchSection(context),
|
||||
_buildVideoSection(context),
|
||||
_buildPrivacySection(context),
|
||||
_buildLanguageSection(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -76,8 +83,10 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
|
||||
Widget _buildNavigationSection(BuildContext context) {
|
||||
return AvesExpansionTile(
|
||||
leading: _buildLeading(AIcons.home, stringToColor('Navigation')),
|
||||
title: context.l10n.settingsSectionNavigation,
|
||||
expandedNotifier: _expandedNotifier,
|
||||
showHighlight: false,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(context.l10n.settingsHome),
|
||||
|
@ -96,21 +105,6 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
}
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.mustBackTwiceToExit,
|
||||
onChanged: (v) => settings.mustBackTwiceToExit = v,
|
||||
title: Text(context.l10n.settingsDoubleBackExit),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDisplaySection(BuildContext context) {
|
||||
return AvesExpansionTile(
|
||||
title: context.l10n.settingsSectionDisplay,
|
||||
expandedNotifier: _expandedNotifier,
|
||||
children: [
|
||||
LanguageTile(),
|
||||
ListTile(
|
||||
title: Text(context.l10n.settingsKeepScreenOnTile),
|
||||
subtitle: Text(settings.keepScreenOn.getName(context)),
|
||||
|
@ -128,6 +122,91 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
}
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.mustBackTwiceToExit,
|
||||
onChanged: (v) => settings.mustBackTwiceToExit = v,
|
||||
title: Text(context.l10n.settingsDoubleBackExit),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThumbnailsSection(BuildContext context) {
|
||||
final iconSize = IconTheme.of(context).size * MediaQuery.of(context).textScaleFactor;
|
||||
double opacityFor(bool enabled) => enabled ? 1 : .2;
|
||||
return AvesExpansionTile(
|
||||
leading: _buildLeading(AIcons.grid, stringToColor('Thumbnails')),
|
||||
title: context.l10n.settingsSectionThumbnails,
|
||||
expandedNotifier: _expandedNotifier,
|
||||
showHighlight: false,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
value: settings.showThumbnailLocation,
|
||||
onChanged: (v) => settings.showThumbnailLocation = v,
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(context.l10n.settingsThumbnailShowLocationIcon)),
|
||||
AnimatedOpacity(
|
||||
opacity: opacityFor(settings.showThumbnailLocation),
|
||||
duration: Durations.toggleableTransitionAnimation,
|
||||
child: Icon(
|
||||
AIcons.location,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.showThumbnailRaw,
|
||||
onChanged: (v) => settings.showThumbnailRaw = v,
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(context.l10n.settingsThumbnailShowRawIcon)),
|
||||
AnimatedOpacity(
|
||||
opacity: opacityFor(settings.showThumbnailRaw),
|
||||
duration: Durations.toggleableTransitionAnimation,
|
||||
child: Icon(
|
||||
AIcons.raw,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.showThumbnailVideoDuration,
|
||||
onChanged: (v) => settings.showThumbnailVideoDuration = v,
|
||||
title: Text(context.l10n.settingsThumbnailShowVideoDuration),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildViewerSection(BuildContext context) {
|
||||
return AvesExpansionTile(
|
||||
leading: _buildLeading(AIcons.image, stringToColor('Image')),
|
||||
title: context.l10n.settingsSectionViewer,
|
||||
expandedNotifier: _expandedNotifier,
|
||||
showHighlight: false,
|
||||
children: [
|
||||
QuickActionsTile(),
|
||||
SwitchListTile(
|
||||
value: settings.showOverlayMinimap,
|
||||
onChanged: (v) => settings.showOverlayMinimap = v,
|
||||
title: Text(context.l10n.settingsViewerShowMinimap),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.showOverlayInfo,
|
||||
onChanged: (v) => settings.showOverlayInfo = v,
|
||||
title: Text(context.l10n.settingsViewerShowInformation),
|
||||
subtitle: Text(context.l10n.settingsViewerShowInformationSubtitle),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.showOverlayShootingDetails,
|
||||
onChanged: settings.showOverlayInfo ? (v) => settings.showOverlayShootingDetails = v : null,
|
||||
title: Text(context.l10n.settingsViewerShowShootingDetails),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(context.l10n.settingsRasterImageBackground),
|
||||
trailing: EntryBackgroundSelector(
|
||||
|
@ -142,6 +221,67 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
setter: (value) => settings.vectorBackground = value,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVideoSection(BuildContext context) {
|
||||
final hiddenFilters = settings.hiddenFilters;
|
||||
final showVideos = !hiddenFilters.contains(MimeFilter.video);
|
||||
return AvesExpansionTile(
|
||||
leading: _buildLeading(AIcons.video, stringToColor('Video')),
|
||||
title: context.l10n.settingsSectionVideo,
|
||||
expandedNotifier: _expandedNotifier,
|
||||
showHighlight: false,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
value: showVideos,
|
||||
onChanged: (v) => context.read<CollectionSource>().changeFilterVisibility(MimeFilter.video, v),
|
||||
title: Text(context.l10n.settingsVideoShowVideos),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPrivacySection(BuildContext context) {
|
||||
return AvesExpansionTile(
|
||||
leading: _buildLeading(AIcons.privacy, stringToColor('Privacy')),
|
||||
title: context.l10n.settingsSectionPrivacy,
|
||||
expandedNotifier: _expandedNotifier,
|
||||
showHighlight: false,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
value: settings.isCrashlyticsEnabled,
|
||||
onChanged: (v) => settings.isCrashlyticsEnabled = v,
|
||||
title: Text(context.l10n.settingsEnableAnalytics),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.saveSearchHistory,
|
||||
onChanged: (v) {
|
||||
settings.saveSearchHistory = v;
|
||||
if (!v) {
|
||||
settings.searchHistory = [];
|
||||
}
|
||||
},
|
||||
title: Text(context.l10n.settingsSaveSearchHistory),
|
||||
),
|
||||
HiddenFilterTile(),
|
||||
StorageAccessTile(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLanguageSection(BuildContext context) {
|
||||
return AvesExpansionTile(
|
||||
// use a fixed value instead of the title to identify this expansion tile
|
||||
// so that the tile state is kept when the language is modified
|
||||
value: 'language',
|
||||
leading: _buildLeading(AIcons.language, stringToColor('Language')),
|
||||
title: context.l10n.settingsSectionLanguage,
|
||||
expandedNotifier: _expandedNotifier,
|
||||
showHighlight: false,
|
||||
children: [
|
||||
LanguageTile(),
|
||||
ListTile(
|
||||
title: Text(context.l10n.settingsCoordinateFormatTile),
|
||||
subtitle: Text(settings.coordinateFormat.getName(context)),
|
||||
|
@ -164,87 +304,19 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildThumbnailsSection(BuildContext context) {
|
||||
return AvesExpansionTile(
|
||||
title: context.l10n.settingsSectionThumbnails,
|
||||
expandedNotifier: _expandedNotifier,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
value: settings.showThumbnailLocation,
|
||||
onChanged: (v) => settings.showThumbnailLocation = v,
|
||||
title: Text(context.l10n.settingsThumbnailShowLocationIcon),
|
||||
Widget _buildLeading(IconData icon, Color color) => Container(
|
||||
padding: EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: color,
|
||||
width: AvesFilterChip.outlineWidth,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.showThumbnailRaw,
|
||||
onChanged: (v) => settings.showThumbnailRaw = v,
|
||||
title: Text(context.l10n.settingsThumbnailShowRawIcon),
|
||||
child: DecoratedIcon(
|
||||
icon,
|
||||
shadows: [Constants.embossShadow],
|
||||
size: 18,
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.showThumbnailVideoDuration,
|
||||
onChanged: (v) => settings.showThumbnailVideoDuration = v,
|
||||
title: Text(context.l10n.settingsThumbnailShowVideoDuration),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildViewerSection(BuildContext context) {
|
||||
return AvesExpansionTile(
|
||||
title: context.l10n.settingsSectionViewer,
|
||||
expandedNotifier: _expandedNotifier,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
value: settings.showOverlayMinimap,
|
||||
onChanged: (v) => settings.showOverlayMinimap = v,
|
||||
title: Text(context.l10n.settingsViewerShowMinimap),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.showOverlayInfo,
|
||||
onChanged: (v) => settings.showOverlayInfo = v,
|
||||
title: Text(context.l10n.settingsViewerShowInformation),
|
||||
subtitle: Text(context.l10n.settingsViewerShowInformationSubtitle),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.showOverlayShootingDetails,
|
||||
onChanged: settings.showOverlayInfo ? (v) => settings.showOverlayShootingDetails = v : null,
|
||||
title: Text(context.l10n.settingsViewerShowShootingDetails),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchSection(BuildContext context) {
|
||||
return AvesExpansionTile(
|
||||
title: context.l10n.settingsSectionSearch,
|
||||
expandedNotifier: _expandedNotifier,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
value: settings.saveSearchHistory,
|
||||
onChanged: (v) {
|
||||
settings.saveSearchHistory = v;
|
||||
if (!v) {
|
||||
settings.searchHistory = [];
|
||||
}
|
||||
},
|
||||
title: Text(context.l10n.settingsSaveSearchHistory),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPrivacySection(BuildContext context) {
|
||||
return AvesExpansionTile(
|
||||
title: context.l10n.settingsSectionPrivacy,
|
||||
expandedNotifier: _expandedNotifier,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
value: settings.isCrashlyticsEnabled,
|
||||
onChanged: (v) => settings.isCrashlyticsEnabled = v,
|
||||
title: Text(context.l10n.settingsEnableAnalytics),
|
||||
),
|
||||
HiddenFilterTile(),
|
||||
StorageAccessTile(),
|
||||
],
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,10 +3,10 @@ import 'package:aves/model/multipage.dart';
|
|||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart';
|
||||
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
|
||||
import 'package:aves/widgets/common/video/video.dart';
|
||||
import 'package:aves/widgets/viewer/multipage.dart';
|
||||
import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
|
@ -15,7 +15,7 @@ class MultiEntryScroller extends StatefulWidget {
|
|||
final PageController pageController;
|
||||
final ValueChanged<int> onPageChanged;
|
||||
final VoidCallback onTap;
|
||||
final List<Tuple2<String, IjkMediaController>> videoControllers;
|
||||
final List<Tuple2<String, AvesVideoController>> videoControllers;
|
||||
final List<Tuple2<String, MultiPageController>> multiPageControllers;
|
||||
final void Function(String uri) onViewDisposed;
|
||||
|
||||
|
@ -89,7 +89,7 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
|
|||
mainEntry: entry,
|
||||
page: page,
|
||||
viewportSize: mqSize,
|
||||
onTap: (_) => widget.onTap?.call(),
|
||||
onTap: widget.onTap == null ? null : (_) => widget.onTap(),
|
||||
videoControllers: widget.videoControllers,
|
||||
onDisposed: () => widget.onViewDisposed?.call(entry.uri),
|
||||
);
|
||||
|
@ -108,7 +108,7 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
|
|||
class SingleEntryScroller extends StatefulWidget {
|
||||
final AvesEntry entry;
|
||||
final VoidCallback onTap;
|
||||
final List<Tuple2<String, IjkMediaController>> videoControllers;
|
||||
final List<Tuple2<String, AvesVideoController>> videoControllers;
|
||||
final List<Tuple2<String, MultiPageController>> multiPageControllers;
|
||||
|
||||
const SingleEntryScroller({
|
||||
|
@ -163,7 +163,7 @@ class _SingleEntryScrollerState extends State<SingleEntryScroller> with Automati
|
|||
mainEntry: entry,
|
||||
page: page,
|
||||
viewportSize: mqSize,
|
||||
onTap: (_) => widget.onTap?.call(),
|
||||
onTap: widget.onTap == null ? null : (_) => widget.onTap(),
|
||||
videoControllers: widget.videoControllers,
|
||||
);
|
||||
},
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:math';
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
|
||||
import 'package:aves/widgets/common/video/video.dart';
|
||||
import 'package:aves/widgets/viewer/entry_horizontal_pager.dart';
|
||||
import 'package:aves/widgets/viewer/info/info_page.dart';
|
||||
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||
|
@ -10,13 +11,12 @@ import 'package:aves/widgets/viewer/multipage.dart';
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class ViewerVerticalPageView extends StatefulWidget {
|
||||
final CollectionLens collection;
|
||||
final ValueNotifier<AvesEntry> entryNotifier;
|
||||
final List<Tuple2<String, IjkMediaController>> videoControllers;
|
||||
final List<Tuple2<String, AvesVideoController>> videoControllers;
|
||||
final List<Tuple2<String, MultiPageController>> multiPageControllers;
|
||||
final PageController horizontalPager, verticalPager;
|
||||
final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged;
|
||||
|
@ -32,7 +32,7 @@ class ViewerVerticalPageView extends StatefulWidget {
|
|||
@required this.horizontalPager,
|
||||
@required this.onVerticalPageChanged,
|
||||
@required this.onHorizontalPageChanged,
|
||||
@required this.onImageTap,
|
||||
this.onImageTap,
|
||||
@required this.onImagePageRequested,
|
||||
@required this.onViewDisposed,
|
||||
});
|
||||
|
|
|
@ -11,6 +11,7 @@ import 'package:aves/theme/durations.dart';
|
|||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
import 'package:aves/widgets/common/video/video.dart';
|
||||
import 'package:aves/widgets/viewer/entry_action_delegate.dart';
|
||||
import 'package:aves/widgets/viewer/entry_vertical_pager.dart';
|
||||
import 'package:aves/widgets/viewer/hero.dart';
|
||||
|
@ -25,7 +26,6 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
|
@ -55,7 +55,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
Animation<Offset> _bottomOverlayOffset;
|
||||
EdgeInsets _frozenViewInsets, _frozenViewPadding;
|
||||
EntryActionDelegate _actionDelegate;
|
||||
final List<Tuple2<String, IjkMediaController>> _videoControllers = [];
|
||||
final List<Tuple2<String, AvesVideoController>> _videoControllers = [];
|
||||
final List<Tuple2<String, MultiPageController>> _multiPageControllers = [];
|
||||
final List<Tuple2<String, ValueNotifier<ViewState>>> _viewStateNotifiers = [];
|
||||
final ValueNotifier<HeroInfo> _heroInfoNotifier = ValueNotifier(null);
|
||||
|
@ -496,10 +496,10 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
(_) => _.dispose(),
|
||||
);
|
||||
if (entry.isVideo) {
|
||||
_initViewSpecificController<IjkMediaController>(
|
||||
_initViewSpecificController<AvesVideoController>(
|
||||
uri,
|
||||
_videoControllers,
|
||||
() => IjkMediaController(),
|
||||
() => AvesVideoController.flutterIjkPlayer(),
|
||||
(_) => _.dispose(),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ import 'package:aves/model/filters/mime.dart';
|
|||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/model/filters/type.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/file_utils.dart';
|
||||
|
@ -78,12 +77,12 @@ class BasicSection extends StatelessWidget {
|
|||
final album = entry.directory;
|
||||
final filters = {
|
||||
MimeFilter(entry.mimeType),
|
||||
if (entry.isAnimated) TypeFilter(TypeFilter.animated),
|
||||
if (entry.isGeotiff) TypeFilter(TypeFilter.geotiff),
|
||||
if (entry.isImage && entry.is360) TypeFilter(TypeFilter.panorama),
|
||||
if (entry.isVideo && entry.is360) TypeFilter(TypeFilter.sphericalVideo),
|
||||
if (entry.isVideo && !entry.is360) MimeFilter(MimeTypes.anyVideo),
|
||||
if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(context, album)),
|
||||
if (entry.isAnimated) TypeFilter.animated,
|
||||
if (entry.isGeotiff) TypeFilter.geotiff,
|
||||
if (entry.isImage && entry.is360) TypeFilter.panorama,
|
||||
if (entry.isVideo && entry.is360) TypeFilter.sphericalVideo,
|
||||
if (entry.isVideo && !entry.is360) MimeFilter.video,
|
||||
if (album != null) AlbumFilter(album, collection?.source?.getAlbumDisplayName(context, album)),
|
||||
...tags.map((tag) => TagFilter(tag)),
|
||||
};
|
||||
return AnimatedBuilder(
|
||||
|
@ -91,7 +90,7 @@ class BasicSection extends StatelessWidget {
|
|||
builder: (context, child) {
|
||||
final effectiveFilters = [
|
||||
...filters,
|
||||
if (entry.isFavourite) FavouriteFilter(),
|
||||
if (entry.isFavourite) FavouriteFilter.instance,
|
||||
]..sort();
|
||||
if (effectiveFilters.isEmpty) return SizedBox.shrink();
|
||||
return Padding(
|
||||
|
|
|
@ -17,7 +17,7 @@ import 'package:flutter/scheduler.dart';
|
|||
class MapDecorator extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
static const BorderRadius mapBorderRadius = BorderRadius.all(Radius.circular(24)); // to match button circles
|
||||
static final BorderRadius mapBorderRadius = BorderRadius.circular(24); // to match button circles
|
||||
|
||||
const MapDecorator({@required this.child});
|
||||
|
||||
|
@ -90,7 +90,7 @@ class MapButtonPanel extends StatelessWidget {
|
|||
);
|
||||
},
|
||||
);
|
||||
// wait for the dialog to hide because switching to Google Maps layer may block the UI
|
||||
// wait for the dialog to hide as applying the change may block the UI
|
||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||
if (style != null && style != settings.infoMapStyle) {
|
||||
settings.infoMapStyle = style;
|
||||
|
|
|
@ -10,10 +10,9 @@ class OverlayButton extends StatelessWidget {
|
|||
|
||||
const OverlayButton({
|
||||
Key key,
|
||||
@required this.scale,
|
||||
this.scale = kAlwaysCompleteAnimation,
|
||||
@required this.child,
|
||||
}) : assert(scale != null),
|
||||
super(key: key);
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -34,6 +33,9 @@ class OverlayButton extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
// icon (24) + icon padding (8) + button padding (16) + border (2)
|
||||
static double getSize(BuildContext context) => 50.0;
|
||||
}
|
||||
|
||||
class OverlayTextButton extends StatelessWidget {
|
||||
|
@ -67,7 +69,7 @@ class OverlayTextButton extends StatelessWidget {
|
|||
minimumSize: _minSize,
|
||||
side: MaterialStateProperty.all<BorderSide>(AvesCircleBorder.buildSide(context)),
|
||||
shape: MaterialStateProperty.all<OutlinedBorder>(RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(_borderRadius)),
|
||||
borderRadius: BorderRadius.circular(_borderRadius),
|
||||
)),
|
||||
// shape: MaterialStateProperty.all<OutlinedBorder>(CircleBorder()),
|
||||
),
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/favourites.dart';
|
||||
|
@ -17,7 +15,6 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class ViewerTopOverlay extends StatelessWidget {
|
||||
final AvesEntry entry;
|
||||
|
@ -30,10 +27,6 @@ class ViewerTopOverlay extends StatelessWidget {
|
|||
|
||||
static const double padding = 8;
|
||||
|
||||
static const int landscapeActionCount = 3;
|
||||
|
||||
static const int portraitActionCount = 2;
|
||||
|
||||
const ViewerTopOverlay({
|
||||
Key key,
|
||||
@required this.entry,
|
||||
|
@ -52,21 +45,11 @@ class ViewerTopOverlay extends StatelessWidget {
|
|||
minimum: (viewInsets ?? EdgeInsets.zero) + (viewPadding ?? EdgeInsets.zero),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(padding),
|
||||
child: Selector<MediaQueryData, Tuple2<double, Orientation>>(
|
||||
selector: (c, mq) => Tuple2(mq.size.width, mq.orientation),
|
||||
builder: (c, mq, child) {
|
||||
final mqWidth = mq.item1;
|
||||
final mqOrientation = mq.item2;
|
||||
|
||||
final targetCount = mqOrientation == Orientation.landscape ? landscapeActionCount : portraitActionCount;
|
||||
final availableCount = (mqWidth / (kMinInteractiveDimension + padding)).floor() - 2;
|
||||
final quickActionCount = min(targetCount, availableCount);
|
||||
|
||||
final quickActions = [
|
||||
EntryAction.toggleFavourite,
|
||||
EntryAction.share,
|
||||
EntryAction.delete,
|
||||
].where(_canDo).take(quickActionCount).toList();
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (c, mq) => mq.size.width - mq.padding.horizontal,
|
||||
builder: (c, mqWidth, child) {
|
||||
final availableCount = (mqWidth / (OverlayButton.getSize(context) + padding)).floor() - 2;
|
||||
final quickActions = settings.viewerQuickActions.where(_canDo).take(availableCount).toList();
|
||||
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList();
|
||||
final externalAppActions = EntryActions.externalApp.where(_canDo).toList();
|
||||
final buttonRow = _TopOverlayRow(
|
||||
|
|
|
@ -8,13 +8,13 @@ import 'package:aves/utils/time_utils.dart';
|
|||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/fx/blurred.dart';
|
||||
import 'package:aves/widgets/common/fx/borders.dart';
|
||||
import 'package:aves/widgets/common/video/video.dart';
|
||||
import 'package:aves/widgets/viewer/overlay/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||
|
||||
class VideoControlOverlay extends StatefulWidget {
|
||||
final AvesEntry entry;
|
||||
final IjkMediaController controller;
|
||||
final AvesVideoController controller;
|
||||
final Animation<double> scale;
|
||||
|
||||
const VideoControlOverlay({
|
||||
|
@ -42,18 +42,11 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
|
|||
|
||||
Animation<double> get scale => widget.scale;
|
||||
|
||||
IjkMediaController get controller => widget.controller;
|
||||
AvesVideoController get controller => widget.controller;
|
||||
|
||||
// `videoInfo` is never null (even if `toString` prints `null`)
|
||||
// check presence with `hasData` instead
|
||||
VideoInfo get videoInfo => controller.videoInfo;
|
||||
bool get isPlayable => controller.isPlayable;
|
||||
|
||||
// we check whether video info is ready instead of checking for `noDatasource` status,
|
||||
// as the controller could also be uninitialized with the `pause` status
|
||||
// (e.g. when switching between video entries without playing them the first time)
|
||||
bool get isInitialized => videoInfo.hasData;
|
||||
|
||||
bool get isPlaying => controller.ijkStatus == IjkStatus.playing;
|
||||
bool get isPlaying => controller.isPlaying;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -80,10 +73,10 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
|
|||
}
|
||||
|
||||
void _registerWidget(VideoControlOverlay widget) {
|
||||
_subscriptions.add(widget.controller.ijkStatusStream.listen(_onStatusChange));
|
||||
_subscriptions.add(widget.controller.textureIdStream.listen(_onTextureIdChange));
|
||||
_onStatusChange(widget.controller.ijkStatus);
|
||||
_onTextureIdChange(widget.controller.textureId);
|
||||
_subscriptions.add(widget.controller.statusStream.listen(_onStatusChange));
|
||||
_subscriptions.add(widget.controller.isVideoReadyStream.listen(_onVideoReadinessChanged));
|
||||
_onStatusChange(widget.controller.status);
|
||||
_onVideoReadinessChanged(widget.controller.isVideoReady);
|
||||
}
|
||||
|
||||
void _unregisterWidget(VideoControlOverlay widget) {
|
||||
|
@ -95,18 +88,18 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<IjkStatus>(
|
||||
stream: controller.ijkStatusStream,
|
||||
return StreamBuilder<VideoStatus>(
|
||||
stream: controller.statusStream,
|
||||
builder: (context, snapshot) {
|
||||
// do not use stream snapshot because it is obsolete when switching between videos
|
||||
final status = controller.ijkStatus;
|
||||
final status = controller.status;
|
||||
return TooltipTheme(
|
||||
data: TooltipTheme.of(context).copyWith(
|
||||
preferBelow: false,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: status == IjkStatus.error
|
||||
children: status == VideoStatus.error
|
||||
? [
|
||||
OverlayButton(
|
||||
scale: scale,
|
||||
|
@ -171,22 +164,22 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
|
|||
children: [
|
||||
Row(
|
||||
children: [
|
||||
StreamBuilder<VideoInfo>(
|
||||
stream: controller.videoInfoStream,
|
||||
StreamBuilder<int>(
|
||||
stream: controller.positionStream,
|
||||
builder: (context, snapshot) {
|
||||
// do not use stream snapshot because it is obsolete when switching between videos
|
||||
final position = videoInfo.currentPosition?.floor() ?? 0;
|
||||
return Text(formatDuration(Duration(seconds: position)));
|
||||
final position = controller.currentPosition?.floor() ?? 0;
|
||||
return Text(formatDuration(Duration(milliseconds: position)));
|
||||
}),
|
||||
Spacer(),
|
||||
Text(entry.durationText),
|
||||
],
|
||||
),
|
||||
StreamBuilder<VideoInfo>(
|
||||
stream: controller.videoInfoStream,
|
||||
StreamBuilder<int>(
|
||||
stream: controller.positionStream,
|
||||
builder: (context, snapshot) {
|
||||
// do not use stream snapshot because it is obsolete when switching between videos
|
||||
var progress = videoInfo.progress;
|
||||
var progress = controller.progress;
|
||||
if (!progress.isFinite) progress = 0.0;
|
||||
return LinearProgressIndicator(value: progress);
|
||||
}),
|
||||
|
@ -199,7 +192,7 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
|
|||
}
|
||||
|
||||
void _startTimer() {
|
||||
if (controller.textureId == null) return;
|
||||
if (!controller.isVideoReady) return;
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(Durations.videoProgressTimerInterval, (_) => controller.refreshVideoInfo());
|
||||
}
|
||||
|
@ -208,16 +201,16 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
|
|||
_progressTimer?.cancel();
|
||||
}
|
||||
|
||||
void _onTextureIdChange(int textureId) {
|
||||
if (textureId != null) {
|
||||
void _onVideoReadinessChanged(bool isVideoReady) {
|
||||
if (isVideoReady) {
|
||||
_startTimer();
|
||||
} else {
|
||||
_stopTimer();
|
||||
}
|
||||
}
|
||||
|
||||
void _onStatusChange(IjkStatus status) {
|
||||
if (status == IjkStatus.playing && _seekTargetPercent != null) {
|
||||
void _onStatusChange(VideoStatus status) {
|
||||
if (status == VideoStatus.playing && _seekTargetPercent != null) {
|
||||
_seekFromTarget();
|
||||
}
|
||||
_updatePlayPauseIcon();
|
||||
|
@ -226,10 +219,10 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
|
|||
Future<void> _playPause() async {
|
||||
if (isPlaying) {
|
||||
await controller.pause();
|
||||
} else if (isInitialized) {
|
||||
} else if (isPlayable) {
|
||||
await controller.play();
|
||||
} else {
|
||||
await controller.setDataSource(DataSource.photoManagerUrl(entry.uri), autoPlay: true);
|
||||
await controller.setDataSource(entry.uri);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -248,19 +241,17 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
|
|||
final localPosition = box.globalToLocal(globalPosition);
|
||||
_seekTargetPercent = (localPosition.dx / box.size.width);
|
||||
|
||||
if (isInitialized) {
|
||||
if (isPlayable) {
|
||||
await _seekFromTarget();
|
||||
} else {
|
||||
// autoplay when seeking on uninitialized player, otherwise the texture is not updated
|
||||
// as a workaround, pausing after a brief duration is possible, but fiddly
|
||||
await controller.setDataSource(DataSource.photoManagerUrl(entry.uri), autoPlay: true);
|
||||
await controller.setDataSource(entry.uri);
|
||||
}
|
||||
}
|
||||
|
||||
Future _seekFromTarget() async {
|
||||
// `seekToProgress` is not safe as it can be called when the `duration` is not set yet
|
||||
// so we make sure the video info is up to date first
|
||||
if (videoInfo.duration == null) {
|
||||
if (controller.duration == null) {
|
||||
await controller.refreshVideoInfo();
|
||||
} else {
|
||||
await controller.seekToProgress(_seekTargetPercent);
|
||||
|
|
|
@ -94,7 +94,6 @@ class _PanoramaPageState extends State<PanoramaPage> {
|
|||
return Padding(
|
||||
padding: EdgeInsets.all(8) + EdgeInsets.only(right: mqPadding.right, bottom: mqPadding.bottom),
|
||||
child: OverlayButton(
|
||||
scale: kAlwaysCompleteAnimation,
|
||||
child: ValueListenableBuilder<SensorControl>(
|
||||
valueListenable: _sensorControl,
|
||||
builder: (context, sensorControl, child) {
|
||||
|
|
|
@ -12,6 +12,7 @@ import 'package:aves/widgets/common/magnifier/magnifier.dart';
|
|||
import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/scale_level.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/state.dart';
|
||||
import 'package:aves/widgets/common/video/video.dart';
|
||||
import 'package:aves/widgets/viewer/hero.dart';
|
||||
import 'package:aves/widgets/viewer/visual/error.dart';
|
||||
import 'package:aves/widgets/viewer/visual/raster.dart';
|
||||
|
@ -20,7 +21,6 @@ import 'package:aves/widgets/viewer/visual/vector.dart';
|
|||
import 'package:aves/widgets/viewer/visual/video.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
@ -31,7 +31,7 @@ class EntryPageView extends StatefulWidget {
|
|||
final SinglePageInfo page;
|
||||
final Size viewportSize;
|
||||
final MagnifierTapCallback onTap;
|
||||
final List<Tuple2<String, IjkMediaController>> videoControllers;
|
||||
final List<Tuple2<String, AvesVideoController>> videoControllers;
|
||||
final VoidCallback onDisposed;
|
||||
|
||||
static const decorationCheckSize = 20.0;
|
||||
|
@ -138,7 +138,7 @@ class _EntryPageViewState extends State<EntryPageView> {
|
|||
}
|
||||
child ??= ErrorView(
|
||||
entry: entry,
|
||||
onTap: () => onTap?.call(null),
|
||||
onTap: onTap == null ? null : () => onTap(null),
|
||||
);
|
||||
return child;
|
||||
},
|
||||
|
@ -221,7 +221,7 @@ class _EntryPageViewState extends State<EntryPageView> {
|
|||
initialScale: initialScale,
|
||||
scaleStateCycle: scaleStateCycle,
|
||||
applyScale: applyScale,
|
||||
onTap: (c, d, s, childPosition) => onTap?.call(childPosition),
|
||||
onTap: onTap == null ? null : (c, d, s, childPosition) => onTap(childPosition),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,16 +1,15 @@
|
|||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/entry_images.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/common/video/video.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||
|
||||
class VideoView extends StatefulWidget {
|
||||
final AvesEntry entry;
|
||||
final IjkMediaController controller;
|
||||
final AvesVideoController controller;
|
||||
|
||||
const VideoView({
|
||||
Key key,
|
||||
|
@ -23,11 +22,9 @@ class VideoView extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _VideoViewState extends State<VideoView> {
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
|
||||
AvesEntry get entry => widget.entry;
|
||||
|
||||
IjkMediaController get controller => widget.controller;
|
||||
AvesVideoController get controller => widget.controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -49,56 +46,24 @@ class _VideoViewState extends State<VideoView> {
|
|||
}
|
||||
|
||||
void _registerWidget(VideoView widget) {
|
||||
_subscriptions.add(widget.controller.playFinishStream.listen(_onPlayFinish));
|
||||
widget.controller.playCompletedListenable.addListener(_onPlayCompleted);
|
||||
}
|
||||
|
||||
void _unregisterWidget(VideoView widget) {
|
||||
_subscriptions
|
||||
..forEach((sub) => sub.cancel())
|
||||
..clear();
|
||||
widget.controller.playCompletedListenable.removeListener(_onPlayCompleted);
|
||||
}
|
||||
|
||||
bool isPlayable(IjkStatus status) => controller != null && [IjkStatus.prepared, IjkStatus.playing, IjkStatus.pause, IjkStatus.complete].contains(status);
|
||||
bool isPlayable(VideoStatus status) => controller != null && [VideoStatus.prepared, VideoStatus.playing, VideoStatus.paused, VideoStatus.completed].contains(status);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (controller == null) return SizedBox();
|
||||
return StreamBuilder<IjkStatus>(
|
||||
stream: widget.controller.ijkStatusStream,
|
||||
return StreamBuilder<VideoStatus>(
|
||||
stream: widget.controller.statusStream,
|
||||
builder: (context, snapshot) {
|
||||
final status = snapshot.data;
|
||||
return isPlayable(status)
|
||||
? IjkPlayer(
|
||||
mediaController: controller,
|
||||
controllerWidgetBuilder: (controller) => SizedBox.shrink(),
|
||||
statusWidgetBuilder: (context, controller, status) => SizedBox.shrink(),
|
||||
textureBuilder: (context, controller, info) {
|
||||
var id = controller.textureId;
|
||||
var child = id != null
|
||||
? Texture(
|
||||
textureId: id,
|
||||
)
|
||||
: Container(
|
||||
color: Colors.black,
|
||||
);
|
||||
|
||||
final degree = entry.rotationDegrees ?? 0;
|
||||
if (degree != 0) {
|
||||
child = RotatedBox(
|
||||
quarterTurns: degree ~/ 90,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: entry.displayAspectRatio,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
backgroundColor: Colors.transparent,
|
||||
)
|
||||
? controller.buildPlayerWidget(entry)
|
||||
: Image(
|
||||
image: entry.getBestThumbnail(settings.getTileExtent(CollectionPage.routeName)),
|
||||
fit: BoxFit.contain,
|
||||
|
@ -106,5 +71,5 @@ class _VideoViewState extends State<VideoView> {
|
|||
});
|
||||
}
|
||||
|
||||
void _onPlayFinish(IjkMediaController controller) => controller.seekTo(0);
|
||||
void _onPlayCompleted() => controller.seekTo(0);
|
||||
}
|
||||
|
|
33
pubspec.lock
33
pubspec.lock
|
@ -7,14 +7,14 @@ packages:
|
|||
name: _fe_analyzer_shared
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "17.0.0"
|
||||
version: "18.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
version: "1.2.0"
|
||||
ansicolor:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -112,7 +112,14 @@ packages:
|
|||
name: connectivity
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
version: "3.0.3"
|
||||
connectivity_for_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: connectivity_for_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.4.0"
|
||||
connectivity_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -327,7 +334,7 @@ packages:
|
|||
name: flutter_map
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.11.0"
|
||||
version: "0.12.0"
|
||||
flutter_markdown:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -398,7 +405,7 @@ packages:
|
|||
name: google_api_availability
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
version: "3.0.1"
|
||||
google_maps_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -412,7 +419,7 @@ packages:
|
|||
name: google_maps_flutter_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
version: "2.0.2"
|
||||
highlight:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -664,14 +671,14 @@ packages:
|
|||
name: permission_handler
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
version: "6.1.1"
|
||||
permission_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
version: "3.1.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -762,7 +769,7 @@ packages:
|
|||
name: shared_preferences
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
version: "2.0.5"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -804,7 +811,7 @@ packages:
|
|||
name: shelf
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
shelf_packages_handler:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -825,7 +832,7 @@ packages:
|
|||
name: shelf_web_socket
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "1.0.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
@ -858,7 +865,7 @@ packages:
|
|||
name: sqflite
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.0+2"
|
||||
version: "2.0.0+3"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1106,4 +1113,4 @@ packages:
|
|||
version: "3.1.0"
|
||||
sdks:
|
||||
dart: ">=2.12.0 <3.0.0"
|
||||
flutter: ">=1.26.0-0"
|
||||
flutter: ">=2.0.0"
|
||||
|
|
44
pubspec.yaml
44
pubspec.yaml
|
@ -1,7 +1,7 @@
|
|||
name: aves
|
||||
description: A visual media gallery and metadata explorer app.
|
||||
repository: https://github.com/deckerst/aves
|
||||
version: 1.3.6+42
|
||||
version: 1.3.7+43
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
|
@ -40,8 +40,12 @@ dependencies:
|
|||
firebase_analytics:
|
||||
firebase_crashlytics:
|
||||
flutter_highlight:
|
||||
# fijkplayer:
|
||||
## path: ../fijkplayer
|
||||
# git:
|
||||
# url: git://github.com/deckerst/fijkplayer.git
|
||||
# ref: aves-config
|
||||
flutter_ijkplayer:
|
||||
# path: ../flutter_ijkplayer
|
||||
git:
|
||||
url: git://github.com/deckerst/flutter_ijkplayer.git
|
||||
flutter_localized_locales:
|
||||
|
@ -87,6 +91,21 @@ flutter:
|
|||
generate: true
|
||||
uses-material-design: true
|
||||
|
||||
################################################################################
|
||||
# Build
|
||||
|
||||
# deckerst/fijkplayer
|
||||
|
||||
# This fork depends on a local .aar, but Flutter does not support this well
|
||||
# cf https://github.com/flutter/flutter/issues/28195
|
||||
# so building an app with this plugin requires the file to be present at:
|
||||
# `<app-root>/android/app/libs/fijkplayer-full-release.aar`
|
||||
# The .aar file in the app will take precedence over the one in the plugin itself.
|
||||
|
||||
# The reference file is available at:
|
||||
# - [git] https://github.com/deckerst/fijkplayer/blob/aves-config/android/libs/fijkplayer-full-release.aar
|
||||
# - [local/win] C:\Users\<user>\AppData\Local\Pub\Cache\git\fijkplayer-<version>\android\libs\fijkplayer-full-release.aar
|
||||
|
||||
################################################################################
|
||||
# Localization
|
||||
|
||||
|
@ -120,13 +139,16 @@ flutter:
|
|||
# - does not support AVI/XVID, AC3
|
||||
# - cannot play if only the video or audio stream is supported
|
||||
|
||||
# fijkplayer (as of v0.7.1, backed by IJKPlayer & ffmpeg):
|
||||
# - support content URIs
|
||||
# - does not support XVID, AC3 (by default, but possible by custom build)
|
||||
# - can play if only the video or audio stream is supported
|
||||
# - crash when calling `seekTo` for some files (e.g. TED talk videos)
|
||||
|
||||
# flutter_ijkplayer (as of v0.3.5+1, backed by IJKPlayer & ffmpeg):
|
||||
# - support content URIs (`DataSource.photoManagerUrl` from v0.3.6, but need fork to support content URIs on Android <Q)
|
||||
# - does not support AC3 (by default, but possible by custom build)
|
||||
# - can play if only the video or audio stream is supported
|
||||
# ~ support content URIs (`DataSource.photoManagerUrl` from v0.3.6, but need fork to support content URIs on Android <Q)
|
||||
# + does not support AC3 (by default, but possible by custom build)
|
||||
# + can play if only the video or audio stream is supported
|
||||
# - edge smear on some videos, depending on dimensions (dimension not multiple of 16?)
|
||||
# - unmaintained
|
||||
|
||||
# fijkplayer (as of v0.8.7, backed by IJKPlayer & ffmpeg):
|
||||
# + support content URIs
|
||||
# + does not support XVID, AC3 (by default, but possible by custom build)
|
||||
# + can play if only the video or audio stream is supported
|
||||
# + no edge smear (with default build)
|
||||
# - crash when calling `seekTo` for some files, cf https://github.com/befovy/fijkplayer/issues/360
|
||||
|
|
File diff suppressed because one or more lines are too long
1
shaders_2.1.0-12.2.pre.sksl.json
Normal file
1
shaders_2.1.0-12.2.pre.sksl.json
Normal file
File diff suppressed because one or more lines are too long
28
test/fake/storage_service.dart
Normal file
28
test/fake/storage_service.dart
Normal file
|
@ -0,0 +1,28 @@
|
|||
import 'package:aves/services/storage_service.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
class FakeStorageService extends Fake implements StorageService {
|
||||
static const primaryRootAlbum = '/storage/emulated/0';
|
||||
static const primaryPath = '$primaryRootAlbum/';
|
||||
static const primaryDescription = 'Internal Storage';
|
||||
static const removablePath = '/storage/1234-5678/';
|
||||
static const removableDescription = 'SD Card';
|
||||
|
||||
@override
|
||||
Future<Set<StorageVolume>> getStorageVolumes() => SynchronousFuture({
|
||||
StorageVolume(
|
||||
path: primaryPath,
|
||||
description: primaryDescription,
|
||||
isPrimary: true,
|
||||
isRemovable: false,
|
||||
),
|
||||
StorageVolume(
|
||||
path: removablePath,
|
||||
description: removableDescription,
|
||||
isPrimary: false,
|
||||
isRemovable: true,
|
||||
),
|
||||
});
|
||||
}
|
|
@ -12,29 +12,36 @@ import 'package:aves/services/image_file_service.dart';
|
|||
import 'package:aves/services/media_store_service.dart';
|
||||
import 'package:aves/services/metadata_service.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/storage_service.dart';
|
||||
import 'package:aves/services/time_service.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../fake/availability.dart';
|
||||
import '../fake/image_file_service.dart';
|
||||
import '../fake/media_store_service.dart';
|
||||
import '../fake/metadata_db.dart';
|
||||
import '../fake/metadata_service.dart';
|
||||
import '../fake/storage_service.dart';
|
||||
import '../fake/time_service.dart';
|
||||
|
||||
void main() {
|
||||
const volume = '/storage/emulated/0/';
|
||||
const testAlbum = '${volume}Pictures/test';
|
||||
const sourceAlbum = '${volume}Pictures/source';
|
||||
const destinationAlbum = '${volume}Pictures/destination';
|
||||
const testAlbum = '${FakeStorageService.primaryPath}Pictures/test';
|
||||
const sourceAlbum = '${FakeStorageService.primaryPath}Pictures/source';
|
||||
const destinationAlbum = '${FakeStorageService.primaryPath}Pictures/destination';
|
||||
|
||||
setUp(() async {
|
||||
// specify Posix style path context for consistent behaviour when running tests on Windows
|
||||
getIt.registerLazySingleton<p.Context>(() => p.Context(style: p.Style.posix));
|
||||
getIt.registerLazySingleton<AvesAvailability>(() => FakeAvesAvailability());
|
||||
getIt.registerLazySingleton<MetadataDb>(() => FakeMetadataDb());
|
||||
|
||||
getIt.registerLazySingleton<ImageFileService>(() => FakeImageFileService());
|
||||
getIt.registerLazySingleton<MediaStoreService>(() => FakeMediaStoreService());
|
||||
getIt.registerLazySingleton<MetadataService>(() => FakeMetadataService());
|
||||
getIt.registerLazySingleton<StorageService>(() => FakeStorageService());
|
||||
getIt.registerLazySingleton<TimeService>(() => FakeTimeService());
|
||||
|
||||
await settings.init();
|
||||
|
@ -236,4 +243,35 @@ void main() {
|
|||
expect(covers.count, 1);
|
||||
expect(covers.coverContentId(albumFilter), image1.contentId);
|
||||
});
|
||||
|
||||
testWidgets('unique album names', (tester) async {
|
||||
(mediaStoreService as FakeMediaStoreService).entries = {
|
||||
FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Pictures/Elea/Zeno', '1'),
|
||||
FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Pictures/Citium/Zeno', '1'),
|
||||
FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Pictures/Cleanthes', '1'),
|
||||
FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Pictures/Chrysippus', '1'),
|
||||
FakeMediaStoreService.newImage('${FakeStorageService.removablePath}Pictures/Chrysippus', '1'),
|
||||
FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}', '1'),
|
||||
FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Pictures/Seneca', '1'),
|
||||
FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Seneca', '1'),
|
||||
};
|
||||
|
||||
await androidFileUtils.init();
|
||||
final source = await _initSource();
|
||||
await tester.pumpWidget(
|
||||
Builder(
|
||||
builder: (context) {
|
||||
expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Elea/Zeno'), 'Elea/Zeno');
|
||||
expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Citium/Zeno'), 'Citium/Zeno');
|
||||
expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Cleanthes'), 'Cleanthes');
|
||||
expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Chrysippus'), 'Chrysippus');
|
||||
expect(source.getAlbumDisplayName(context, '${FakeStorageService.removablePath}Pictures/Chrysippus'), 'Chrysippus (${FakeStorageService.removableDescription})');
|
||||
expect(source.getAlbumDisplayName(context, FakeStorageService.primaryRootAlbum), FakeStorageService.primaryDescription);
|
||||
expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Seneca'), 'Pictures/Seneca');
|
||||
expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Seneca'), 'Seneca');
|
||||
return Placeholder();
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@ import 'package:aves/model/filters/mime.dart';
|
|||
import 'package:aves/model/filters/query.dart';
|
||||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/model/filters/type.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
@ -16,16 +15,16 @@ void main() {
|
|||
final album = AlbumFilter('path/to/album', 'album');
|
||||
expect(album, jsonRoundTrip(album));
|
||||
|
||||
final fav = FavouriteFilter();
|
||||
const fav = FavouriteFilter.instance;
|
||||
expect(fav, jsonRoundTrip(fav));
|
||||
|
||||
final location = LocationFilter(LocationLevel.country, 'France${LocationFilter.locationSeparator}FR');
|
||||
expect(location, jsonRoundTrip(location));
|
||||
|
||||
final type = TypeFilter(TypeFilter.sphericalVideo);
|
||||
final type = TypeFilter.sphericalVideo;
|
||||
expect(type, jsonRoundTrip(type));
|
||||
|
||||
final mime = MimeFilter(MimeTypes.anyVideo);
|
||||
final mime = MimeFilter.video;
|
||||
expect(mime, jsonRoundTrip(mime));
|
||||
|
||||
final query = QueryFilter('some query');
|
||||
|
|
|
@ -3,9 +3,9 @@ import 'dart:ui';
|
|||
import 'package:aves/main.dart' as app;
|
||||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/services/android_file_service.dart';
|
||||
import 'package:aves/services/storage_service.dart';
|
||||
import 'package:flutter_driver/driver_extension.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'constants.dart';
|
||||
|
||||
|
@ -15,7 +15,7 @@ void main() {
|
|||
// scan files copied from test assets
|
||||
// we do it via the app instead of broadcasting via ADB
|
||||
// because `MEDIA_SCANNER_SCAN_FILE` intent got deprecated in API 29
|
||||
AndroidFileService.scanFile(path.join(targetPicturesDir, 'ipse.jpg'), 'image/jpeg');
|
||||
PlatformStorageService().scanFile(p.join(targetPicturesDir, 'ipse.jpg'), 'image/jpeg');
|
||||
|
||||
configureAndLaunch();
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:flutter_driver/flutter_driver.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:pedantic/pedantic.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
|
@ -141,9 +141,9 @@ void searchAlbum() {
|
|||
await driver.waitUntilNoTransientCallbacks();
|
||||
|
||||
const albumPath = targetPicturesDirEmulated;
|
||||
final albumUniqueName = path.split(albumPath).last;
|
||||
final albumDisplayName = p.split(albumPath).last;
|
||||
await driver.tap(find.byType('TextField'));
|
||||
await driver.enterText(albumUniqueName);
|
||||
await driver.enterText(albumDisplayName);
|
||||
|
||||
final albumChip = find.byValueKey('album-$albumPath');
|
||||
await driver.waitFor(albumChip);
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
String get adb {
|
||||
final env = Platform.environment;
|
||||
// e.g. C:\Users\<username>\AppData\Local\Android\Sdk
|
||||
final sdkDir = env['ANDROID_SDK_ROOT'] ?? env['ANDROID_SDK'];
|
||||
return path.join(sdkDir, 'platform-tools', Platform.isWindows ? 'adb.exe' : 'adb');
|
||||
return p.join(sdkDir, 'platform-tools', Platform.isWindows ? 'adb.exe' : 'adb');
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
Thanks for using Aves!
|
||||
v1.3.6:
|
||||
- Korean translation
|
||||
- cover selection for albums, countries & tags
|
||||
- TIFF decoding fixes
|
||||
v1.3.7:
|
||||
- fast scroll label
|
||||
- localized common album names
|
||||
- customizable shortcut icon image
|
||||
- customizable viewer quick actions
|
||||
- option to hide videos from collection
|
||||
Full changelog available on Github
|
Loading…
Reference in a new issue