Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2021-04-02 10:03:18 +09:00
commit b31ad98d22
99 changed files with 2270 additions and 684 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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{동영상}}",

View file

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

View file

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

View file

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

View file

@ -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() => {

View file

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

View file

@ -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('/*')) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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;
// }
// }

View 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;
}
}

View 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,
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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 [];
},
);
}
}

View file

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

View file

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

View file

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

View file

@ -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) => [

View file

@ -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) => [

View file

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

View file

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

View file

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

View file

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

View 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;
}

View 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,
);
}
}

View 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;
}
}

View 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;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()),
),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

View 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,
),
});
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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