diff --git a/CHANGELOG.md b/CHANGELOG.md index 2091c9c95..5440ce590 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [v1.5.1] - 2021-09-08 +### Added +- About: bug reporting instructions + +### Changed +- Collection: improved video date detection + +### Fixed +- fixed hanging app when loading thumbnails for some video formats on some devices + ## [v1.5.0] - 2021-09-02 ### Added - Info: edit Exif dates (setting, shifting, deleting) diff --git a/README.md b/README.md index c306ede1a..d17cf244c 100644 --- a/README.md +++ b/README.md @@ -4,25 +4,34 @@
Aves logo -[Get it on Google Play](https://play.google.com/store/apps/details?id=deckers.thibault.aves&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1) - +[Get it on Google Play](https://play.google.com/store/apps/details?id=deckers.thibault.aves&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1) +[Get it on GitHub](https://github.com/deckerst/aves/releases/latest) + Aves is a gallery and metadata explorer app. It is built for Android, with Flutter. Collection screenshotImage screenshotStats screenshot ## Features -- support raster images: JPEG, GIF, PNG, HEIC/HEIF (including multi-track, from Android Pie), WEBP, TIFF (including multi-page), BMP, WBMP, ICO -- support animated images: GIF, WEBP -- support raw images: ARW, CR2, DNG, NEF, NRW, ORF, PEF, RAF, RW2, SRW -- support vector images: SVG -- support videos: MP4, AVI, MKV, AVCHD & probably others -- identify panoramas (aka photo spheres), 360° videos, GeoTIFF files -- search and filter by country, place, XMP tag, type (animated, raster, vector…) -- favorites -- statistics -- support Android API 20 ~ 31 (Lollipop ~ S) -- Android integration (app shortcuts, handle view/pick intents) +Aves can handle all sorts of images and videos, including your typical JPEGs and MP4s, but also more exotic things like **multi-page TIFFs, SVGs, old AVIs and more**! + +It scans your media collection to identify **motion photos**, **panoramas** (aka photo spheres), **360° videos**, as well as **GeoTIFF** files. + +**Navigation and search** is an important part of Aves. The goal is for users to easily flow from albums to photos to tags to maps, etc. + +Aves integrates with Android (from **API 20 to 31**, i.e. from Lollipop to S) with features such as **app shortcuts** and **global search** handling. It also works as a **media viewer and picker**. + +## Permissions + +Aves requires a few permissions to do its job: +- **read contents of shared storage**: the app only accesses media files, and modifying them requires explicit access grants from the user, +- **read locations from media collection**: necessary to display the media coordinates, and to group them by country (via reverse geocoding), +- **have network access**: necessary for the map view, and most likely for precise reverse geocoding too, +- **view network connections**: checking for connection states allows Aves to gracefully degrade features that depend on internet. ## Project Setup diff --git a/android/app/build.gradle b/android/app/build.gradle index f82f67e68..aa7b88db9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -123,7 +123,7 @@ dependencies { // https://jitpack.io/com/github/deckerst/Android-TiffBitmapFactory/**********/build.log implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' // forked, built by JitPack // https://jitpack.io/com/github/deckerst/pixymeta-android/**********/build.log - implementation 'com.github.deckerst:pixymeta-android:f90140ed2b' // forked, built by JitPack + implementation 'com.github.deckerst:pixymeta-android:e4e50da939' // forked, built by JitPack implementation 'com.github.bumptech.glide:glide:4.12.0' kapt 'androidx.annotation:annotation:1.2.0' diff --git a/android/app/libs/fijkplayer-full-release.aar b/android/app/libs/fijkplayer-full-release.aar index a3588ec7b..4a5f64390 100644 Binary files a/android/app/libs/fijkplayer-full-release.aar and b/android/app/libs/fijkplayer-full-release.aar differ diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index 1c4d47dce..1377c88a3 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -31,6 +31,10 @@ class MainActivity : FlutterActivity() { override fun onCreate(savedInstanceState: Bundle?) { Log.i(LOG_TAG, "onCreate intent=$intent") + intent.extras?.takeUnless { it.isEmpty }?.let { + Log.i(LOG_TAG, "onCreate intent extras=$it") + } + // StrictMode.setThreadPolicy( // StrictMode.ThreadPolicy.Builder() // .detectAll() @@ -194,6 +198,9 @@ class MainActivity : FlutterActivity() { "query" to intent.getStringExtra(SearchManager.QUERY), ) } + Intent.ACTION_RUN -> { + // flutter run + } else -> { Log.w(LOG_TAG, "unhandled intent action=${intent?.action}") } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt index 5681e56be..b1cb53d20 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -68,15 +68,19 @@ class DebugHandler(private val context: Context) : MethodCallHandler { return } + if (!isSupportedByPixyMeta(mimeType)) { + result.error("getPixyMetadata-unsupported", "PixyMeta does not support mimeType=$mimeType", null) + return + } + val metadataMap = HashMap() - if (isSupportedByPixyMeta(mimeType)) { - try { - StorageUtils.openInputStream(context, uri)?.use { input -> - metadataMap.putAll(PixyMetaHelper.describe(input)) - } - } catch (e: Exception) { - result.error("getPixyMetadata-exception", e.message, e.stackTraceToString()) + try { + StorageUtils.openInputStream(context, uri)?.use { input -> + metadataMap.putAll(PixyMetaHelper.describe(input)) } + } catch (e: Exception) { + result.error("getPixyMetadata-exception", e.message, e.stackTraceToString()) + return } result.success(metadataMap) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt index 4a90afec2..c0bd10e44 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt @@ -50,7 +50,7 @@ internal class SvgFetcher(val model: SvgThumbnail, val width: Int, val height: I val context = model.context val uri = model.uri - StorageUtils.openInputStream(context, uri)?.use { input -> + val bitmap: Bitmap? = StorageUtils.openInputStream(context, uri)?.use { input -> try { SVG.getFromInputStream(input)?.let { svg -> svg.normalizeSize() @@ -71,12 +71,19 @@ internal class SvgFetcher(val model: SvgThumbnail, val width: Int, val height: I val canvas = Canvas(bitmap) svg.renderToCanvas(canvas) - callback.onDataReady(bitmap) + bitmap } } catch (ex: SVGParseException) { callback.onLoadFailed(ex) + return } } + + if (bitmap == null) { + callback.onLoadFailed(Exception("failed to load SVG for uri=$uri")) + } else { + callback.onDataReady(bitmap) + } } override fun cleanup() {} diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/VideoThumbnailGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/VideoThumbnailGlideModule.kt index 37bc13071..d0f2138ff 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/VideoThumbnailGlideModule.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/VideoThumbnailGlideModule.kt @@ -52,7 +52,9 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFe override fun loadData(priority: Priority, callback: DataCallback) { GlobalScope.launch(Dispatchers.IO) { val retriever = openMetadataRetriever(model.context, model.uri) - if (retriever != null) { + if (retriever == null) { + callback.onLoadFailed(Exception("failed to initialize MediaMetadataRetriever for uri=${model.uri}")) + } else { try { var bytes = retriever.embeddedPicture if (bytes == null) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index c4c3ba8db..a5f2f3f29 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -467,6 +467,7 @@ object StorageUtils { } } catch (e: Exception) { // unsupported format + Log.w(LOG_TAG, "failed to initialize MediaMetadataRetriever for uri=$uri") null } } diff --git a/assets/terms.md b/assets/terms.md index 752eb1e13..101150057 100644 --- a/assets/terms.md +++ b/assets/terms.md @@ -14,4 +14,4 @@ __We collect anonymous data to improve the app.__ We use Google Firebase for Cra ## Links [Sources](https://github.com/deckerst/aves) -[License](https://github.com/deckerst/aves/blob/master/LICENSE) +[License](https://github.com/deckerst/aves/blob/main/LICENSE) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7bd7cf0cd..49cd2dc3e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -363,8 +363,11 @@ "aboutPageTitle": "About", "@aboutPageTitle": {}, - "aboutFlutter": "Flutter", - "@aboutFlutter": {}, + "aboutLinkSources": "Sources", + "@aboutLinkSources": {}, + "aboutLinkLicense": "License", + "@aboutLinkLicense": {}, + "aboutUpdate": "New Version Available", "@aboutUpdate": {}, "aboutUpdateLinks1": "A new version of Aves is available on", @@ -377,12 +380,29 @@ "@aboutUpdateGitHub": {}, "aboutUpdateGooglePlay": "Google Play", "@aboutUpdateGooglePlay": {}, + + "aboutBug": "Bug Report", + "@aboutBug": {}, + "aboutBugSaveLogInstruction": "Save app logs to a file", + "@aboutBugSaveLogInstruction": {}, + "aboutBugSaveLogButton": "Save", + "@aboutBugSaveLogButton": {}, + "aboutBugCopyInfoInstruction": "Copy system information", + "@aboutBugCopyInfoInstruction": {}, + "aboutBugCopyInfoButton": "Copy", + "@aboutBugCopyInfoButton": {}, + "aboutBugReportInstruction": "Report on GitHub with the logs and system information", + "@aboutBugReportInstruction": {}, + "aboutBugReportButton": "Report", + "@aboutBugReportButton": {}, + "aboutCredits": "Credits", "@aboutCredits": {}, "aboutCreditsWorldAtlas1": "This app uses a TopoJSON file from", "@aboutCreditsWorldAtlas1": {}, "aboutCreditsWorldAtlas2": "under ISC License.", "@aboutCreditsWorldAtlas2": {}, + "aboutLicenses": "Open-Source Licenses", "@aboutLicenses": {}, "aboutLicensesBanner": "This app uses the following open-source packages and libraries.", @@ -714,7 +734,7 @@ "settingsSectionPrivacy": "Privacy", "@settingsSectionPrivacy": {}, - "settingsEnableCrashReport": "Allow anonymous crash reporting", + "settingsEnableCrashReport": "Allow anonymous error reporting", "@settingsEnableCrashReport": {}, "settingsSaveSearchHistory": "Save search history", "@settingsSaveSearchHistory": {}, diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 5f4e891a4..9e5c7453b 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -142,8 +142,8 @@ "renameEntryDialogLabel": "이름", "editEntryDateDialogTitle": "날짜 및 시간", - "editEntryDateDialogSet": "설정", - "editEntryDateDialogShift": "앞뒤로", + "editEntryDateDialogSet": "편집", + "editEntryDateDialogShift": "시간 이동", "editEntryDateDialogClear": "삭제", "editEntryDateDialogFieldSelection": "필드 선택", "editEntryDateDialogHours": "시간", @@ -170,16 +170,28 @@ "menuActionStats": "통계", "aboutPageTitle": "앱 정보", - "aboutFlutter": "플러터", + "aboutLinkSources": "소스 코드", + "aboutLinkLicense": "라이선스", + "aboutUpdate": "업데이트 사용 가능", "aboutUpdateLinks1": "앱의 최신 버전을", "aboutUpdateLinks2": "와", "aboutUpdateLinks3": "에서 다운로드 사용 가능합니다.", "aboutUpdateGitHub": "깃허브", "aboutUpdateGooglePlay": "구글 플레이", + + "aboutBug": "버그 보고", + "aboutBugSaveLogInstruction": "앱 로그를 파일에 저장하기", + "aboutBugSaveLogButton": "저장", + "aboutBugCopyInfoInstruction": "시스템 정보를 복사하기", + "aboutBugCopyInfoButton": "복사", + "aboutBugReportInstruction": "로그와 시스템 정보를 첨부하여 깃허브에서 이슈를 제출하기", + "aboutBugReportButton": "제출", + "aboutCredits": "크레딧", "aboutCreditsWorldAtlas1": "이 앱은", "aboutCreditsWorldAtlas2": "의 TopoJSON 파일(ISC 라이선스)을 이용합니다.", + "aboutLicenses": "오픈 소스 라이선스", "aboutLicensesBanner": "이 앱은 다음의 오픈 소스 패키지와 라이브러리를 이용합니다.", "aboutLicensesAndroidLibraries": "안드로이드 라이브러리", diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index e7c44407e..7636c330d 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -3,28 +3,49 @@ import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/actions/video_actions.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; +import 'package:aves/model/settings/enums.dart'; +import 'package:aves/model/source/enums.dart'; 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'; class SettingsDefaults { + // app + static const hasAcceptedTerms = false; + static const isCrashlyticsEnabled = false; + static const mustBackTwiceToExit = true; + static const keepScreenOn = KeepScreenOn.viewerOnly; + static const homePage = HomePageSetting.collection; + // drawer static final drawerTypeBookmarks = [ null, MimeFilter.video, FavouriteFilter.instance, ]; - static final drawerPageBookmarks = [ + static const drawerPageBookmarks = [ AlbumListPage.routeName, CountryListPage.routeName, TagListPage.routeName, ]; // collection + static const collectionSectionFactor = EntryGroupFactor.month; + static const collectionSortFactor = EntrySortFactor.date; static const collectionSelectionQuickActions = [ EntrySetAction.share, EntrySetAction.delete, ]; + static const showThumbnailLocation = true; + static const showThumbnailRaw = true; + static const showThumbnailVideoDuration = true; + + // filter grids + static const albumGroupFactor = AlbumChipGroupFactor.importance; + static const albumSortFactor = ChipSortFactor.name; + static const countrySortFactor = ChipSortFactor.name; + static const tagSortFactor = ChipSortFactor.name; // viewer static const viewerQuickActions = [ @@ -32,10 +53,37 @@ class SettingsDefaults { EntryAction.share, EntryAction.rotateScreen, ]; + static const showOverlayMinimap = false; + static const showOverlayInfo = true; + static const showOverlayShootingDetails = false; + static const enableOverlayBlurEffect = true; + static const viewerUseCutout = true; // video static const videoQuickActions = [ VideoAction.replay10, VideoAction.togglePlay, ]; + static const enableVideoHardwareAcceleration = true; + static const enableVideoAutoPlay = false; + static const videoLoopMode = VideoLoopMode.shortOnly; + static const videoShowRawTimedText = false; + + // subtitles + static const subtitleFontSize = 20.0; + static const subtitleTextAlignment = TextAlign.center; + static const subtitleShowOutline = true; + static const subtitleTextColor = Colors.white; + static const subtitleBackgroundColor = Colors.transparent; + + // info + static const infoMapStyle = EntryMapStyle.stamenWatercolor; + static const infoMapZoom = 12.0; + static const coordinateFormat = CoordinateFormat.dms; + + // rendering + static const imageBackground = EntryBackground.white; + + // search + static const saveSearchHistory = true; } diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 76ec17bee..b06cc923f 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -143,11 +143,11 @@ class Settings extends ChangeNotifier { // app - bool get hasAcceptedTerms => getBoolOrDefault(hasAcceptedTermsKey, false); + bool get hasAcceptedTerms => getBoolOrDefault(hasAcceptedTermsKey, SettingsDefaults.hasAcceptedTerms); set hasAcceptedTerms(bool newValue) => setAndNotify(hasAcceptedTermsKey, newValue); - bool get isCrashlyticsEnabled => getBoolOrDefault(isCrashlyticsEnabledKey, false); + bool get isCrashlyticsEnabled => getBoolOrDefault(isCrashlyticsEnabledKey, SettingsDefaults.isCrashlyticsEnabled); set isCrashlyticsEnabled(bool newValue) { setAndNotify(isCrashlyticsEnabledKey, newValue); @@ -182,18 +182,18 @@ class Settings extends ChangeNotifier { setAndNotify(localeKey, tag); } - bool get mustBackTwiceToExit => getBoolOrDefault(mustBackTwiceToExitKey, true); + bool get mustBackTwiceToExit => getBoolOrDefault(mustBackTwiceToExitKey, SettingsDefaults.mustBackTwiceToExit); set mustBackTwiceToExit(bool newValue) => setAndNotify(mustBackTwiceToExitKey, newValue); - KeepScreenOn get keepScreenOn => getEnumOrDefault(keepScreenOnKey, KeepScreenOn.viewerOnly, KeepScreenOn.values); + KeepScreenOn get keepScreenOn => getEnumOrDefault(keepScreenOnKey, SettingsDefaults.keepScreenOn, KeepScreenOn.values); set keepScreenOn(KeepScreenOn newValue) { setAndNotify(keepScreenOnKey, newValue.toString()); newValue.apply(); } - HomePageSetting get homePage => getEnumOrDefault(homePageKey, HomePageSetting.collection, HomePageSetting.values); + HomePageSetting get homePage => getEnumOrDefault(homePageKey, SettingsDefaults.homePage, HomePageSetting.values); set homePage(HomePageSetting newValue) => setAndNotify(homePageKey, newValue.toString()); @@ -226,11 +226,11 @@ class Settings extends ChangeNotifier { // collection - EntryGroupFactor get collectionSectionFactor => getEnumOrDefault(collectionGroupFactorKey, EntryGroupFactor.month, EntryGroupFactor.values); + EntryGroupFactor get collectionSectionFactor => getEnumOrDefault(collectionGroupFactorKey, SettingsDefaults.collectionSectionFactor, EntryGroupFactor.values); set collectionSectionFactor(EntryGroupFactor newValue) => setAndNotify(collectionGroupFactorKey, newValue.toString()); - EntrySortFactor get collectionSortFactor => getEnumOrDefault(collectionSortFactorKey, EntrySortFactor.date, EntrySortFactor.values); + EntrySortFactor get collectionSortFactor => getEnumOrDefault(collectionSortFactorKey, SettingsDefaults.collectionSortFactor, EntrySortFactor.values); set collectionSortFactor(EntrySortFactor newValue) => setAndNotify(collectionSortFactorKey, newValue.toString()); @@ -238,33 +238,33 @@ class Settings extends ChangeNotifier { set collectionSelectionQuickActions(List newValue) => setAndNotify(collectionSelectionQuickActionsKey, newValue.map((v) => v.toString()).toList()); - bool get showThumbnailLocation => getBoolOrDefault(showThumbnailLocationKey, true); + bool get showThumbnailLocation => getBoolOrDefault(showThumbnailLocationKey, SettingsDefaults.showThumbnailLocation); set showThumbnailLocation(bool newValue) => setAndNotify(showThumbnailLocationKey, newValue); - bool get showThumbnailRaw => getBoolOrDefault(showThumbnailRawKey, true); + bool get showThumbnailRaw => getBoolOrDefault(showThumbnailRawKey, SettingsDefaults.showThumbnailRaw); set showThumbnailRaw(bool newValue) => setAndNotify(showThumbnailRawKey, newValue); - bool get showThumbnailVideoDuration => getBoolOrDefault(showThumbnailVideoDurationKey, true); + bool get showThumbnailVideoDuration => getBoolOrDefault(showThumbnailVideoDurationKey, SettingsDefaults.showThumbnailVideoDuration); set showThumbnailVideoDuration(bool newValue) => setAndNotify(showThumbnailVideoDurationKey, newValue); // filter grids - AlbumChipGroupFactor get albumGroupFactor => getEnumOrDefault(albumGroupFactorKey, AlbumChipGroupFactor.importance, AlbumChipGroupFactor.values); + AlbumChipGroupFactor get albumGroupFactor => getEnumOrDefault(albumGroupFactorKey, SettingsDefaults.albumGroupFactor, AlbumChipGroupFactor.values); set albumGroupFactor(AlbumChipGroupFactor newValue) => setAndNotify(albumGroupFactorKey, newValue.toString()); - ChipSortFactor get albumSortFactor => getEnumOrDefault(albumSortFactorKey, ChipSortFactor.name, ChipSortFactor.values); + ChipSortFactor get albumSortFactor => getEnumOrDefault(albumSortFactorKey, SettingsDefaults.albumSortFactor, ChipSortFactor.values); set albumSortFactor(ChipSortFactor newValue) => setAndNotify(albumSortFactorKey, newValue.toString()); - ChipSortFactor get countrySortFactor => getEnumOrDefault(countrySortFactorKey, ChipSortFactor.name, ChipSortFactor.values); + ChipSortFactor get countrySortFactor => getEnumOrDefault(countrySortFactorKey, SettingsDefaults.countrySortFactor, ChipSortFactor.values); set countrySortFactor(ChipSortFactor newValue) => setAndNotify(countrySortFactorKey, newValue.toString()); - ChipSortFactor get tagSortFactor => getEnumOrDefault(tagSortFactorKey, ChipSortFactor.name, ChipSortFactor.values); + ChipSortFactor get tagSortFactor => getEnumOrDefault(tagSortFactorKey, SettingsDefaults.tagSortFactor, ChipSortFactor.values); set tagSortFactor(ChipSortFactor newValue) => setAndNotify(tagSortFactorKey, newValue.toString()); @@ -282,23 +282,23 @@ class Settings extends ChangeNotifier { set viewerQuickActions(List newValue) => setAndNotify(viewerQuickActionsKey, newValue.map((v) => v.toString()).toList()); - bool get showOverlayMinimap => getBoolOrDefault(showOverlayMinimapKey, false); + bool get showOverlayMinimap => getBoolOrDefault(showOverlayMinimapKey, SettingsDefaults.showOverlayMinimap); set showOverlayMinimap(bool newValue) => setAndNotify(showOverlayMinimapKey, newValue); - bool get showOverlayInfo => getBoolOrDefault(showOverlayInfoKey, true); + bool get showOverlayInfo => getBoolOrDefault(showOverlayInfoKey, SettingsDefaults.showOverlayInfo); set showOverlayInfo(bool newValue) => setAndNotify(showOverlayInfoKey, newValue); - bool get showOverlayShootingDetails => getBoolOrDefault(showOverlayShootingDetailsKey, false); + bool get showOverlayShootingDetails => getBoolOrDefault(showOverlayShootingDetailsKey, SettingsDefaults.showOverlayShootingDetails); set showOverlayShootingDetails(bool newValue) => setAndNotify(showOverlayShootingDetailsKey, newValue); - bool get enableOverlayBlurEffect => getBoolOrDefault(enableOverlayBlurEffectKey, true); + bool get enableOverlayBlurEffect => getBoolOrDefault(enableOverlayBlurEffectKey, SettingsDefaults.enableOverlayBlurEffect); set enableOverlayBlurEffect(bool newValue) => setAndNotify(enableOverlayBlurEffectKey, newValue); - bool get viewerUseCutout => getBoolOrDefault(viewerUseCutoutKey, true); + bool get viewerUseCutout => getBoolOrDefault(viewerUseCutoutKey, SettingsDefaults.viewerUseCutout); set viewerUseCutout(bool newValue) => setAndNotify(viewerUseCutoutKey, newValue); @@ -308,67 +308,67 @@ class Settings extends ChangeNotifier { set videoQuickActions(List newValue) => setAndNotify(videoQuickActionsKey, newValue.map((v) => v.toString()).toList()); - bool get enableVideoHardwareAcceleration => getBoolOrDefault(enableVideoHardwareAccelerationKey, true); + bool get enableVideoHardwareAcceleration => getBoolOrDefault(enableVideoHardwareAccelerationKey, SettingsDefaults.enableVideoHardwareAcceleration); set enableVideoHardwareAcceleration(bool newValue) => setAndNotify(enableVideoHardwareAccelerationKey, newValue); - bool get enableVideoAutoPlay => getBoolOrDefault(enableVideoAutoPlayKey, false); + bool get enableVideoAutoPlay => getBoolOrDefault(enableVideoAutoPlayKey, SettingsDefaults.enableVideoAutoPlay); set enableVideoAutoPlay(bool newValue) => setAndNotify(enableVideoAutoPlayKey, newValue); - VideoLoopMode get videoLoopMode => getEnumOrDefault(videoLoopModeKey, VideoLoopMode.shortOnly, VideoLoopMode.values); + VideoLoopMode get videoLoopMode => getEnumOrDefault(videoLoopModeKey, SettingsDefaults.videoLoopMode, VideoLoopMode.values); set videoLoopMode(VideoLoopMode newValue) => setAndNotify(videoLoopModeKey, newValue.toString()); - bool get videoShowRawTimedText => getBoolOrDefault(videoShowRawTimedTextKey, false); + bool get videoShowRawTimedText => getBoolOrDefault(videoShowRawTimedTextKey, SettingsDefaults.videoShowRawTimedText); set videoShowRawTimedText(bool newValue) => setAndNotify(videoShowRawTimedTextKey, newValue); // subtitles - double get subtitleFontSize => _prefs!.getDouble(subtitleFontSizeKey) ?? 20; + double get subtitleFontSize => _prefs!.getDouble(subtitleFontSizeKey) ?? SettingsDefaults.subtitleFontSize; set subtitleFontSize(double newValue) => setAndNotify(subtitleFontSizeKey, newValue); - TextAlign get subtitleTextAlignment => getEnumOrDefault(subtitleTextAlignmentKey, TextAlign.center, TextAlign.values); + TextAlign get subtitleTextAlignment => getEnumOrDefault(subtitleTextAlignmentKey, SettingsDefaults.subtitleTextAlignment, TextAlign.values); set subtitleTextAlignment(TextAlign newValue) => setAndNotify(subtitleTextAlignmentKey, newValue.toString()); - bool get subtitleShowOutline => getBoolOrDefault(subtitleShowOutlineKey, true); + bool get subtitleShowOutline => getBoolOrDefault(subtitleShowOutlineKey, SettingsDefaults.subtitleShowOutline); set subtitleShowOutline(bool newValue) => setAndNotify(subtitleShowOutlineKey, newValue); - Color get subtitleTextColor => Color(_prefs!.getInt(subtitleTextColorKey) ?? Colors.white.value); + Color get subtitleTextColor => Color(_prefs!.getInt(subtitleTextColorKey) ?? SettingsDefaults.subtitleTextColor.value); set subtitleTextColor(Color newValue) => setAndNotify(subtitleTextColorKey, newValue.value); - Color get subtitleBackgroundColor => Color(_prefs!.getInt(subtitleBackgroundColorKey) ?? Colors.transparent.value); + Color get subtitleBackgroundColor => Color(_prefs!.getInt(subtitleBackgroundColorKey) ?? SettingsDefaults.subtitleBackgroundColor.value); set subtitleBackgroundColor(Color newValue) => setAndNotify(subtitleBackgroundColorKey, newValue.value); // info - EntryMapStyle get infoMapStyle => getEnumOrDefault(infoMapStyleKey, EntryMapStyle.stamenWatercolor, EntryMapStyle.values); + EntryMapStyle get infoMapStyle => getEnumOrDefault(infoMapStyleKey, SettingsDefaults.infoMapStyle, EntryMapStyle.values); set infoMapStyle(EntryMapStyle newValue) => setAndNotify(infoMapStyleKey, newValue.toString()); - double get infoMapZoom => _prefs!.getDouble(infoMapZoomKey) ?? 12; + double get infoMapZoom => _prefs!.getDouble(infoMapZoomKey) ?? SettingsDefaults.infoMapZoom; set infoMapZoom(double newValue) => setAndNotify(infoMapZoomKey, newValue); - CoordinateFormat get coordinateFormat => getEnumOrDefault(coordinateFormatKey, CoordinateFormat.dms, CoordinateFormat.values); + CoordinateFormat get coordinateFormat => getEnumOrDefault(coordinateFormatKey, SettingsDefaults.coordinateFormat, CoordinateFormat.values); set coordinateFormat(CoordinateFormat newValue) => setAndNotify(coordinateFormatKey, newValue.toString()); // rendering - EntryBackground get imageBackground => getEnumOrDefault(imageBackgroundKey, EntryBackground.white, EntryBackground.values); + EntryBackground get imageBackground => getEnumOrDefault(imageBackgroundKey, SettingsDefaults.imageBackground, EntryBackground.values); set imageBackground(EntryBackground newValue) => setAndNotify(imageBackgroundKey, newValue.toString()); // search - bool get saveSearchHistory => getBoolOrDefault(saveSearchHistoryKey, true); + bool get saveSearchHistory => getBoolOrDefault(saveSearchHistoryKey, SettingsDefaults.saveSearchHistory); set saveSearchHistory(bool newValue) => setAndNotify(saveSearchHistoryKey, newValue); diff --git a/lib/model/video/metadata.dart b/lib/model/video/metadata.dart index 2a792dc7e..c20c3a805 100644 --- a/lib/model/video/metadata.dart +++ b/lib/model/video/metadata.dart @@ -82,8 +82,13 @@ class VideoMetadataFormatter { int? dateMillis; - final dateString = mediaInfo[Keys.date]; - if (dateString is String && dateString != '0') { + bool isDefined(dynamic value) => value is String && value != '0'; + + var dateString = mediaInfo[Keys.date]; + if (!isDefined(dateString)) { + dateString = mediaInfo[Keys.creationTime]; + } + if (isDefined(dateString)) { final date = DateTime.tryParse(dateString); if (date != null) { dateMillis = date.millisecondsSinceEpoch; diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart index 62d576670..9868a5110 100644 --- a/lib/ref/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -47,8 +47,10 @@ class MimeTypes { static const mp2t = 'video/mp2t'; // .m2ts static const mp4 = 'video/mp4'; static const ogg = 'video/ogg'; + static const webm = 'video/webm'; static const json = 'application/json'; + static const plainText = 'text/plain'; // groups @@ -62,7 +64,7 @@ class MimeTypes { static const Set _knownOpaqueImages = {heic, heif, jpeg}; - static const Set _knownVideos = {avi, aviVnd, mkv, mov, mp2t, mp4, ogg}; + static const Set _knownVideos = {avi, aviVnd, mkv, mov, mp2t, mp4, ogg, webm}; static final Set knownMediaTypes = {..._knownOpaqueImages, ...alphaImages, ...rawImages, ...undecodableImages, ..._knownVideos}; diff --git a/lib/services/report_service.dart b/lib/services/report_service.dart index 00930ee28..fb61799d2 100644 --- a/lib/services/report_service.dart +++ b/lib/services/report_service.dart @@ -1,5 +1,8 @@ +import 'package:collection/collection.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:stack_trace/stack_trace.dart'; abstract class ReportService { bool get isCollectionEnabled; @@ -40,6 +43,19 @@ class CrashlyticsReportService extends ReportService { @override Future recordError(dynamic exception, StackTrace? stack) { + if (exception is PlatformException && stack != null) { + // simply creating a trace with `Trace.current(1)` or creating a `Trace` from modified frames + // does not yield a stack trace that Crashlytics can segment, + // so we reconstruct a string stack trace instead + stack = StackTrace.fromString(Trace.from(stack) + .frames + .skip(2) + .toList() + .mapIndexed( + (i, f) => '#${(i++).toString().padRight(8)}${f.member} (${f.uri}:${f.line}:${f.column})', + ) + .join('\n')); + } return instance.recordError(exception, stack); } diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index a23273655..ffe0201b0 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -102,4 +102,7 @@ class AIcons { static const IconData threeSixty = Icons.threesixty_outlined; static const IconData selected = Icons.check_circle_outline; static const IconData unselected = Icons.radio_button_unchecked; + + static const IconData github = MdiIcons.github; + static const IconData legal = MdiIcons.scaleBalance; } diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index ab0ab82c2..4ef6ce2d9 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -38,6 +38,8 @@ class Constants { static const int infoGroupMaxValueLength = 140; + static const String avesGithub = 'https://github.com/deckerst/aves'; + static const List androidDependencies = [ Dependency( name: 'AndroidX Core-KTX', @@ -91,6 +93,12 @@ class Constants { licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/connectivity_plus/connectivity_plus/LICENSE', sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/connectivity_plus', ), + Dependency( + name: 'Device Info Plus', + license: 'BSD 3-Clause', + licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/device_info_plus/device_info_plus/LICENSE', + sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/device_info_plus', + ), Dependency( name: 'FlutterFire (Core, Crashlytics)', license: 'BSD 3-Clause', @@ -289,15 +297,20 @@ class Constants { license: 'Apache 2.0', sourceUrl: 'https://github.com/DavBfr/dart_pdf', ), + Dependency( + name: 'Stack Trace', + license: 'BSD 3-Clause', + sourceUrl: 'https://github.com/dart-lang/stack_trace', + ), Dependency( name: 'Transparent Image', license: 'MIT', - sourceUrl: 'https://pub.dev/packages/transparent_image', + sourceUrl: 'https://github.com/brianegan/transparent_image', ), Dependency( name: 'Tuple', license: 'BSD 2-Clause', - sourceUrl: 'https://github.com/dart-lang/tuple', + sourceUrl: 'https://github.com/google/tuple.dart', ), Dependency( name: 'Version', diff --git a/lib/widgets/about/about_page.dart b/lib/widgets/about/about_page.dart index 564d36509..7e124c114 100644 --- a/lib/widgets/about/about_page.dart +++ b/lib/widgets/about/about_page.dart @@ -1,4 +1,5 @@ import 'package:aves/widgets/about/app_ref.dart'; +import 'package:aves/widgets/about/bug_report.dart'; import 'package:aves/widgets/about/credits.dart'; import 'package:aves/widgets/about/licenses.dart'; import 'package:aves/widgets/about/update.dart'; @@ -27,6 +28,8 @@ class AboutPage extends StatelessWidget { AppReference(), Divider(), AboutUpdate(), + BugReport(), + Divider(), AboutCredits(), Divider(), ], diff --git a/lib/widgets/about/app_ref.dart b/lib/widgets/about/app_ref.dart index f995f0a7a..77f8d0b5f 100644 --- a/lib/widgets/about/app_ref.dart +++ b/lib/widgets/about/app_ref.dart @@ -1,6 +1,7 @@ import 'dart:ui'; -import 'package:aves/flutter_version.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/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_logo.dart'; @@ -29,8 +30,8 @@ class _AppReferenceState extends State { child: Column( children: [ _buildAvesLine(), - _buildFlutterLine(), const SizedBox(height: 16), + _buildLinks(), ], ), ); @@ -47,37 +48,45 @@ class _AppReferenceState extends State { return FutureBuilder( future: _packageInfoLoader, builder: (context, snapshot) { - return LinkChip( - leading: AvesLogo( - size: style.fontSize! * MediaQuery.textScaleFactorOf(context) * 1.25, - ), - text: '${context.l10n.appName} ${snapshot.data?.version}', - url: 'https://github.com/deckerst/aves', - textStyle: style, + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + AvesLogo( + size: style.fontSize! * MediaQuery.textScaleFactorOf(context) * 1.25, + ), + const SizedBox(width: 8), + Text( + '${context.l10n.appName} ${snapshot.data?.version}', + style: style, + ), + ], ); }, ); } - Widget _buildFlutterLine() { - final style = DefaultTextStyle.of(context).style; - final subColor = style.color!.withOpacity(.6); - - return Text.rich( - TextSpan( - children: [ - WidgetSpan( - child: Padding( - padding: const EdgeInsetsDirectional.only(end: 4), - child: FlutterLogo( - size: style.fontSize! * 1.25, - ), - ), + Widget _buildLinks() { + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 16, + children: [ + LinkChip( + leading: const Icon( + AIcons.github, + size: 24, ), - TextSpan(text: '${context.l10n.aboutFlutter} ${version['frameworkVersion']}'), - ], - ), - style: TextStyle(color: subColor), + text: context.l10n.aboutLinkSources, + url: Constants.avesGithub, + ), + LinkChip( + leading: const Icon( + AIcons.legal, + size: 22, + ), + text: context.l10n.aboutLinkLicense, + url: '${Constants.avesGithub}/blob/main/LICENSE', + ), + ], ); } } diff --git a/lib/widgets/about/bug_report.dart b/lib/widgets/about/bug_report.dart new file mode 100644 index 000000000..1d39146ca --- /dev/null +++ b/lib/widgets/about/bug_report.dart @@ -0,0 +1,163 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:aves/flutter_version.dart'; +import 'package:aves/ref/mime_types.dart'; +import 'package:aves/services/services.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class BugReport extends StatefulWidget { + const BugReport({Key? key}) : super(key: key); + + @override + _BugReportState createState() => _BugReportState(); +} + +class _BugReportState extends State with FeedbackMixin { + late Future _infoLoader; + bool _showInstructions = false; + + @override + void initState() { + super.initState(); + _infoLoader = _getInfo(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return ExpansionPanelList( + expansionCallback: (index, isExpanded) { + setState(() => _showInstructions = !isExpanded); + }, + expandedHeaderPadding: EdgeInsets.zero, + elevation: 0, + children: [ + ExpansionPanel( + headerBuilder: (context, isExpanded) => ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + alignment: AlignmentDirectional.centerStart, + child: Text(l10n.aboutBug, style: Constants.titleTextStyle), + ), + ), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildStep(1, l10n.aboutBugSaveLogInstruction, l10n.aboutBugSaveLogButton, _saveLogs), + _buildStep(2, l10n.aboutBugCopyInfoInstruction, l10n.aboutBugCopyInfoButton, _copySystemInfo), + FutureBuilder( + future: _infoLoader, + builder: (context, snapshot) { + final info = snapshot.data; + if (info == null) return const SizedBox(); + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey.shade800, + border: Border.all( + color: Colors.white, + ), + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + margin: const EdgeInsets.symmetric(vertical: 8), + child: SelectableText(info)); + }, + ), + _buildStep(3, l10n.aboutBugReportInstruction, l10n.aboutBugReportButton, _goToGithub), + const SizedBox(height: 16), + ], + ), + ), + isExpanded: _showInstructions, + canTapOnHeader: true, + backgroundColor: Colors.transparent, + ), + ], + ); + } + + Widget _buildStep(int step, String text, String buttonText, VoidCallback onPressed) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.fromBorderSide(BorderSide( + color: Theme.of(context).accentColor, + width: AvesFilterChip.outlineWidth, + )), + shape: BoxShape.circle, + ), + child: Text('$step'), + ), + const SizedBox(width: 8), + Expanded(child: Text(text)), + const SizedBox(width: 8), + OutlinedButton( + onPressed: onPressed, + style: ButtonStyle( + side: MaterialStateProperty.all(BorderSide(color: Theme.of(context).accentColor)), + foregroundColor: MaterialStateProperty.all(Colors.white), + ), + child: Text(buttonText), + ) + ], + ), + ); + } + + Future _getInfo() async { + final packageInfo = await PackageInfo.fromPlatform(); + final androidInfo = await DeviceInfoPlugin().androidInfo; + final hasPlayServices = await availability.hasPlayServices; + return [ + 'Aves version: ${packageInfo.version} (Build ${packageInfo.buildNumber})', + 'Flutter version: ${version['frameworkVersion']} (Channel ${version['channel']})', + 'Android version: ${androidInfo.version.release} (SDK ${androidInfo.version.sdkInt})', + 'Device: ${androidInfo.manufacturer} ${androidInfo.model}', + 'Google Play services: ${hasPlayServices ? 'ready' : 'not available'}', + ].join('\n'); + } + + Future _saveLogs() async { + final result = await Process.run('logcat', ['-d']); + final logs = result.stdout; + final success = await storageService.createFile( + 'aves-logs-${DateFormat('yyyyMMdd_HHmmss').format(DateTime.now())}.txt', + MimeTypes.plainText, + Uint8List.fromList(utf8.encode(logs)), + ); + if (success != null) { + if (success) { + showFeedback(context, context.l10n.genericSuccessFeedback); + } else { + showFeedback(context, context.l10n.genericFailureFeedback); + } + } + } + + Future _copySystemInfo() async { + await Clipboard.setData(ClipboardData(text: await _infoLoader)); + showFeedback(context, context.l10n.genericSuccessFeedback); + } + + Future _goToGithub() async { + await launch('${Constants.avesGithub}/issues/new'); + } +} diff --git a/lib/widgets/about/update.dart b/lib/widgets/about/update.dart index 3c904f5b4..dbd9e2d4d 100644 --- a/lib/widgets/about/update.dart +++ b/lib/widgets/about/update.dart @@ -62,7 +62,7 @@ class _AboutUpdateState extends State { WidgetSpan( child: LinkChip( text: context.l10n.aboutUpdateGitHub, - url: 'https://github.com/deckerst/aves/releases', + url: '${Constants.avesGithub}/releases', textStyle: const TextStyle(fontWeight: FontWeight.bold), ), alignment: PlaceholderAlignment.middle, diff --git a/lib/widgets/common/basic/insets.dart b/lib/widgets/common/basic/insets.dart index af2f44867..cca2f4edc 100644 --- a/lib/widgets/common/basic/insets.dart +++ b/lib/widgets/common/basic/insets.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -52,7 +54,7 @@ class BottomPaddingSliver extends StatelessWidget { Widget build(BuildContext context) { return SliverToBoxAdapter( child: Selector( - selector: (context, mq) => mq.effectiveBottomPadding, + selector: (context, mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom), builder: (context, mqPaddingBottom, child) { return SizedBox(height: mqPaddingBottom); }, diff --git a/lib/widgets/common/basic/popup_menu_button.dart b/lib/widgets/common/basic/popup_menu_button.dart new file mode 100644 index 000000000..ede8487ee --- /dev/null +++ b/lib/widgets/common/basic/popup_menu_button.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +class AvesPopupMenuButton extends PopupMenuButton { + final VoidCallback? onMenuOpened; + + const AvesPopupMenuButton({ + Key? key, + required PopupMenuItemBuilder itemBuilder, + T? initialValue, + PopupMenuItemSelected? onSelected, + PopupMenuCanceled? onCanceled, + String? tooltip, + double? elevation, + EdgeInsetsGeometry padding = const EdgeInsets.all(8.0), + Widget? child, + Widget? icon, + Offset offset = Offset.zero, + bool enabled = true, + ShapeBorder? shape, + Color? color, + bool? enableFeedback, + double? iconSize, + this.onMenuOpened, + }) : super( + key: key, + itemBuilder: itemBuilder, + initialValue: initialValue, + onSelected: onSelected, + onCanceled: onCanceled, + tooltip: tooltip, + elevation: elevation, + padding: padding, + child: child, + icon: icon, + iconSize: iconSize, + offset: offset, + enabled: enabled, + shape: shape, + color: color, + enableFeedback: enableFeedback, + ); + + @override + _AvesPopupMenuButtonState createState() => _AvesPopupMenuButtonState(); +} + +class _AvesPopupMenuButtonState extends PopupMenuButtonState { + @override + void showButtonMenu() { + (widget as AvesPopupMenuButton).onMenuOpened?.call(); + super.showButtonMenu(); + } +} diff --git a/lib/widgets/settings/common/quick_actions/available_actions.dart b/lib/widgets/settings/common/quick_actions/available_actions.dart index 2f1a9ed7d..bfa5bc6a1 100644 --- a/lib/widgets/settings/common/quick_actions/available_actions.dart +++ b/lib/widgets/settings/common/quick_actions/available_actions.dart @@ -1,6 +1,8 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/settings/common/quick_actions/action_button.dart'; import 'package:aves/widgets/settings/common/quick_actions/placeholder.dart'; +import 'package:aves/widgets/viewer/overlay/common.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; class AvailableActionPanel extends StatelessWidget { @@ -75,10 +77,6 @@ class AvailableActionPanel extends StatelessWidget { Widget child, ) => LongPressDraggable( - data: action, - maxSimultaneousDrags: 1, - onDragStarted: () => _setDraggedAvailableAction(action), - onDragEnd: (details) => _setDraggedAvailableAction(null), feedback: MediaQueryDataProvider( child: _buildActionButton( context, @@ -86,6 +84,13 @@ class AvailableActionPanel extends StatelessWidget { showCaption: false, ), ), + data: action, + dragAnchorStrategy: (draggable, context, position) { + return childDragAnchorStrategy(draggable, context, position) + Offset(0, OverlayButton.getSize(context)); + }, + maxSimultaneousDrags: 1, + onDragStarted: () => _setDraggedAvailableAction(action), + onDragEnd: (details) => _setDraggedAvailableAction(null), childWhenDragging: child, child: child, ); diff --git a/lib/widgets/settings/common/quick_actions/quick_actions.dart b/lib/widgets/settings/common/quick_actions/quick_actions.dart index e45bdae6a..5e351048b 100644 --- a/lib/widgets/settings/common/quick_actions/quick_actions.dart +++ b/lib/widgets/settings/common/quick_actions/quick_actions.dart @@ -1,4 +1,5 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:flutter/widgets.dart'; enum QuickActionPlacement { header, action, footer } @@ -58,16 +59,19 @@ class QuickActionButton extends StatelessWidget { } Widget _buildDraggable(Widget child, T action) => LongPressDraggable( + feedback: MediaQueryDataProvider( + child: draggableFeedbackBuilder!(action), + ), data: action, + dragAnchorStrategy: (draggable, context, position) { + return childDragAnchorStrategy(draggable, context, position) + Offset(0, OverlayButton.getSize(context)); + }, 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: draggableFeedbackBuilder!(action), - ), childWhenDragging: child, child: child, ); diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index ece1e739d..e00045c73 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -304,6 +304,13 @@ class _EntryViewerStackState extends State with FeedbackMixin, _videoActionDelegate.onActionSelected(context, videoController, action); } }, + onActionMenuOpened: () { + // if the menu is opened while overlay is hiding, + // the popup menu button is disposed and menu items are ineffective, + // so we make sure overlay stays visible + _videoActionDelegate.stopOverlayHidingTimer(); + const ToggleOverlayNotification(visible: true).dispatch(context); + }, ), ); } else if (targetEntry.is360) { diff --git a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart index 81fbde239..9beea2310 100644 --- a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart +++ b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart @@ -4,6 +4,7 @@ import 'package:aves/utils/constants.dart'; import 'package:aves/utils/string_utils.dart'; import 'package:aves/widgets/common/identity/highlight_title.dart'; import 'package:aves/widgets/viewer/info/common.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_ns/darktable.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/exif.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/google.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/iptc.dart'; @@ -30,6 +31,8 @@ class XmpNamespace extends Equatable { switch (namespace) { case XmpBasicNamespace.ns: return XmpBasicNamespace(rawProps); + case XmpDarktableNamespace.ns: + return XmpDarktableNamespace(rawProps); case XmpExifNamespace.ns: return XmpExifNamespace(rawProps); case XmpGAudioNamespace.ns: @@ -136,8 +139,10 @@ class XmpProp { return propPath.splitMapJoin(XMP.structFieldSeparator, onMatch: (match) => ' ${match.group(0)} ', onNonMatch: (s) { - // strip namespace & format - return s.split(XMP.propNamespaceSeparator).last.toSentenceCase(); + // strip namespace + final key = s.split(XMP.propNamespaceSeparator).last; + // format + return key.replaceAll('_', ' ').toSentenceCase(); }); } diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/darktable.dart b/lib/widgets/viewer/info/metadata/xmp_ns/darktable.dart new file mode 100644 index 000000000..1959630e2 --- /dev/null +++ b/lib/widgets/viewer/info/metadata/xmp_ns/darktable.dart @@ -0,0 +1,25 @@ +import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; +import 'package:flutter/material.dart'; + +class XmpDarktableNamespace extends XmpNamespace { + static const ns = 'darktable'; + + static final historyPattern = RegExp(r'darktable:history\[(\d+)\]/(.*)'); + + final history = >{}; + + XmpDarktableNamespace(Map rawProps) : super(ns, rawProps); + + @override + bool extractData(XmpProp prop) => extractIndexedStruct(prop, historyPattern, history); + + @override + List buildFromExtractedData() => [ + if (history.isNotEmpty) + XmpStructArrayCard( + title: 'History', + structByIndex: history, + ), + ]; +} diff --git a/lib/widgets/viewer/overlay/bottom/video.dart b/lib/widgets/viewer/overlay/bottom/video.dart index addcdf028..a0ba9ee3b 100644 --- a/lib/widgets/viewer/overlay/bottom/video.dart +++ b/lib/widgets/viewer/overlay/bottom/video.dart @@ -9,6 +9,7 @@ import 'package:aves/theme/format.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/basic/menu.dart'; +import 'package:aves/widgets/common/basic/popup_menu_button.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'; @@ -24,6 +25,7 @@ class VideoControlOverlay extends StatefulWidget { final AvesVideoController? controller; final Animation scale; final Function(VideoAction value) onActionSelected; + final VoidCallback onActionMenuOpened; const VideoControlOverlay({ Key? key, @@ -31,6 +33,7 @@ class VideoControlOverlay extends StatefulWidget { required this.controller, required this.scale, required this.onActionSelected, + required this.onActionMenuOpened, }) : super(key: key); @override @@ -94,6 +97,7 @@ class _VideoControlOverlayState extends State with SingleTi scale: scale, controller: controller, onActionSelected: widget.onActionSelected, + onActionMenuOpened: widget.onActionMenuOpened, ), const SizedBox(height: 8), _buildProgressBar(), @@ -199,6 +203,7 @@ class _ButtonRow extends StatelessWidget { final Animation scale; final AvesVideoController? controller; final Function(VideoAction value) onActionSelected; + final VoidCallback onActionMenuOpened; const _ButtonRow({ Key? key, @@ -207,6 +212,7 @@ class _ButtonRow extends StatelessWidget { required this.scale, required this.controller, required this.onActionSelected, + required this.onActionMenuOpened, }) : super(key: key); static const double padding = 8; @@ -225,12 +231,13 @@ class _ButtonRow extends StatelessWidget { child: OverlayButton( scale: scale, child: MenuIconTheme( - child: PopupMenuButton( + child: AvesPopupMenuButton( itemBuilder: (context) => menuActions.map((action) => _buildPopupMenuItem(context, action)).toList(), onSelected: (action) { // wait for the popup menu to hide before proceeding with the action Future.delayed(Durations.popupMenuAnimation * timeDilation, () => onActionSelected(action)); }, + onMenuOpened: onActionMenuOpened, ), ), ), diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index 8244c8d2c..db300afb7 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -5,12 +5,14 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/basic/menu.dart'; +import 'package:aves/widgets/common/basic/popup_menu_button.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/sweeper.dart'; import 'package:aves/widgets/viewer/entry_action_delegate.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/overlay/minimap.dart'; +import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:aves/widgets/viewer/page_entry_builder.dart'; import 'package:aves/widgets/viewer/visual/conductor.dart'; import 'package:flutter/foundation.dart'; @@ -167,7 +169,7 @@ class _TopOverlayRow extends StatelessWidget { OverlayButton( scale: scale, child: MenuIconTheme( - child: PopupMenuButton( + child: AvesPopupMenuButton( key: const Key('entry-menu-button'), itemBuilder: (context) => [ ...inAppActions.map((action) => _buildPopupMenuItem(context, action)), @@ -183,6 +185,12 @@ class _TopOverlayRow extends StatelessWidget { // wait for the popup menu to hide before proceeding with the action Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(context, action)); }, + onMenuOpened: () { + // if the menu is opened while overlay is hiding, + // the popup menu button is disposed and menu items are ineffective, + // so we make sure overlay stays visible + const ToggleOverlayNotification(visible: true).dispatch(context); + }, ), ), ), diff --git a/lib/widgets/viewer/video_action_delegate.dart b/lib/widgets/viewer/video_action_delegate.dart index 9e2178c4f..0b5c21bd6 100644 --- a/lib/widgets/viewer/video_action_delegate.dart +++ b/lib/widgets/viewer/video_action_delegate.dart @@ -31,12 +31,12 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix }); void dispose() { - _stopOverlayHidingTimer(); + stopOverlayHidingTimer(); } void onActionSelected(BuildContext context, AvesVideoController controller, VideoAction action) { // make sure overlay is not disappearing when selecting an action - _stopOverlayHidingTimer(); + stopOverlayHidingTimer(); const ToggleOverlayNotification(visible: true).dispatch(context); switch (action) { @@ -187,5 +187,5 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } } - void _stopOverlayHidingTimer() => _overlayHidingTimer?.cancel(); + void stopOverlayHidingTimer() => _overlayHidingTimer?.cancel(); } diff --git a/pubspec.lock b/pubspec.lock index 27b31a9e7..f58765f0c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,7 +105,7 @@ packages: name: connectivity_plus url: "https://pub.dartlang.org" source: hosted - version: "1.0.7" + version: "1.1.0" connectivity_plus_linux: dependency: transitive description: @@ -119,7 +119,7 @@ packages: name: connectivity_plus_macos url: "https://pub.dartlang.org" source: hosted - version: "1.0.2" + version: "1.1.0" connectivity_plus_platform_interface: dependency: transitive description: @@ -133,14 +133,14 @@ packages: name: connectivity_plus_web url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.1.0+1" connectivity_plus_windows: dependency: transitive description: name: connectivity_plus_windows url: "https://pub.dartlang.org" source: hosted - version: "1.0.2" + version: "1.1.0" convert: dependency: transitive description: @@ -190,6 +190,48 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.1" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + device_info_plus_linux: + dependency: transitive + description: + name: device_info_plus_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + device_info_plus_macos: + dependency: transitive + description: + name: device_info_plus_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + device_info_plus_web: + dependency: transitive + description: + name: device_info_plus_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + device_info_plus_windows: + dependency: transitive + description: + name: device_info_plus_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" equatable: dependency: "direct main" description: @@ -232,7 +274,7 @@ packages: description: path: "." ref: aves - resolved-ref: "9542ec208248bfa4d459e3967087a4b236da1368" + resolved-ref: "2aefcebb9f4bc08107e7de16927d91e577e10d7d" url: "git://github.com/deckerst/fijkplayer.git" source: git version: "0.10.0" @@ -249,7 +291,7 @@ packages: name: firebase_core url: "https://pub.dartlang.org" source: hosted - version: "1.5.0" + version: "1.6.0" firebase_core_platform_interface: dependency: transitive description: @@ -270,14 +312,14 @@ packages: name: firebase_crashlytics url: "https://pub.dartlang.org" source: hosted - version: "2.2.0" + version: "2.2.1" firebase_crashlytics_platform_interface: dependency: transitive description: name: firebase_crashlytics_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "3.1.1" + version: "3.1.2" flex_color_picker: dependency: "direct main" description: @@ -341,14 +383,14 @@ packages: name: flutter_markdown url: "https://pub.dartlang.org" source: hosted - version: "0.6.4" + version: "0.6.6" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "2.0.3" flutter_staggered_animations: dependency: "direct main" description: @@ -405,7 +447,7 @@ packages: name: google_maps_flutter url: "https://pub.dartlang.org" source: hosted - version: "2.0.6" + version: "2.0.8" google_maps_flutter_platform_interface: dependency: transitive description: @@ -594,7 +636,7 @@ packages: name: package_info_plus url: "https://pub.dartlang.org" source: hosted - version: "1.0.5" + version: "1.0.6" package_info_plus_linux: dependency: transitive description: @@ -622,7 +664,7 @@ packages: name: package_info_plus_web url: "https://pub.dartlang.org" source: hosted - version: "1.0.3" + version: "1.0.4" package_info_plus_windows: dependency: transitive description: @@ -636,7 +678,7 @@ packages: name: palette_generator url: "https://pub.dartlang.org" source: hosted - version: "0.3.0" + version: "0.3.1" panorama: dependency: "direct main" description: @@ -685,7 +727,7 @@ packages: name: pdf url: "https://pub.dartlang.org" source: hosted - version: "3.4.2" + version: "3.5.0" pedantic: dependency: transitive description: @@ -804,7 +846,7 @@ packages: name: shared_preferences url: "https://pub.dartlang.org" source: hosted - version: "2.0.6" + version: "2.0.7" shared_preferences_linux: dependency: transitive description: @@ -832,7 +874,7 @@ packages: name: shared_preferences_web url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.0.2" shared_preferences_windows: dependency: transitive description: @@ -900,16 +942,16 @@ packages: name: sqflite url: "https://pub.dartlang.org" source: hosted - version: "2.0.0+3" + version: "2.0.0+4" sqflite_common: dependency: transitive description: name: sqflite_common url: "https://pub.dartlang.org" source: hosted - version: "2.0.0+2" + version: "2.0.1" stack_trace: - dependency: transitive + dependency: "direct main" description: name: stack_trace url: "https://pub.dartlang.org" @@ -1021,21 +1063,21 @@ packages: name: url_launcher url: "https://pub.dartlang.org" source: hosted - version: "6.0.9" + version: "6.0.10" url_launcher_linux: dependency: transitive description: name: url_launcher_linux url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.0.2" url_launcher_macos: dependency: transitive description: name: url_launcher_macos url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.0.2" url_launcher_platform_interface: dependency: transitive description: @@ -1049,7 +1091,7 @@ packages: name: url_launcher_web url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "2.0.4" url_launcher_windows: dependency: transitive description: @@ -1112,7 +1154,7 @@ packages: name: win32 url: "https://pub.dartlang.org" source: hosted - version: "2.2.5" + version: "2.2.8" wkt_parser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f0da08e0f..b73edacc5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: aves description: A visual media gallery and metadata explorer app. repository: https://github.com/deckerst/aves -version: 1.5.0+54 +version: 1.5.1+55 publish_to: none environment: @@ -19,6 +19,7 @@ dependencies: # TODO TLAD as of 2021/08/04, null safe version is pre-release custom_rounded_rectangle_border: '>=0.2.0-nullsafety.0' decorated_icon: + device_info_plus: equatable: event_bus: expansion_tile_card: @@ -56,6 +57,7 @@ dependencies: provider: shared_preferences: sqflite: + stack_trace: streams_channel: git: url: git://github.com/deckerst/aves_streams_channel.git diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index f399a334a..bca2efbf0 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,4 +1,7 @@ Thanks for using Aves! +v1.5.1: +- fixed hanging app for collections with specific video formats +- added bug reporting instructions v1.5.0: - faster launch - edit Exif dates