#51 settings: import/export
This commit is contained in:
parent
3a2e0349e2
commit
e2166bd15a
18 changed files with 454 additions and 102 deletions
|
@ -15,11 +15,11 @@ import deckers.thibault.aves.channel.calls.*
|
||||||
import deckers.thibault.aves.channel.streams.*
|
import deckers.thibault.aves.channel.streams.*
|
||||||
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
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.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler
|
private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler
|
||||||
|
@ -92,10 +92,10 @@ class MainActivity : FlutterActivity() {
|
||||||
|
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
when (requestCode) {
|
when (requestCode) {
|
||||||
VOLUME_ACCESS_REQUEST -> {
|
DOCUMENT_TREE_ACCESS_REQUEST -> {
|
||||||
val treeUri = data?.data
|
val treeUri = data?.data
|
||||||
if (resultCode != RESULT_OK || treeUri == null) {
|
if (resultCode != RESULT_OK || treeUri == null) {
|
||||||
PermissionManager.onPermissionResult(requestCode, null)
|
onPermissionResult(requestCode, null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -106,7 +106,7 @@ class MainActivity : FlutterActivity() {
|
||||||
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
|
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
|
||||||
|
|
||||||
// resume pending action
|
// resume pending action
|
||||||
PermissionManager.onPermissionResult(requestCode, treeUri)
|
onPermissionResult(requestCode, treeUri)
|
||||||
}
|
}
|
||||||
DELETE_PERMISSION_REQUEST -> {
|
DELETE_PERMISSION_REQUEST -> {
|
||||||
// delete permission may be requested on Android 10+ only
|
// delete permission may be requested on Android 10+ only
|
||||||
|
@ -114,6 +114,9 @@ class MainActivity : FlutterActivity() {
|
||||||
MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK)
|
MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
CREATE_FILE_REQUEST, OPEN_FILE_REQUEST -> {
|
||||||
|
onPermissionResult(requestCode, data?.data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -189,7 +192,26 @@ class MainActivity : FlutterActivity() {
|
||||||
companion object {
|
companion object {
|
||||||
private val LOG_TAG = LogUtils.createTag<MainActivity>()
|
private val LOG_TAG = LogUtils.createTag<MainActivity>()
|
||||||
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
|
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
|
||||||
const val VOLUME_ACCESS_REQUEST = 1
|
const val DOCUMENT_TREE_ACCESS_REQUEST = 1
|
||||||
const val DELETE_PERMISSION_REQUEST = 2
|
const val DELETE_PERMISSION_REQUEST = 2
|
||||||
|
const val CREATE_FILE_REQUEST = 3
|
||||||
|
const val OPEN_FILE_REQUEST = 4
|
||||||
|
|
||||||
|
// permission request code to pending runnable
|
||||||
|
val pendingResultHandlers = ConcurrentHashMap<Int, PendingResultHandler>()
|
||||||
|
|
||||||
|
fun onPermissionResult(requestCode: Int, uri: Uri?) {
|
||||||
|
Log.d(LOG_TAG, "onPermissionResult with requestCode=$requestCode, uri=$uri")
|
||||||
|
val handler = pendingResultHandlers.remove(requestCode) ?: return
|
||||||
|
if (uri != null) {
|
||||||
|
handler.onGranted(uri)
|
||||||
|
} else {
|
||||||
|
handler.onDenied()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// onGranted: user selected a directory/file (with no guarantee that it matches the requested `path`)
|
||||||
|
// onDenied: user cancelled
|
||||||
|
data class PendingResultHandler(val path: String?, val onGranted: (uri: Uri) -> Unit, val onDenied: () -> Unit)
|
||||||
|
|
|
@ -187,12 +187,12 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val LOG_TAG = LogUtils.createTag<ImageByteStreamHandler>()
|
private val LOG_TAG = LogUtils.createTag<ImageByteStreamHandler>()
|
||||||
const val CHANNEL = "deckers.thibault/aves/imagebytestream"
|
const val CHANNEL = "deckers.thibault/aves/image_byte_stream"
|
||||||
|
|
||||||
const val BUFFER_SIZE = 2 shl 17 // 256kB
|
private const val BUFFER_SIZE = 2 shl 17 // 256kB
|
||||||
|
|
||||||
// request a fresh image with the highest quality format
|
// request a fresh image with the highest quality format
|
||||||
val glideOptions = RequestOptions()
|
private val glideOptions = RequestOptions()
|
||||||
.format(DecodeFormat.PREFER_ARGB_8888)
|
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
.skipMemoryCache(true)
|
.skipMemoryCache(true)
|
||||||
|
|
|
@ -177,6 +177,6 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val LOG_TAG = LogUtils.createTag<ImageOpStreamHandler>()
|
private val LOG_TAG = LogUtils.createTag<ImageOpStreamHandler>()
|
||||||
const val CHANNEL = "deckers.thibault/aves/imageopstream"
|
const val CHANNEL = "deckers.thibault/aves/image_op_stream"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,25 +1,35 @@
|
||||||
package deckers.thibault.aves.channel.streams
|
package deckers.thibault.aves.channel.streams
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import deckers.thibault.aves.MainActivity
|
||||||
|
import deckers.thibault.aves.PendingResultHandler
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.PermissionManager.requestVolumeAccess
|
import deckers.thibault.aves.utils.PermissionManager
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
import io.flutter.plugin.common.EventChannel.EventSink
|
import io.flutter.plugin.common.EventChannel.EventSink
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
// starting activity to give access with the native dialog
|
// starting activity to give access with the native dialog
|
||||||
// breaks the regular `MethodChannel` so we use a stream channel instead
|
// breaks the regular `MethodChannel` so we use a stream channel instead
|
||||||
class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?) : EventChannel.StreamHandler {
|
class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?) : EventChannel.StreamHandler {
|
||||||
private lateinit var eventSink: EventSink
|
private lateinit var eventSink: EventSink
|
||||||
private lateinit var handler: Handler
|
private lateinit var handler: Handler
|
||||||
private var path: String? = null
|
|
||||||
|
private var op: String? = null
|
||||||
|
private lateinit var args: Map<*, *>
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (arguments is Map<*, *>) {
|
if (arguments is Map<*, *>) {
|
||||||
path = arguments["path"] as String?
|
op = arguments["op"] as String?
|
||||||
|
args = arguments
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,6 +37,16 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
||||||
this.eventSink = eventSink
|
this.eventSink = eventSink
|
||||||
handler = Handler(Looper.getMainLooper())
|
handler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
when (op) {
|
||||||
|
"requestVolumeAccess" -> requestVolumeAccess()
|
||||||
|
"createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() }
|
||||||
|
"openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() }
|
||||||
|
else -> endOfStream()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestVolumeAccess() {
|
||||||
|
val path = args["path"] as String?
|
||||||
if (path == null) {
|
if (path == null) {
|
||||||
error("requestVolumeAccess-args", "failed because of missing arguments", null)
|
error("requestVolumeAccess-args", "failed because of missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -37,12 +57,80 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
requestVolumeAccess(activity, path!!, { success(true) }, { success(false) })
|
PermissionManager.requestVolumeAccess(activity, path, {
|
||||||
|
success(true)
|
||||||
|
endOfStream()
|
||||||
|
}, {
|
||||||
|
success(false)
|
||||||
|
endOfStream()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createFile() {
|
||||||
|
val name = args["name"] as String?
|
||||||
|
val mimeType = args["mimeType"] as String?
|
||||||
|
val bytes = args["bytes"] as ByteArray?
|
||||||
|
if (name == null || mimeType == null || bytes == null) {
|
||||||
|
error("createFile-args", "failed because of missing arguments", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||||
|
addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
|
type = mimeType
|
||||||
|
putExtra(Intent.EXTRA_TITLE, name)
|
||||||
|
}
|
||||||
|
MainActivity.pendingResultHandlers[MainActivity.CREATE_FILE_REQUEST] = PendingResultHandler(null, { uri ->
|
||||||
|
try {
|
||||||
|
activity.contentResolver.openOutputStream(uri)?.use { output ->
|
||||||
|
output as FileOutputStream
|
||||||
|
// truncate is necessary when overwriting a longer file
|
||||||
|
output.channel.truncate(0)
|
||||||
|
output.write(bytes)
|
||||||
|
}
|
||||||
|
success(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error("createFile-write", "failed to write file at uri=$uri", e.message)
|
||||||
|
}
|
||||||
|
endOfStream()
|
||||||
|
}, {
|
||||||
|
success(null)
|
||||||
|
endOfStream()
|
||||||
|
})
|
||||||
|
activity.startActivityForResult(intent, MainActivity.CREATE_FILE_REQUEST)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun openFile() {
|
||||||
|
val mimeType = args["mimeType"] as String?
|
||||||
|
if (mimeType == null) {
|
||||||
|
error("openFile-args", "failed because of missing arguments", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||||
|
addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
|
type = mimeType
|
||||||
|
}
|
||||||
|
MainActivity.pendingResultHandlers[MainActivity.OPEN_FILE_REQUEST] = PendingResultHandler(null, { uri ->
|
||||||
|
activity.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
val buffer = ByteArray(BUFFER_SIZE)
|
||||||
|
var len: Int
|
||||||
|
while (input.read(buffer).also { len = it } != -1) {
|
||||||
|
success(buffer.copyOf(len))
|
||||||
|
}
|
||||||
|
endOfStream()
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
success(ByteArray(0))
|
||||||
|
endOfStream()
|
||||||
|
})
|
||||||
|
activity.startActivityForResult(intent, MainActivity.OPEN_FILE_REQUEST)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCancel(arguments: Any?) {}
|
override fun onCancel(arguments: Any?) {}
|
||||||
|
|
||||||
private fun success(result: Boolean) {
|
private fun success(result: Any?) {
|
||||||
handler.post {
|
handler.post {
|
||||||
try {
|
try {
|
||||||
eventSink.success(result)
|
eventSink.success(result)
|
||||||
|
@ -50,7 +138,6 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
||||||
Log.w(LOG_TAG, "failed to use event sink", e)
|
Log.w(LOG_TAG, "failed to use event sink", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
endOfStream()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("SameParameterValue")
|
@Suppress("SameParameterValue")
|
||||||
|
@ -62,6 +149,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
||||||
Log.w(LOG_TAG, "failed to use event sink", e)
|
Log.w(LOG_TAG, "failed to use event sink", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
endOfStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun endOfStream() {
|
private fun endOfStream() {
|
||||||
|
@ -76,6 +164,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val LOG_TAG = LogUtils.createTag<StorageAccessStreamHandler>()
|
private val LOG_TAG = LogUtils.createTag<StorageAccessStreamHandler>()
|
||||||
const val CHANNEL = "deckers.thibault/aves/storageaccessstream"
|
const val CHANNEL = "deckers.thibault/aves/storage_access_stream"
|
||||||
|
private const val BUFFER_SIZE = 2 shl 17 // 256kB
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -9,22 +9,19 @@ import android.os.Environment
|
||||||
import android.os.storage.StorageManager
|
import android.os.storage.StorageManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import deckers.thibault.aves.MainActivity.Companion.VOLUME_ACCESS_REQUEST
|
import deckers.thibault.aves.MainActivity
|
||||||
|
import deckers.thibault.aves.PendingResultHandler
|
||||||
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import kotlin.collections.ArrayList
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
object PermissionManager {
|
object PermissionManager {
|
||||||
private val LOG_TAG = LogUtils.createTag<PermissionManager>()
|
private val LOG_TAG = LogUtils.createTag<PermissionManager>()
|
||||||
|
|
||||||
// permission request code to pending runnable
|
|
||||||
private val pendingPermissionMap = ConcurrentHashMap<Int, PendingPermissionHandler>()
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||||
fun requestVolumeAccess(activity: Activity, path: String, onGranted: () -> Unit, onDenied: () -> Unit) {
|
fun requestVolumeAccess(activity: Activity, path: String, onGranted: (uri: Uri) -> Unit, onDenied: () -> Unit) {
|
||||||
Log.i(LOG_TAG, "request user to select and grant access permission to volume=$path")
|
Log.i(LOG_TAG, "request user to select and grant access permission to path=$path")
|
||||||
|
|
||||||
var intent: Intent? = null
|
var intent: Intent? = null
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
@ -38,20 +35,14 @@ object PermissionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent.resolveActivity(activity.packageManager) != null) {
|
if (intent.resolveActivity(activity.packageManager) != null) {
|
||||||
pendingPermissionMap[VOLUME_ACCESS_REQUEST] = PendingPermissionHandler(path, onGranted, onDenied)
|
MainActivity.pendingResultHandlers[MainActivity.DOCUMENT_TREE_ACCESS_REQUEST] = PendingResultHandler(path, onGranted, onDenied)
|
||||||
activity.startActivityForResult(intent, VOLUME_ACCESS_REQUEST, null)
|
activity.startActivityForResult(intent, MainActivity.DOCUMENT_TREE_ACCESS_REQUEST)
|
||||||
} else {
|
} else {
|
||||||
Log.e(LOG_TAG, "failed to resolve activity for intent=$intent")
|
Log.e(LOG_TAG, "failed to resolve activity for intent=$intent")
|
||||||
onDenied()
|
onDenied()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onPermissionResult(requestCode: Int, treeUri: Uri?) {
|
|
||||||
Log.d(LOG_TAG, "onPermissionResult with requestCode=$requestCode, treeUri=$treeUri")
|
|
||||||
val handler = pendingPermissionMap.remove(requestCode) ?: return
|
|
||||||
(if (treeUri != null) handler.onGranted else handler.onDenied)()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getGrantedDirForPath(context: Context, anyPath: String): String? {
|
fun getGrantedDirForPath(context: Context, anyPath: String): String? {
|
||||||
return getAccessibleDirs(context).firstOrNull { anyPath.startsWith(it) }
|
return getAccessibleDirs(context).firstOrNull { anyPath.startsWith(it) }
|
||||||
}
|
}
|
||||||
|
@ -167,8 +158,4 @@ object PermissionManager {
|
||||||
}
|
}
|
||||||
return accessibleDirs
|
return accessibleDirs
|
||||||
}
|
}
|
||||||
|
|
||||||
// onGranted: user gave access to a directory, with no guarantee that it matches the specified `path`
|
|
||||||
// onDenied: user cancelled
|
|
||||||
internal data class PendingPermissionHandler(val path: String, val onGranted: () -> Unit, val onDenied: () -> Unit)
|
|
||||||
}
|
}
|
|
@ -538,6 +538,11 @@
|
||||||
"settingsSystemDefault": "System",
|
"settingsSystemDefault": "System",
|
||||||
"@settingsSystemDefault": {},
|
"@settingsSystemDefault": {},
|
||||||
|
|
||||||
|
"settingsActionExport": "Export",
|
||||||
|
"@settingsActionExport": {},
|
||||||
|
"settingsActionImport": "Import",
|
||||||
|
"@settingsActionImport": {},
|
||||||
|
|
||||||
"settingsSectionNavigation": "Navigation",
|
"settingsSectionNavigation": "Navigation",
|
||||||
"@settingsSectionNavigation": {},
|
"@settingsSectionNavigation": {},
|
||||||
"settingsHome": "Home",
|
"settingsHome": "Home",
|
||||||
|
|
|
@ -250,6 +250,9 @@
|
||||||
"settingsPageTitle": "설정",
|
"settingsPageTitle": "설정",
|
||||||
"settingsSystemDefault": "시스템",
|
"settingsSystemDefault": "시스템",
|
||||||
|
|
||||||
|
"settingsActionExport": "내보내기",
|
||||||
|
"settingsActionImport": "가져오기",
|
||||||
|
|
||||||
"settingsSectionNavigation": "탐색",
|
"settingsSectionNavigation": "탐색",
|
||||||
"settingsHome": "홈",
|
"settingsHome": "홈",
|
||||||
"settingsKeepScreenOnTile": "화면 자동 꺼짐 방지",
|
"settingsKeepScreenOnTile": "화면 자동 꺼짐 방지",
|
||||||
|
|
|
@ -113,7 +113,7 @@ extension ExtraEntryAction on EntryAction {
|
||||||
case EntryAction.delete:
|
case EntryAction.delete:
|
||||||
return AIcons.delete;
|
return AIcons.delete;
|
||||||
case EntryAction.export:
|
case EntryAction.export:
|
||||||
return AIcons.export;
|
return AIcons.saveAs;
|
||||||
case EntryAction.info:
|
case EntryAction.info:
|
||||||
return AIcons.info;
|
return AIcons.info;
|
||||||
case EntryAction.rename:
|
case EntryAction.rename:
|
||||||
|
|
4
lib/model/actions/settings_actions.dart
Normal file
4
lib/model/actions/settings_actions.dart
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
enum SettingsAction {
|
||||||
|
export,
|
||||||
|
import,
|
||||||
|
}
|
|
@ -26,22 +26,24 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
||||||
|
|
||||||
static CollectionFilter? fromJson(String jsonString) {
|
static CollectionFilter? fromJson(String jsonString) {
|
||||||
final jsonMap = jsonDecode(jsonString);
|
final jsonMap = jsonDecode(jsonString);
|
||||||
final type = jsonMap['type'];
|
if (jsonMap is Map<String, dynamic>) {
|
||||||
switch (type) {
|
final type = jsonMap['type'];
|
||||||
case AlbumFilter.type:
|
switch (type) {
|
||||||
return AlbumFilter.fromMap(jsonMap);
|
case AlbumFilter.type:
|
||||||
case FavouriteFilter.type:
|
return AlbumFilter.fromMap(jsonMap);
|
||||||
return FavouriteFilter.instance;
|
case FavouriteFilter.type:
|
||||||
case LocationFilter.type:
|
return FavouriteFilter.instance;
|
||||||
return LocationFilter.fromMap(jsonMap);
|
case LocationFilter.type:
|
||||||
case TypeFilter.type:
|
return LocationFilter.fromMap(jsonMap);
|
||||||
return TypeFilter.fromMap(jsonMap);
|
case TypeFilter.type:
|
||||||
case MimeFilter.type:
|
return TypeFilter.fromMap(jsonMap);
|
||||||
return MimeFilter.fromMap(jsonMap);
|
case MimeFilter.type:
|
||||||
case QueryFilter.type:
|
return MimeFilter.fromMap(jsonMap);
|
||||||
return QueryFilter.fromMap(jsonMap);
|
case QueryFilter.type:
|
||||||
case TagFilter.type:
|
return QueryFilter.fromMap(jsonMap);
|
||||||
return TagFilter.fromMap(jsonMap);
|
case TagFilter.type:
|
||||||
|
return TagFilter.fromMap(jsonMap);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
debugPrint('failed to parse filter from json=$jsonString');
|
debugPrint('failed to parse filter from json=$jsonString');
|
||||||
return null;
|
return null;
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:aves/model/actions/entry_actions.dart';
|
import 'package:aves/model/actions/entry_actions.dart';
|
||||||
import 'package:aves/model/actions/video_actions.dart';
|
import 'package:aves/model/actions/video_actions.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
|
@ -25,6 +27,14 @@ class Settings extends ChangeNotifier {
|
||||||
_platformSettingsChangeChannel.receiveBroadcastStream().listen((event) => _onPlatformSettingsChange(event as Map?));
|
_platformSettingsChangeChannel.receiveBroadcastStream().listen((event) => _onPlatformSettingsChange(event as Map?));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const Set<String> internalKeys = {
|
||||||
|
hasAcceptedTermsKey,
|
||||||
|
catalogTimeZoneKey,
|
||||||
|
videoShowRawTimedTextKey,
|
||||||
|
searchHistoryKey,
|
||||||
|
lastVersionCheckDateKey,
|
||||||
|
};
|
||||||
|
|
||||||
// app
|
// app
|
||||||
static const hasAcceptedTermsKey = 'has_accepted_terms';
|
static const hasAcceptedTermsKey = 'has_accepted_terms';
|
||||||
static const isCrashlyticsEnabledKey = 'is_crashlytics_enabled';
|
static const isCrashlyticsEnabledKey = 'is_crashlytics_enabled';
|
||||||
|
@ -109,8 +119,12 @@ class Settings extends ChangeNotifier {
|
||||||
await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(isCrashlyticsEnabled);
|
await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(isCrashlyticsEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> reset() {
|
Future<void> reset({required bool includeInternalKeys}) async {
|
||||||
return _prefs!.clear();
|
if (includeInternalKeys) {
|
||||||
|
await _prefs!.clear();
|
||||||
|
} else {
|
||||||
|
await Future.forEach(_prefs!.getKeys().whereNot(internalKeys.contains), _prefs!.remove);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// app
|
// app
|
||||||
|
@ -398,4 +412,98 @@ class Settings extends ChangeNotifier {
|
||||||
bool _isRotationLocked = false;
|
bool _isRotationLocked = false;
|
||||||
|
|
||||||
bool get isRotationLocked => _isRotationLocked;
|
bool get isRotationLocked => _isRotationLocked;
|
||||||
|
|
||||||
|
// import/export
|
||||||
|
|
||||||
|
String toJson() => jsonEncode(Map.fromEntries(
|
||||||
|
_prefs!.getKeys().whereNot(internalKeys.contains).map((k) => MapEntry(k, _prefs!.get(k))),
|
||||||
|
));
|
||||||
|
|
||||||
|
Future<void> fromJson(String jsonString) async {
|
||||||
|
final jsonMap = jsonDecode(jsonString);
|
||||||
|
if (jsonMap is Map<String, dynamic>) {
|
||||||
|
// clear to restore defaults
|
||||||
|
await reset(includeInternalKeys: false);
|
||||||
|
|
||||||
|
// apply user modifications
|
||||||
|
jsonMap.forEach((key, value) {
|
||||||
|
if (key.startsWith(tileExtentPrefixKey)) {
|
||||||
|
if (value is double) {
|
||||||
|
_prefs!.setDouble(key, value);
|
||||||
|
} else {
|
||||||
|
debugPrint('failed to import key=$key, value=$value is not a double');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (key) {
|
||||||
|
case subtitleTextColorKey:
|
||||||
|
case subtitleBackgroundColorKey:
|
||||||
|
if (value is int) {
|
||||||
|
_prefs!.setInt(key, value);
|
||||||
|
} else {
|
||||||
|
debugPrint('failed to import key=$key, value=$value is not an int');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case subtitleFontSizeKey:
|
||||||
|
case infoMapZoomKey:
|
||||||
|
if (value is double) {
|
||||||
|
_prefs!.setDouble(key, value);
|
||||||
|
} else {
|
||||||
|
debugPrint('failed to import key=$key, value=$value is not a double');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case isCrashlyticsEnabledKey:
|
||||||
|
case mustBackTwiceToExitKey:
|
||||||
|
case showThumbnailLocationKey:
|
||||||
|
case showThumbnailRawKey:
|
||||||
|
case showThumbnailVideoDurationKey:
|
||||||
|
case showOverlayMinimapKey:
|
||||||
|
case showOverlayInfoKey:
|
||||||
|
case showOverlayShootingDetailsKey:
|
||||||
|
case enableVideoHardwareAccelerationKey:
|
||||||
|
case enableVideoAutoPlayKey:
|
||||||
|
case subtitleShowOutlineKey:
|
||||||
|
case saveSearchHistoryKey:
|
||||||
|
if (value is bool) {
|
||||||
|
_prefs!.setBool(key, value);
|
||||||
|
} else {
|
||||||
|
debugPrint('failed to import key=$key, value=$value is not a bool');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case localeKey:
|
||||||
|
case keepScreenOnKey:
|
||||||
|
case homePageKey:
|
||||||
|
case collectionGroupFactorKey:
|
||||||
|
case collectionSortFactorKey:
|
||||||
|
case albumGroupFactorKey:
|
||||||
|
case albumSortFactorKey:
|
||||||
|
case countrySortFactorKey:
|
||||||
|
case tagSortFactorKey:
|
||||||
|
case videoLoopModeKey:
|
||||||
|
case subtitleTextAlignmentKey:
|
||||||
|
case infoMapStyleKey:
|
||||||
|
case coordinateFormatKey:
|
||||||
|
case rasterBackgroundKey:
|
||||||
|
case vectorBackgroundKey:
|
||||||
|
if (value is String) {
|
||||||
|
_prefs!.setString(key, value);
|
||||||
|
} else {
|
||||||
|
debugPrint('failed to import key=$key, value=$value is not a string');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case pinnedFiltersKey:
|
||||||
|
case hiddenFiltersKey:
|
||||||
|
case viewerQuickActionsKey:
|
||||||
|
case videoQuickActionsKey:
|
||||||
|
if (value is List) {
|
||||||
|
_prefs!.setStringList(key, value.cast<String>());
|
||||||
|
} else {
|
||||||
|
debugPrint('failed to import key=$key, value=$value is not a list');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,6 +44,8 @@ class MimeTypes {
|
||||||
static const mp2t = 'video/mp2t'; // .m2ts
|
static const mp2t = 'video/mp2t'; // .m2ts
|
||||||
static const mp4 = 'video/mp4';
|
static const mp4 = 'video/mp4';
|
||||||
|
|
||||||
|
static const json = 'application/json';
|
||||||
|
|
||||||
// groups
|
// groups
|
||||||
|
|
||||||
// formats that support transparency
|
// formats that support transparency
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/services/image_op_events.dart';
|
import 'package:aves/services/image_op_events.dart';
|
||||||
|
import 'package:aves/services/output_buffer.dart';
|
||||||
import 'package:aves/services/service_policy.dart';
|
import 'package:aves/services/service_policy.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
@ -96,8 +96,8 @@ abstract class ImageFileService {
|
||||||
|
|
||||||
class PlatformImageFileService implements ImageFileService {
|
class PlatformImageFileService implements ImageFileService {
|
||||||
static const platform = MethodChannel('deckers.thibault/aves/image');
|
static const platform = MethodChannel('deckers.thibault/aves/image');
|
||||||
static final StreamsChannel _byteStreamChannel = StreamsChannel('deckers.thibault/aves/imagebytestream');
|
static final StreamsChannel _byteStreamChannel = StreamsChannel('deckers.thibault/aves/image_byte_stream');
|
||||||
static final StreamsChannel _opStreamChannel = StreamsChannel('deckers.thibault/aves/imageopstream');
|
static final StreamsChannel _opStreamChannel = StreamsChannel('deckers.thibault/aves/image_op_stream');
|
||||||
static const double thumbnailDefaultSize = 64.0;
|
static const double thumbnailDefaultSize = 64.0;
|
||||||
|
|
||||||
static Map<String, dynamic> _toPlatformEntryMap(AvesEntry entry) {
|
static Map<String, dynamic> _toPlatformEntryMap(AvesEntry entry) {
|
||||||
|
@ -157,7 +157,7 @@ class PlatformImageFileService implements ImageFileService {
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
final completer = Completer<Uint8List>.sync();
|
final completer = Completer<Uint8List>.sync();
|
||||||
final sink = _OutputBuffer();
|
final sink = OutputBuffer();
|
||||||
var bytesReceived = 0;
|
var bytesReceived = 0;
|
||||||
_byteStreamChannel.receiveBroadcastStream(<String, dynamic>{
|
_byteStreamChannel.receiveBroadcastStream(<String, dynamic>{
|
||||||
'uri': uri,
|
'uri': uri,
|
||||||
|
@ -405,40 +405,3 @@ class PlatformImageFileService implements ImageFileService {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cf flutter/foundation `consolidateHttpClientResponseBytes`
|
|
||||||
typedef BytesReceivedCallback = void Function(int cumulative, int? total);
|
|
||||||
|
|
||||||
// cf flutter/foundation `consolidateHttpClientResponseBytes`
|
|
||||||
class _OutputBuffer extends ByteConversionSinkBase {
|
|
||||||
List<List<int>>? _chunks = <List<int>>[];
|
|
||||||
int _contentLength = 0;
|
|
||||||
Uint8List? _bytes;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void add(List<int> chunk) {
|
|
||||||
assert(_bytes == null);
|
|
||||||
_chunks!.add(chunk);
|
|
||||||
_contentLength += chunk.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void close() {
|
|
||||||
if (_bytes != null) {
|
|
||||||
// We've already been closed; this is a no-op
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_bytes = Uint8List(_contentLength);
|
|
||||||
var offset = 0;
|
|
||||||
for (final chunk in _chunks!) {
|
|
||||||
_bytes!.setRange(offset, offset + chunk.length, chunk);
|
|
||||||
offset += chunk.length;
|
|
||||||
}
|
|
||||||
_chunks = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Uint8List get bytes {
|
|
||||||
assert(_bytes != null);
|
|
||||||
return _bytes!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
36
lib/services/output_buffer.dart
Normal file
36
lib/services/output_buffer.dart
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
// cf flutter/foundation `consolidateHttpClientResponseBytes`
|
||||||
|
class OutputBuffer extends ByteConversionSinkBase {
|
||||||
|
List<List<int>>? _chunks = <List<int>>[];
|
||||||
|
int _contentLength = 0;
|
||||||
|
Uint8List? _bytes;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void add(List<int> chunk) {
|
||||||
|
assert(_bytes == null);
|
||||||
|
_chunks!.add(chunk);
|
||||||
|
_contentLength += chunk.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close() {
|
||||||
|
if (_bytes != null) {
|
||||||
|
// We've already been closed; this is a no-op
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_bytes = Uint8List(_contentLength);
|
||||||
|
var offset = 0;
|
||||||
|
for (final chunk in _chunks!) {
|
||||||
|
_bytes!.setRange(offset, offset + chunk.length, chunk);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
_chunks = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List get bytes {
|
||||||
|
assert(_bytes != null);
|
||||||
|
return _bytes!;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:aves/services/output_buffer.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
@ -26,11 +28,16 @@ abstract class StorageService {
|
||||||
|
|
||||||
// returns media URI
|
// returns media URI
|
||||||
Future<Uri?> scanFile(String path, String mimeType);
|
Future<Uri?> scanFile(String path, String mimeType);
|
||||||
|
|
||||||
|
// return whether operation succeeded (`null` if user cancelled)
|
||||||
|
Future<bool?> createFile(String name, String mimeType, Uint8List bytes);
|
||||||
|
|
||||||
|
Future<Uint8List> openFile(String mimeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
class PlatformStorageService implements StorageService {
|
class PlatformStorageService implements StorageService {
|
||||||
static const platform = MethodChannel('deckers.thibault/aves/storage');
|
static const platform = MethodChannel('deckers.thibault/aves/storage');
|
||||||
static final StreamsChannel storageAccessChannel = StreamsChannel('deckers.thibault/aves/storageaccessstream');
|
static final StreamsChannel storageAccessChannel = StreamsChannel('deckers.thibault/aves/storage_access_stream');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Set<StorageVolume>> getStorageVolumes() async {
|
Future<Set<StorageVolume>> getStorageVolumes() async {
|
||||||
|
@ -113,9 +120,10 @@ class PlatformStorageService implements StorageService {
|
||||||
try {
|
try {
|
||||||
final completer = Completer<bool>();
|
final completer = Completer<bool>();
|
||||||
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
|
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
|
||||||
|
'op': 'requestVolumeAccess',
|
||||||
'path': volumePath,
|
'path': volumePath,
|
||||||
}).listen(
|
}).listen(
|
||||||
(data) => completer.complete(data as bool?),
|
(data) => completer.complete(data as bool),
|
||||||
onError: completer.completeError,
|
onError: completer.completeError,
|
||||||
onDone: () {
|
onDone: () {
|
||||||
if (!completer.isCompleted) completer.complete(false);
|
if (!completer.isCompleted) completer.complete(false);
|
||||||
|
@ -158,4 +166,55 @@ class PlatformStorageService implements StorageService {
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool?> createFile(String name, String mimeType, Uint8List bytes) async {
|
||||||
|
try {
|
||||||
|
final completer = Completer<bool>();
|
||||||
|
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
|
||||||
|
'op': 'createFile',
|
||||||
|
'name': name,
|
||||||
|
'mimeType': mimeType,
|
||||||
|
'bytes': bytes,
|
||||||
|
}).listen(
|
||||||
|
(data) => completer.complete(data as bool?),
|
||||||
|
onError: completer.completeError,
|
||||||
|
onDone: () {
|
||||||
|
if (!completer.isCompleted) completer.complete(false);
|
||||||
|
},
|
||||||
|
cancelOnError: true,
|
||||||
|
);
|
||||||
|
return completer.future;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
debugPrint('createFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List> openFile(String mimeType) async {
|
||||||
|
try {
|
||||||
|
final completer = Completer<Uint8List>.sync();
|
||||||
|
final sink = OutputBuffer();
|
||||||
|
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
|
||||||
|
'op': 'openFile',
|
||||||
|
'mimeType': mimeType,
|
||||||
|
}).listen(
|
||||||
|
(data) {
|
||||||
|
final chunk = data as Uint8List;
|
||||||
|
sink.add(chunk);
|
||||||
|
},
|
||||||
|
onError: completer.completeError,
|
||||||
|
onDone: () {
|
||||||
|
sink.close();
|
||||||
|
completer.complete(sink.bytes);
|
||||||
|
},
|
||||||
|
cancelOnError: true,
|
||||||
|
);
|
||||||
|
return completer.future;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
debugPrint('openFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||||
|
}
|
||||||
|
return Uint8List(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,13 +39,14 @@ class AIcons {
|
||||||
static const IconData createAlbum = Icons.add_circle_outline;
|
static const IconData createAlbum = Icons.add_circle_outline;
|
||||||
static const IconData debug = Icons.whatshot_outlined;
|
static const IconData debug = Icons.whatshot_outlined;
|
||||||
static const IconData delete = Icons.delete_outlined;
|
static const IconData delete = Icons.delete_outlined;
|
||||||
static const IconData export = Icons.save_alt_outlined;
|
static const IconData export = MdiIcons.fileExportOutline;
|
||||||
static const IconData flip = Icons.flip_outlined;
|
static const IconData flip = Icons.flip_outlined;
|
||||||
static const IconData favourite = Icons.favorite_border;
|
static const IconData favourite = Icons.favorite_border;
|
||||||
static const IconData favouriteActive = Icons.favorite;
|
static const IconData favouriteActive = Icons.favorite;
|
||||||
static const IconData goUp = Icons.arrow_upward_outlined;
|
static const IconData goUp = Icons.arrow_upward_outlined;
|
||||||
static const IconData group = Icons.group_work_outlined;
|
static const IconData group = Icons.group_work_outlined;
|
||||||
static const IconData hide = Icons.visibility_off_outlined;
|
static const IconData hide = Icons.visibility_off_outlined;
|
||||||
|
static const IconData import = MdiIcons.fileImportOutline;
|
||||||
static const IconData info = Icons.info_outlined;
|
static const IconData info = Icons.info_outlined;
|
||||||
static const IconData layers = Icons.layers_outlined;
|
static const IconData layers = Icons.layers_outlined;
|
||||||
static const IconData openOutside = Icons.open_in_new_outlined;
|
static const IconData openOutside = Icons.open_in_new_outlined;
|
||||||
|
@ -57,6 +58,7 @@ class AIcons {
|
||||||
static const IconData rotateLeft = Icons.rotate_left_outlined;
|
static const IconData rotateLeft = Icons.rotate_left_outlined;
|
||||||
static const IconData rotateRight = Icons.rotate_right_outlined;
|
static const IconData rotateRight = Icons.rotate_right_outlined;
|
||||||
static const IconData rotateScreen = Icons.screen_rotation_outlined;
|
static const IconData rotateScreen = Icons.screen_rotation_outlined;
|
||||||
|
static const IconData saveAs = Icons.save_alt_outlined;
|
||||||
static const IconData search = Icons.search_outlined;
|
static const IconData search = Icons.search_outlined;
|
||||||
static const IconData select = Icons.select_all_outlined;
|
static const IconData select = Icons.select_all_outlined;
|
||||||
static const IconData setCover = MdiIcons.imageEditOutline;
|
static const IconData setCover = MdiIcons.imageEditOutline;
|
||||||
|
|
|
@ -22,8 +22,15 @@ class DebugSettingsSection extends StatelessWidget {
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: () => settings.reset(),
|
onPressed: () => settings.reset(includeInternalKeys: true),
|
||||||
child: const Text('Reset'),
|
child: const Text('Reset (all store)'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => settings.reset(includeInternalKeys: false),
|
||||||
|
child: const Text('Reset (user preferences)'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
|
|
|
@ -1,4 +1,14 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:aves/model/actions/settings_actions.dart';
|
||||||
|
import 'package:aves/model/settings/settings.dart';
|
||||||
|
import 'package:aves/ref/mime_types.dart';
|
||||||
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
|
import 'package:aves/theme/icons.dart';
|
||||||
|
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||||
|
import 'package:aves/widgets/common/basic/menu_row.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||||
import 'package:aves/widgets/settings/language/language.dart';
|
import 'package:aves/widgets/settings/language/language.dart';
|
||||||
|
@ -8,7 +18,9 @@ import 'package:aves/widgets/settings/thumbnails.dart';
|
||||||
import 'package:aves/widgets/settings/video/video.dart';
|
import 'package:aves/widgets/settings/video/video.dart';
|
||||||
import 'package:aves/widgets/settings/viewer/viewer.dart';
|
import 'package:aves/widgets/settings/viewer/viewer.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
class SettingsPage extends StatefulWidget {
|
class SettingsPage extends StatefulWidget {
|
||||||
static const routeName = '/settings';
|
static const routeName = '/settings';
|
||||||
|
@ -19,7 +31,7 @@ class SettingsPage extends StatefulWidget {
|
||||||
_SettingsPageState createState() => _SettingsPageState();
|
_SettingsPageState createState() => _SettingsPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsPageState extends State<SettingsPage> {
|
class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
|
||||||
final ValueNotifier<String?> _expandedNotifier = ValueNotifier(null);
|
final ValueNotifier<String?> _expandedNotifier = ValueNotifier(null);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -29,6 +41,26 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(context.l10n.settingsPageTitle),
|
title: Text(context.l10n.settingsPageTitle),
|
||||||
|
actions: [
|
||||||
|
PopupMenuButton<SettingsAction>(
|
||||||
|
itemBuilder: (context) {
|
||||||
|
return [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: SettingsAction.export,
|
||||||
|
child: MenuRow(text: context.l10n.settingsActionExport, icon: AIcons.export),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: SettingsAction.import,
|
||||||
|
child: MenuRow(text: context.l10n.settingsActionImport, icon: AIcons.import),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
onSelected: (action) {
|
||||||
|
// wait for the popup menu to hide before proceeding with the action
|
||||||
|
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(action));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: Theme(
|
body: Theme(
|
||||||
data: theme.copyWith(
|
data: theme.copyWith(
|
||||||
|
@ -66,4 +98,35 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onActionSelected(SettingsAction action) async {
|
||||||
|
switch (action) {
|
||||||
|
case SettingsAction.export:
|
||||||
|
final success = await storageService.createFile(
|
||||||
|
'aves-settings-${DateFormat('yyyyMMdd_HHmmss').format(DateTime.now())}.json',
|
||||||
|
MimeTypes.json,
|
||||||
|
Uint8List.fromList(utf8.encode(settings.toJson())),
|
||||||
|
);
|
||||||
|
if (success != null) {
|
||||||
|
if (success) {
|
||||||
|
showFeedback(context, context.l10n.genericSuccessFeedback);
|
||||||
|
} else {
|
||||||
|
showFeedback(context, context.l10n.genericFailureFeedback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case SettingsAction.import:
|
||||||
|
final bytes = await storageService.openFile(MimeTypes.json);
|
||||||
|
if (bytes.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
await settings.fromJson(utf8.decode(bytes));
|
||||||
|
showFeedback(context, context.l10n.genericSuccessFeedback);
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('failed to import settings, error=$error');
|
||||||
|
showFeedback(context, context.l10n.genericFailureFeedback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue