#39 listen to media store changes
This commit is contained in:
parent
b59b323d34
commit
c7fcb5bc53
18 changed files with 318 additions and 161 deletions
|
@ -16,24 +16,18 @@ import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.PermissionManager
|
import deckers.thibault.aves.utils.PermissionManager
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
companion object {
|
private lateinit var contentStreamHandler: ContentChangeStreamHandler
|
||||||
private val LOG_TAG = LogUtils.createTag(MainActivity::class.java)
|
private lateinit var intentStreamHandler: IntentStreamHandler
|
||||||
const val INTENT_CHANNEL = "deckers.thibault/aves/intent"
|
|
||||||
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val intentStreamHandler = IntentStreamHandler()
|
|
||||||
private lateinit var intentDataMap: MutableMap<String, Any?>
|
private lateinit var intentDataMap: MutableMap<String, Any?>
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
Log.i(LOG_TAG, "onCreate intent=$intent")
|
Log.i(LOG_TAG, "onCreate intent=$intent")
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
intentDataMap = extractIntentData(intent)
|
|
||||||
|
|
||||||
val messenger = flutterEngine!!.dartExecutor.binaryMessenger
|
val messenger = flutterEngine!!.dartExecutor.binaryMessenger
|
||||||
|
|
||||||
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
|
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
|
||||||
|
@ -48,59 +42,34 @@ class MainActivity : FlutterActivity() {
|
||||||
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) }
|
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) }
|
||||||
StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) }
|
StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) }
|
||||||
|
|
||||||
|
// Media Store change monitoring
|
||||||
|
contentStreamHandler = ContentChangeStreamHandler(this).apply {
|
||||||
|
EventChannel(messenger, ContentChangeStreamHandler.CHANNEL).setStreamHandler(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// intent handling
|
||||||
|
intentStreamHandler = IntentStreamHandler().apply {
|
||||||
|
EventChannel(messenger, IntentStreamHandler.CHANNEL).setStreamHandler(this)
|
||||||
|
}
|
||||||
|
intentDataMap = extractIntentData(intent)
|
||||||
MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler { call, result ->
|
MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getIntentData" -> {
|
"getIntentData" -> {
|
||||||
result.success(intentDataMap)
|
result.success(intentDataMap)
|
||||||
intentDataMap.clear()
|
intentDataMap.clear()
|
||||||
}
|
}
|
||||||
"pick" -> {
|
"pick" -> pick(call)
|
||||||
val pickedUri = call.argument<String>("uri")
|
|
||||||
if (pickedUri != null) {
|
|
||||||
val intent = Intent().apply {
|
|
||||||
data = Uri.parse(pickedUri)
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
setResult(RESULT_OK, intent)
|
|
||||||
} else {
|
|
||||||
setResult(RESULT_CANCELED)
|
|
||||||
}
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EventChannel(messenger, INTENT_CHANNEL).setStreamHandler(intentStreamHandler)
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||||
setupShortcuts()
|
setupShortcuts()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.N_MR1)
|
override fun onDestroy() {
|
||||||
private fun setupShortcuts() {
|
contentStreamHandler.dispose()
|
||||||
// do not use 'route' as extra key, as the Flutter framework acts on it
|
super.onDestroy()
|
||||||
|
|
||||||
val search = ShortcutInfoCompat.Builder(this, "search")
|
|
||||||
.setShortLabel(getString(R.string.search_shortcut_short_label))
|
|
||||||
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_search))
|
|
||||||
.setIntent(
|
|
||||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
|
||||||
.putExtra("page", "/search")
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val videos = ShortcutInfoCompat.Builder(this, "videos")
|
|
||||||
.setShortLabel(getString(R.string.videos_shortcut_short_label))
|
|
||||||
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_movie))
|
|
||||||
.setIntent(
|
|
||||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
|
||||||
.putExtra("page", "/collection")
|
|
||||||
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
ShortcutManagerCompat.setDynamicShortcuts(this, listOf(videos, search))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
@ -109,6 +78,25 @@ class MainActivity : FlutterActivity() {
|
||||||
intentStreamHandler.notifyNewIntent(extractIntentData(intent))
|
intentStreamHandler.notifyNewIntent(extractIntentData(intent))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
|
if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) {
|
||||||
|
val treeUri = data?.data
|
||||||
|
if (resultCode != RESULT_OK || treeUri == null) {
|
||||||
|
PermissionManager.onPermissionResult(requestCode, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// save access permissions across reboots
|
||||||
|
val takeFlags = (data.flags
|
||||||
|
and (Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
|
||||||
|
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
|
||||||
|
|
||||||
|
// resume pending action
|
||||||
|
PermissionManager.onPermissionResult(requestCode, treeUri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
|
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
Intent.ACTION_MAIN -> {
|
Intent.ACTION_MAIN -> {
|
||||||
|
@ -138,22 +126,48 @@ class MainActivity : FlutterActivity() {
|
||||||
return HashMap()
|
return HashMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
private fun pick(call: MethodCall) {
|
||||||
if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) {
|
val pickedUri = call.argument<String>("uri")
|
||||||
val treeUri = data?.data
|
if (pickedUri != null) {
|
||||||
if (resultCode != RESULT_OK || treeUri == null) {
|
val intent = Intent().apply {
|
||||||
PermissionManager.onPermissionResult(requestCode, null)
|
data = Uri.parse(pickedUri)
|
||||||
return
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
}
|
}
|
||||||
|
setResult(RESULT_OK, intent)
|
||||||
// save access permissions across reboots
|
} else {
|
||||||
val takeFlags = (data.flags
|
setResult(RESULT_CANCELED)
|
||||||
and (Intent.FLAG_GRANT_READ_URI_PERMISSION
|
|
||||||
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
|
|
||||||
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
|
|
||||||
|
|
||||||
// resume pending action
|
|
||||||
PermissionManager.onPermissionResult(requestCode, treeUri)
|
|
||||||
}
|
}
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.N_MR1)
|
||||||
|
private fun setupShortcuts() {
|
||||||
|
// do not use 'route' as extra key, as the Flutter framework acts on it
|
||||||
|
|
||||||
|
val search = ShortcutInfoCompat.Builder(this, "search")
|
||||||
|
.setShortLabel(getString(R.string.search_shortcut_short_label))
|
||||||
|
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_search))
|
||||||
|
.setIntent(
|
||||||
|
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||||
|
.putExtra("page", "/search")
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val videos = ShortcutInfoCompat.Builder(this, "videos")
|
||||||
|
.setShortLabel(getString(R.string.videos_shortcut_short_label))
|
||||||
|
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_movie))
|
||||||
|
.setIntent(
|
||||||
|
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||||
|
.putExtra("page", "/collection")
|
||||||
|
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
ShortcutManagerCompat.setDynamicShortcuts(this, listOf(videos, search))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val LOG_TAG = LogUtils.createTag(MainActivity::class.java)
|
||||||
|
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -111,11 +111,6 @@ class RegionFetcher internal constructor(
|
||||||
.submit()
|
.submit()
|
||||||
try {
|
try {
|
||||||
val bitmap = target.get()
|
val bitmap = target.get()
|
||||||
// if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
|
|
||||||
// bitmap = BitmapUtils.applyExifOrientation(context, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
|
|
||||||
// }
|
|
||||||
bitmap ?: throw Exception("failed to get image from uri=$sourceUri page=$pageId")
|
|
||||||
|
|
||||||
val tempFile = File.createTempFile("aves", null, context.cacheDir).apply {
|
val tempFile = File.createTempFile("aves", null, context.cacheDir).apply {
|
||||||
deleteOnExit()
|
deleteOnExit()
|
||||||
outputStream().use { outputStream ->
|
outputStream().use { outputStream ->
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
package deckers.thibault.aves.channel.streams
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.ContentObserver
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.util.Log
|
||||||
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import io.flutter.plugin.common.EventChannel.EventSink
|
||||||
|
|
||||||
|
class ContentChangeStreamHandler(private val context: Context) : EventChannel.StreamHandler {
|
||||||
|
private val contentObserver = object : ContentObserver(null) {
|
||||||
|
override fun onChange(selfChange: Boolean) {
|
||||||
|
this.onChange(selfChange, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
||||||
|
// warning: querying the content resolver right after a change
|
||||||
|
// sometimes yields obsolete results
|
||||||
|
success(uri?.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private lateinit var eventSink: EventSink
|
||||||
|
private lateinit var handler: Handler
|
||||||
|
|
||||||
|
init {
|
||||||
|
context.contentResolver.apply {
|
||||||
|
registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
|
||||||
|
registerContentObserver(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onListen(arguments: Any?, eventSink: EventSink) {
|
||||||
|
this.eventSink = eventSink
|
||||||
|
handler = Handler(Looper.getMainLooper())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(arguments: Any?) {}
|
||||||
|
|
||||||
|
fun dispose() {
|
||||||
|
context.contentResolver.unregisterContentObserver(contentObserver)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun success(uri: String?) {
|
||||||
|
handler.post {
|
||||||
|
try {
|
||||||
|
eventSink.success(uri)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(LOG_TAG, "failed to use event sink", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val LOG_TAG = LogUtils.createTag(ContentChangeStreamHandler::class.java)
|
||||||
|
const val CHANNEL = "deckers.thibault/aves/contentchange"
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,4 +18,8 @@ class IntentStreamHandler : EventChannel.StreamHandler {
|
||||||
fun notifyNewIntent(intentData: MutableMap<String, Any?>?) {
|
fun notifyNewIntent(intentData: MutableMap<String, Any?>?) {
|
||||||
eventSink?.success(intentData)
|
eventSink?.success(intentData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val CHANNEL = "deckers.thibault/aves/intent"
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -4,7 +4,9 @@ import 'dart:ui';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/model/source/media_store_source.dart';
|
import 'package:aves/model/source/media_store_source.dart';
|
||||||
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
|
import 'package:aves/utils/debouncer.dart';
|
||||||
import 'package:aves/widgets/common/behaviour/route_tracker.dart';
|
import 'package:aves/widgets/common/behaviour/route_tracker.dart';
|
||||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||||
import 'package:aves/widgets/home_page.dart';
|
import 'package:aves/widgets/home_page.dart';
|
||||||
|
@ -45,10 +47,14 @@ class AvesApp extends StatefulWidget {
|
||||||
|
|
||||||
class _AvesAppState extends State<AvesApp> {
|
class _AvesAppState extends State<AvesApp> {
|
||||||
Future<void> _appSetup;
|
Future<void> _appSetup;
|
||||||
|
final _mediaStoreSource = MediaStoreSource();
|
||||||
|
final Debouncer _contentChangeDebouncer = Debouncer(delay: Durations.contentChangeDebounceDelay);
|
||||||
|
final List<String> changedUris = [];
|
||||||
|
|
||||||
// observers are not registered when using the same list object with different items
|
// observers are not registered when using the same list object with different items
|
||||||
// the list itself needs to be reassigned
|
// the list itself needs to be reassigned
|
||||||
List<NavigatorObserver> _navigatorObservers = [];
|
List<NavigatorObserver> _navigatorObservers = [];
|
||||||
|
final EventChannel _contentChangeChannel = EventChannel('deckers.thibault/aves/contentchange');
|
||||||
final EventChannel _newIntentChannel = EventChannel('deckers.thibault/aves/intent');
|
final EventChannel _newIntentChannel = EventChannel('deckers.thibault/aves/intent');
|
||||||
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
|
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
|
||||||
|
|
||||||
|
@ -96,53 +102,18 @@ class _AvesAppState extends State<AvesApp> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_appSetup = _setup();
|
_appSetup = _setup();
|
||||||
|
_contentChangeChannel.receiveBroadcastStream().listen((event) => _onContentChange(event as String));
|
||||||
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map));
|
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _setup() async {
|
|
||||||
await Firebase.initializeApp().then((app) {
|
|
||||||
final crashlytics = FirebaseCrashlytics.instance;
|
|
||||||
FlutterError.onError = crashlytics.recordFlutterError;
|
|
||||||
crashlytics.setCustomKey('locales', window.locales.join(', '));
|
|
||||||
final now = DateTime.now();
|
|
||||||
crashlytics.setCustomKey('timezone', '${now.timeZoneName} (${now.timeZoneOffset})');
|
|
||||||
crashlytics.setCustomKey(
|
|
||||||
'build_mode',
|
|
||||||
kReleaseMode
|
|
||||||
? 'release'
|
|
||||||
: kProfileMode
|
|
||||||
? 'profile'
|
|
||||||
: 'debug');
|
|
||||||
});
|
|
||||||
await settings.init();
|
|
||||||
await settings.initFirebase();
|
|
||||||
_navigatorObservers = [
|
|
||||||
FirebaseAnalyticsObserver(analytics: FirebaseAnalytics()),
|
|
||||||
CrashlyticsRouteTracker(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onNewIntent(Map intentData) {
|
|
||||||
debugPrint('$runtimeType onNewIntent with intentData=$intentData');
|
|
||||||
|
|
||||||
// do not reset when relaunching the app
|
|
||||||
if (AvesApp.mode == AppMode.main && (intentData == null || intentData.isEmpty == true)) return;
|
|
||||||
|
|
||||||
FirebaseCrashlytics.instance.log('New intent');
|
|
||||||
_navigatorKey.currentState.pushReplacement(DirectMaterialPageRoute(
|
|
||||||
settings: RouteSettings(name: HomePage.routeName),
|
|
||||||
builder: (_) => getFirstPage(intentData: intentData),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// place the settings provider above `MaterialApp`
|
// place the settings provider above `MaterialApp`
|
||||||
// so it can be used during navigation transitions
|
// so it can be used during navigation transitions
|
||||||
return ChangeNotifierProvider<Settings>.value(
|
return ChangeNotifierProvider<Settings>.value(
|
||||||
value: settings,
|
value: settings,
|
||||||
child: Provider<CollectionSource>(
|
child: Provider<CollectionSource>.value(
|
||||||
create: (context) => MediaStoreSource(),
|
value: _mediaStoreSource,
|
||||||
child: OverlaySupport(
|
child: OverlaySupport(
|
||||||
child: FutureBuilder<void>(
|
child: FutureBuilder<void>(
|
||||||
future: _appSetup,
|
future: _appSetup,
|
||||||
|
@ -181,4 +152,48 @@ class _AvesAppState extends State<AvesApp> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _setup() async {
|
||||||
|
await Firebase.initializeApp().then((app) {
|
||||||
|
final crashlytics = FirebaseCrashlytics.instance;
|
||||||
|
FlutterError.onError = crashlytics.recordFlutterError;
|
||||||
|
crashlytics.setCustomKey('locales', window.locales.join(', '));
|
||||||
|
final now = DateTime.now();
|
||||||
|
crashlytics.setCustomKey('timezone', '${now.timeZoneName} (${now.timeZoneOffset})');
|
||||||
|
crashlytics.setCustomKey(
|
||||||
|
'build_mode',
|
||||||
|
kReleaseMode
|
||||||
|
? 'release'
|
||||||
|
: kProfileMode
|
||||||
|
? 'profile'
|
||||||
|
: 'debug');
|
||||||
|
});
|
||||||
|
await settings.init();
|
||||||
|
await settings.initFirebase();
|
||||||
|
_navigatorObservers = [
|
||||||
|
FirebaseAnalyticsObserver(analytics: FirebaseAnalytics()),
|
||||||
|
CrashlyticsRouteTracker(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onNewIntent(Map intentData) {
|
||||||
|
debugPrint('$runtimeType onNewIntent with intentData=$intentData');
|
||||||
|
|
||||||
|
// do not reset when relaunching the app
|
||||||
|
if (AvesApp.mode == AppMode.main && (intentData == null || intentData.isEmpty == true)) return;
|
||||||
|
|
||||||
|
FirebaseCrashlytics.instance.log('New intent');
|
||||||
|
_navigatorKey.currentState.pushReplacement(DirectMaterialPageRoute(
|
||||||
|
settings: RouteSettings(name: HomePage.routeName),
|
||||||
|
builder: (_) => getFirstPage(intentData: intentData),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onContentChange(String uri) {
|
||||||
|
changedUris.add(uri);
|
||||||
|
_contentChangeDebouncer(() {
|
||||||
|
_mediaStoreSource.refreshUris(List.of(changedUris));
|
||||||
|
changedUris.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -268,12 +268,13 @@ class AvesEntry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The additional comparison of width to height is a workaround for badly registered entries.
|
// Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata,
|
||||||
// e.g. a portrait FHD video should be registered as width=1920, height=1080, orientation=90,
|
// so it should be registered as width=1920, height=1080, orientation=90,
|
||||||
// but is incorrectly registered in the Media Store as width=1080, height=1920, orientation=0
|
// but is incorrectly registered as width=1080, height=1920, orientation=0.
|
||||||
// Double-checking the width/height during loading or cataloguing is the proper solution,
|
// Double-checking the width/height during loading or cataloguing is the proper solution, but it would take space and time.
|
||||||
// but it would take space and time, so a basic workaround will do.
|
// Comparing width and height can help with the portrait FHD video example,
|
||||||
bool get isPortrait => rotationDegrees % 180 == 90 && (catalogMetadata?.rotationDegrees == null || width > height);
|
// but it fails for a portrait screenshot rotated, which is landscape with width=1080, height=1920, orientation=90
|
||||||
|
bool get isRotated => rotationDegrees % 180 == 90;
|
||||||
|
|
||||||
static const ratioSeparator = '\u2236';
|
static const ratioSeparator = '\u2236';
|
||||||
static const resolutionSeparator = ' \u00D7 ';
|
static const resolutionSeparator = ' \u00D7 ';
|
||||||
|
@ -281,7 +282,7 @@ class AvesEntry {
|
||||||
String get resolutionText {
|
String get resolutionText {
|
||||||
final ws = width ?? '?';
|
final ws = width ?? '?';
|
||||||
final hs = height ?? '?';
|
final hs = height ?? '?';
|
||||||
return isPortrait ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs';
|
return isRotated ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs';
|
||||||
}
|
}
|
||||||
|
|
||||||
String get aspectRatioText {
|
String get aspectRatioText {
|
||||||
|
@ -289,7 +290,7 @@ class AvesEntry {
|
||||||
final gcd = width.gcd(height);
|
final gcd = width.gcd(height);
|
||||||
final w = width ~/ gcd;
|
final w = width ~/ gcd;
|
||||||
final h = height ~/ gcd;
|
final h = height ~/ gcd;
|
||||||
return isPortrait ? '$h$ratioSeparator$w' : '$w$ratioSeparator$h';
|
return isRotated ? '$h$ratioSeparator$w' : '$w$ratioSeparator$h';
|
||||||
} else {
|
} else {
|
||||||
return '?$ratioSeparator?';
|
return '?$ratioSeparator?';
|
||||||
}
|
}
|
||||||
|
@ -297,13 +298,13 @@ class AvesEntry {
|
||||||
|
|
||||||
double get displayAspectRatio {
|
double get displayAspectRatio {
|
||||||
if (width == 0 || height == 0) return 1;
|
if (width == 0 || height == 0) return 1;
|
||||||
return isPortrait ? height / width : width / height;
|
return isRotated ? height / width : width / height;
|
||||||
}
|
}
|
||||||
|
|
||||||
Size get displaySize {
|
Size get displaySize {
|
||||||
final w = width.toDouble();
|
final w = width.toDouble();
|
||||||
final h = height.toDouble();
|
final h = height.toDouble();
|
||||||
return isPortrait ? Size(h, w) : Size(w, h);
|
return isRotated ? Size(h, w) : Size(w, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null;
|
int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null;
|
||||||
|
@ -636,7 +637,10 @@ class AvesEntry {
|
||||||
// 1) date descending
|
// 1) date descending
|
||||||
// 2) name descending
|
// 2) name descending
|
||||||
static int compareByDate(AvesEntry a, AvesEntry b) {
|
static int compareByDate(AvesEntry a, AvesEntry b) {
|
||||||
final c = (b.bestDate ?? _epoch).compareTo(a.bestDate ?? _epoch);
|
var c = (b.bestDate ?? _epoch).compareTo(a.bestDate ?? _epoch);
|
||||||
return c != 0 ? c : -compareByName(a, b);
|
if (c != 0) return c;
|
||||||
|
c = (b.dateModifiedSecs ?? 0).compareTo(a.dateModifiedSecs ?? 0);
|
||||||
|
if (c != 0) return c;
|
||||||
|
return -compareByName(a, b);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -53,7 +53,7 @@ mixin AlbumMixin on SourceBase {
|
||||||
Map<String, AvesEntry> getAlbumEntries() {
|
Map<String, AvesEntry> getAlbumEntries() {
|
||||||
final entries = sortedEntriesForFilterList;
|
final entries = sortedEntriesForFilterList;
|
||||||
final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
|
final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
|
||||||
for (var album in sortedAlbums) {
|
for (final album in sortedAlbums) {
|
||||||
switch (androidFileUtils.getAlbumType(album)) {
|
switch (androidFileUtils.getAlbumType(album)) {
|
||||||
case AlbumType.regular:
|
case AlbumType.regular:
|
||||||
regularAlbums.add(album);
|
regularAlbums.add(album);
|
||||||
|
|
|
@ -19,6 +19,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
EntryGroupFactor groupFactor;
|
EntryGroupFactor groupFactor;
|
||||||
EntrySortFactor sortFactor;
|
EntrySortFactor sortFactor;
|
||||||
final AChangeNotifier filterChangeNotifier = AChangeNotifier();
|
final AChangeNotifier filterChangeNotifier = AChangeNotifier();
|
||||||
|
bool listenToSource;
|
||||||
|
|
||||||
List<AvesEntry> _filteredEntries;
|
List<AvesEntry> _filteredEntries;
|
||||||
List<StreamSubscription> _subscriptions = [];
|
List<StreamSubscription> _subscriptions = [];
|
||||||
|
@ -30,13 +31,16 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
Iterable<CollectionFilter> filters,
|
Iterable<CollectionFilter> filters,
|
||||||
@required EntryGroupFactor groupFactor,
|
@required EntryGroupFactor groupFactor,
|
||||||
@required EntrySortFactor sortFactor,
|
@required EntrySortFactor sortFactor,
|
||||||
|
this.listenToSource = true,
|
||||||
}) : filters = {if (filters != null) ...filters.where((f) => f != null)},
|
}) : filters = {if (filters != null) ...filters.where((f) => f != null)},
|
||||||
groupFactor = groupFactor ?? EntryGroupFactor.month,
|
groupFactor = groupFactor ?? EntryGroupFactor.month,
|
||||||
sortFactor = sortFactor ?? EntrySortFactor.date {
|
sortFactor = sortFactor ?? EntrySortFactor.date {
|
||||||
_subscriptions.add(source.eventBus.on<EntryAddedEvent>().listen((e) => _refresh()));
|
if (listenToSource) {
|
||||||
_subscriptions.add(source.eventBus.on<EntryRemovedEvent>().listen((e) => onEntryRemoved(e.entries)));
|
_subscriptions.add(source.eventBus.on<EntryAddedEvent>().listen((e) => _refresh()));
|
||||||
_subscriptions.add(source.eventBus.on<EntryMovedEvent>().listen((e) => _refresh()));
|
_subscriptions.add(source.eventBus.on<EntryRemovedEvent>().listen((e) => onEntryRemoved(e.entries)));
|
||||||
_subscriptions.add(source.eventBus.on<CatalogMetadataChangedEvent>().listen((e) => _refresh()));
|
_subscriptions.add(source.eventBus.on<EntryMovedEvent>().listen((e) => _refresh()));
|
||||||
|
_subscriptions.add(source.eventBus.on<CatalogMetadataChangedEvent>().listen((e) => _refresh()));
|
||||||
|
}
|
||||||
_refresh();
|
_refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,15 +53,6 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
CollectionLens derive(CollectionFilter filter) {
|
|
||||||
return CollectionLens(
|
|
||||||
source: source,
|
|
||||||
filters: filters,
|
|
||||||
groupFactor: groupFactor,
|
|
||||||
sortFactor: sortFactor,
|
|
||||||
)..addFilter(filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get isEmpty => _filteredEntries.isEmpty;
|
bool get isEmpty => _filteredEntries.isEmpty;
|
||||||
|
|
||||||
int get entryCount => _filteredEntries.length;
|
int get entryCount => _filteredEntries.length;
|
||||||
|
@ -82,7 +77,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Object heroTag(AvesEntry entry) => '$hashCode${entry.uri}';
|
Object heroTag(AvesEntry entry) => entry.uri;
|
||||||
|
|
||||||
void addFilter(CollectionFilter filter) {
|
void addFilter(CollectionFilter filter) {
|
||||||
if (filter == null || filters.contains(filter)) return;
|
if (filter == null || filters.contains(filter)) return;
|
||||||
|
|
|
@ -56,6 +56,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
}
|
}
|
||||||
|
|
||||||
void addAll(Iterable<AvesEntry> entries) {
|
void addAll(Iterable<AvesEntry> entries) {
|
||||||
|
if (entries.isEmpty) return;
|
||||||
if (_rawEntries.isNotEmpty) {
|
if (_rawEntries.isNotEmpty) {
|
||||||
final newContentIds = entries.map((entry) => entry.contentId).toList();
|
final newContentIds = entries.map((entry) => entry.contentId).toList();
|
||||||
_rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId));
|
_rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId));
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
@ -40,6 +41,7 @@ class MediaStoreSource extends CollectionSource {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
|
assert(_initialized);
|
||||||
debugPrint('$runtimeType refresh start');
|
debugPrint('$runtimeType refresh start');
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
stateNotifier.value = SourceState.loading;
|
stateNotifier.value = SourceState.loading;
|
||||||
|
@ -47,8 +49,8 @@ class MediaStoreSource extends CollectionSource {
|
||||||
|
|
||||||
final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries
|
final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries
|
||||||
final knownEntryMap = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs)));
|
final knownEntryMap = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs)));
|
||||||
final obsoleteEntries = (await ImageFileService.getObsoleteEntries(knownEntryMap.keys.toList())).toSet();
|
final obsoleteContentIds = (await ImageFileService.getObsoleteEntries(knownEntryMap.keys.toList())).toSet();
|
||||||
oldEntries.removeWhere((entry) => obsoleteEntries.contains(entry.contentId));
|
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
|
||||||
|
|
||||||
// show known entries
|
// show known entries
|
||||||
addAll(oldEntries);
|
addAll(oldEntries);
|
||||||
|
@ -57,9 +59,10 @@ class MediaStoreSource extends CollectionSource {
|
||||||
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
|
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
|
||||||
|
|
||||||
// clean up obsolete entries
|
// clean up obsolete entries
|
||||||
metadataDb.removeIds(obsoleteEntries, updateFavourites: true);
|
metadataDb.removeIds(obsoleteContentIds, updateFavourites: true);
|
||||||
|
|
||||||
// fetch new entries
|
// fetch new entries
|
||||||
|
// refresh after the first 10 entries, then after 100 more, then every 1000 entries
|
||||||
var refreshCount = 10;
|
var refreshCount = 10;
|
||||||
const refreshCountMax = 1000;
|
const refreshCountMax = 1000;
|
||||||
final allNewEntries = <AvesEntry>[], pendingNewEntries = <AvesEntry>[];
|
final allNewEntries = <AvesEntry>[], pendingNewEntries = <AvesEntry>[];
|
||||||
|
@ -102,6 +105,45 @@ class MediaStoreSource extends CollectionSource {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> refreshUris(List<String> changedUris) async {
|
||||||
|
assert(_initialized);
|
||||||
|
debugPrint('$runtimeType refreshUris uris=$changedUris');
|
||||||
|
|
||||||
|
final uriByContentId = Map.fromEntries(changedUris.map((uri) {
|
||||||
|
if (uri == null) return null;
|
||||||
|
final idString = Uri.parse(uri).pathSegments.last;
|
||||||
|
return MapEntry(int.tryParse(idString), uri);
|
||||||
|
}).where((kv) => kv != null));
|
||||||
|
|
||||||
|
// clean up obsolete entries
|
||||||
|
final obsoleteContentIds = (await ImageFileService.getObsoleteEntries(uriByContentId.keys.toList())).toSet();
|
||||||
|
uriByContentId.removeWhere((contentId, _) => obsoleteContentIds.contains(contentId));
|
||||||
|
metadataDb.removeIds(obsoleteContentIds, updateFavourites: true);
|
||||||
|
|
||||||
|
// add new entries
|
||||||
|
final newEntries = <AvesEntry>[];
|
||||||
|
for (final kv in uriByContentId.entries) {
|
||||||
|
final contentId = kv.key;
|
||||||
|
final uri = kv.value;
|
||||||
|
final sourceEntry = await ImageFileService.getEntry(uri, null);
|
||||||
|
final existingEntry = rawEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null);
|
||||||
|
if (existingEntry == null || sourceEntry.dateModifiedSecs > existingEntry.dateModifiedSecs) {
|
||||||
|
newEntries.add(sourceEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addAll(newEntries);
|
||||||
|
await metadataDb.saveEntries(newEntries);
|
||||||
|
updateAlbums();
|
||||||
|
|
||||||
|
stateNotifier.value = SourceState.cataloguing;
|
||||||
|
await catalogEntries();
|
||||||
|
|
||||||
|
stateNotifier.value = SourceState.locating;
|
||||||
|
await locateEntries();
|
||||||
|
|
||||||
|
stateNotifier.value = SourceState.ready;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> refreshMetadata(Set<AvesEntry> entries) {
|
Future<void> refreshMetadata(Set<AvesEntry> entries) {
|
||||||
final contentIds = entries.map((entry) => entry.contentId).toSet();
|
final contentIds = entries.map((entry) => entry.contentId).toSet();
|
||||||
|
|
|
@ -48,4 +48,5 @@ class Durations {
|
||||||
static const doubleBackTimerDelay = Duration(milliseconds: 1000);
|
static const doubleBackTimerDelay = Duration(milliseconds: 1000);
|
||||||
static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
|
static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
|
||||||
static const searchDebounceDelay = Duration(milliseconds: 250);
|
static const searchDebounceDelay = Duration(milliseconds: 250);
|
||||||
|
static const contentChangeDebounceDelay = Duration(milliseconds: 1000);
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,7 +55,13 @@ class InteractiveThumbnail extends StatelessWidget {
|
||||||
TransparentMaterialPageRoute(
|
TransparentMaterialPageRoute(
|
||||||
settings: RouteSettings(name: EntryViewerPage.routeName),
|
settings: RouteSettings(name: EntryViewerPage.routeName),
|
||||||
pageBuilder: (c, a, sa) => EntryViewerPage(
|
pageBuilder: (c, a, sa) => EntryViewerPage(
|
||||||
collection: collection,
|
collection: CollectionLens(
|
||||||
|
source: collection.source,
|
||||||
|
filters: collection.filters,
|
||||||
|
groupFactor: collection.groupFactor,
|
||||||
|
sortFactor: collection.sortFactor,
|
||||||
|
listenToSource: false,
|
||||||
|
),
|
||||||
initialEntry: entry,
|
initialEntry: entry,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -77,8 +77,8 @@ class ViewerDebugPage extends StatelessWidget {
|
||||||
'height': '${entry.height}',
|
'height': '${entry.height}',
|
||||||
'sourceRotationDegrees': '${entry.sourceRotationDegrees}',
|
'sourceRotationDegrees': '${entry.sourceRotationDegrees}',
|
||||||
'rotationDegrees': '${entry.rotationDegrees}',
|
'rotationDegrees': '${entry.rotationDegrees}',
|
||||||
|
'isRotated': '${entry.isRotated}',
|
||||||
'isFlipped': '${entry.isFlipped}',
|
'isFlipped': '${entry.isFlipped}',
|
||||||
'portrait': '${entry.isPortrait}',
|
|
||||||
'displayAspectRatio': '${entry.displayAspectRatio}',
|
'displayAspectRatio': '${entry.displayAspectRatio}',
|
||||||
'displaySize': '${entry.displaySize}',
|
'displaySize': '${entry.displaySize}',
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -16,11 +16,11 @@ import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/rename_entry_dialog.dart';
|
import 'package:aves/widgets/dialogs/rename_entry_dialog.dart';
|
||||||
import 'package:aves/widgets/filter_grids/album_pick.dart';
|
import 'package:aves/widgets/filter_grids/album_pick.dart';
|
||||||
import 'package:aves/widgets/viewer/debug_page.dart';
|
import 'package:aves/widgets/viewer/debug_page.dart';
|
||||||
|
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||||
import 'package:aves/widgets/viewer/printer.dart';
|
import 'package:aves/widgets/viewer/printer.dart';
|
||||||
import 'package:aves/widgets/viewer/source_viewer_page.dart';
|
import 'package:aves/widgets/viewer/source_viewer_page.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:pedantic/pedantic.dart';
|
import 'package:pedantic/pedantic.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
@ -139,15 +139,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
||||||
|
|
||||||
if (!await entry.delete()) {
|
if (!await entry.delete()) {
|
||||||
showFeedback(context, 'Failed');
|
showFeedback(context, 'Failed');
|
||||||
} else if (hasCollection) {
|
|
||||||
// update collection
|
|
||||||
collection.source.removeEntries([entry]);
|
|
||||||
if (collection.sortedEntries.isEmpty) {
|
|
||||||
Navigator.pop(context);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// leave viewer
|
if (hasCollection) {
|
||||||
unawaited(SystemNavigator.pop());
|
collection.source.removeEntries([entry]);
|
||||||
|
}
|
||||||
|
EntryDeletedNotification(entry).dispatch(context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -199,7 +195,6 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
||||||
} else {
|
} else {
|
||||||
showFeedback(context, 'Done!');
|
showFeedback(context, 'Done!');
|
||||||
}
|
}
|
||||||
source.refresh();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,17 +20,10 @@ class EntryViewerPage extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MediaQueryDataProvider(
|
return MediaQueryDataProvider(
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: collection != null
|
body: EntryViewerStack(
|
||||||
? AnimatedBuilder(
|
collection: collection,
|
||||||
animation: collection,
|
initialEntry: initialEntry,
|
||||||
builder: (context, child) => EntryViewerStack(
|
),
|
||||||
collection: collection,
|
|
||||||
initialEntry: initialEntry,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: EntryViewerStack(
|
|
||||||
initialEntry: initialEntry,
|
|
||||||
),
|
|
||||||
backgroundColor: Navigator.canPop(context) ? Colors.transparent : Colors.black,
|
backgroundColor: Navigator.canPop(context) ? Colors.transparent : Colors.black,
|
||||||
resizeToAvoidBottomInset: false,
|
resizeToAvoidBottomInset: false,
|
||||||
),
|
),
|
||||||
|
|
|
@ -164,6 +164,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
_goToCollection(notification.filter);
|
_goToCollection(notification.filter);
|
||||||
} else if (notification is ViewStateNotification) {
|
} else if (notification is ViewStateNotification) {
|
||||||
_updateViewState(notification.uri, notification.viewState);
|
_updateViewState(notification.uri, notification.viewState);
|
||||||
|
} else if (notification is EntryDeletedNotification) {
|
||||||
|
_onEntryDeleted(context, notification.entry);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
@ -324,7 +326,14 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
settings: RouteSettings(name: CollectionPage.routeName),
|
settings: RouteSettings(name: CollectionPage.routeName),
|
||||||
builder: (context) => CollectionPage(collection.derive(filter)),
|
builder: (context) => CollectionPage(
|
||||||
|
CollectionLens(
|
||||||
|
source: collection.source,
|
||||||
|
filters: collection.filters,
|
||||||
|
groupFactor: collection.groupFactor,
|
||||||
|
sortFactor: collection.sortFactor,
|
||||||
|
)..addFilter(filter),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
(route) => false,
|
(route) => false,
|
||||||
);
|
);
|
||||||
|
@ -356,6 +365,21 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
_updateEntry();
|
_updateEntry();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onEntryDeleted(BuildContext context, AvesEntry entry) {
|
||||||
|
if (hasCollection) {
|
||||||
|
final entries = collection.sortedEntries;
|
||||||
|
entries.remove(entry);
|
||||||
|
if (entries.isEmpty) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
} else {
|
||||||
|
_onCollectionChange();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// leave viewer
|
||||||
|
SystemNavigator.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _updateEntry() {
|
void _updateEntry() {
|
||||||
if (_currentHorizontalPage != null && entries.isNotEmpty && _currentHorizontalPage >= entries.length) {
|
if (_currentHorizontalPage != null && entries.isNotEmpty && _currentHorizontalPage >= entries.length) {
|
||||||
// as of Flutter v1.22.2, `PageView` does not call `onPageChanged` when the last page is deleted
|
// as of Flutter v1.22.2, `PageView` does not call `onPageChanged` when the last page is deleted
|
||||||
|
|
|
@ -11,6 +11,12 @@ class FilterNotification extends Notification {
|
||||||
const FilterNotification(this.filter);
|
const FilterNotification(this.filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class EntryDeletedNotification extends Notification {
|
||||||
|
final AvesEntry entry;
|
||||||
|
|
||||||
|
const EntryDeletedNotification(this.entry);
|
||||||
|
}
|
||||||
|
|
||||||
class OpenTempEntryNotification extends Notification {
|
class OpenTempEntryNotification extends Notification {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
|
|
||||||
|
|
|
@ -32,8 +32,9 @@ class EntryPrinter {
|
||||||
|
|
||||||
void _addPdfPage(pdf.Widget pdfChild) {
|
void _addPdfPage(pdf.Widget pdfChild) {
|
||||||
if (pdfChild == null) return;
|
if (pdfChild == null) return;
|
||||||
|
final displaySize = entry.displaySize;
|
||||||
pages.add(pdf.Page(
|
pages.add(pdf.Page(
|
||||||
orientation: entry.isPortrait ? pdf.PageOrientation.portrait : pdf.PageOrientation.landscape,
|
orientation: displaySize.height > displaySize.width ? pdf.PageOrientation.portrait : pdf.PageOrientation.landscape,
|
||||||
build: (context) => pdf.FullPage(
|
build: (context) => pdf.FullPage(
|
||||||
ignoreMargins: true,
|
ignoreMargins: true,
|
||||||
child: pdf.Center(
|
child: pdf.Center(
|
||||||
|
|
Loading…
Reference in a new issue