#175 photo frame widget

This commit is contained in:
Thibault Deckers 2022-07-18 18:15:48 +02:00
parent 83fd5c91c4
commit 9d3a4777fc
79 changed files with 1563 additions and 530 deletions

View file

@ -1,3 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Android Studio Chipmunk (2021.2.1) recommends:
- removing "package" from AndroidManifest.xml
@ -165,6 +167,28 @@ This change eventually prevents building the app with Flutter v3.0.2.
</intent-filter>
</activity>
<activity
android:name=".HomeWidgetSettingsActivity"
android:exported="false"
android:theme="@style/NormalTheme">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<receiver
android:name=".HomeWidgetProvider"
android:exported="false"
android:label="@string/app_widget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/app_widget_info" />
</receiver>
<service
android:name=".AnalysisService"
android:description="@string/analysis_service_description"

View file

@ -25,7 +25,7 @@ import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.runBlocking
class AnalysisService : MethodChannel.MethodCallHandler, Service() {
private var backgroundFlutterEngine: FlutterEngine? = null
private var flutterEngine: FlutterEngine? = null
private var backgroundChannel: MethodChannel? = null
private var serviceLooper: Looper? = null
private var serviceHandler: ServiceHandler? = null
@ -37,11 +37,11 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
runBlocking {
FlutterUtils.initFlutterEngine(context, SHARED_PREFERENCES_KEY, CALLBACK_HANDLE_KEY) {
backgroundFlutterEngine = it
flutterEngine = it
}
}
val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger
val messenger = flutterEngine!!.dartExecutor
// channels for analysis
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))

View file

@ -0,0 +1,61 @@
package deckers.thibault.aves
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class HomeWidgetSettingsActivity : MainActivity() {
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// cancel if user does not complete widget setup
setResult(RESULT_CANCELED)
intent.extras?.let {
appWidgetId = it.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
intentDataMap = extractIntentData(intent)
}
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
val messenger = flutterEngine!!.dartExecutor
MethodChannel(messenger, CHANNEL).setMethodCallHandler { call: MethodCall, result: MethodChannel.Result ->
when (call.method) {
"configure" -> {
result.success(null)
saveWidget()
}
else -> result.notImplemented()
}
}
}
private fun saveWidget() {
val appWidgetManager = AppWidgetManager.getInstance(context)
val widgetInfo = appWidgetManager.getAppWidgetOptions(appWidgetId)
HomeWidgetProvider().onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, widgetInfo)
val intent = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
setResult(RESULT_OK, intent)
finish()
}
override fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
return hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_WIDGET_SETTINGS,
INTENT_DATA_KEY_WIDGET_ID to appWidgetId,
)
}
companion object {
private const val CHANNEL = "deckers.thibault/aves/widget_configure"
}
}

View file

@ -0,0 +1,201 @@
package deckers.thibault.aves
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.widget.RemoteViews
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.DeviceHandler
import deckers.thibault.aves.channel.calls.MediaFetchHandler
import deckers.thibault.aves.channel.calls.MediaStoreHandler
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler
import deckers.thibault.aves.utils.FlutterUtils
import deckers.thibault.aves.utils.LogUtils
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.*
import java.nio.ByteBuffer
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import kotlin.math.roundToInt
class HomeWidgetProvider : AppWidgetProvider() {
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
Log.d(LOG_TAG, "Widget onUpdate widgetIds=${appWidgetIds.contentToString()}")
for (widgetId in appWidgetIds) {
val widgetInfo = appWidgetManager.getAppWidgetOptions(widgetId)
defaultScope.launch {
val backgroundBytes = getBytes(context, widgetId, widgetInfo, drawEntryImage = false)
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, backgroundBytes)
val imageBytes = getBytes(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = false)
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, imageBytes)
}
}
}
override fun onAppWidgetOptionsChanged(context: Context, appWidgetManager: AppWidgetManager?, widgetId: Int, widgetInfo: Bundle?) {
Log.d(LOG_TAG, "Widget onAppWidgetOptionsChanged widgetId=$widgetId")
appWidgetManager ?: return
widgetInfo ?: return
if (imageByteFetchJob != null) {
imageByteFetchJob?.cancel()
}
imageByteFetchJob = defaultScope.launch {
delay(500)
val imageBytes = getBytes(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = true)
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, imageBytes)
}
}
private suspend fun getBytes(
context: Context,
widgetId: Int,
widgetInfo: Bundle,
drawEntryImage: Boolean,
reuseEntry: Boolean = false,
): ByteArray? {
val devicePixelRatio = context.resources.displayMetrics.density
val widthPx = (widgetInfo.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) * devicePixelRatio).roundToInt()
val heightPx = (widgetInfo.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) * devicePixelRatio).roundToInt()
initFlutterEngine(context)
val messenger = flutterEngine!!.dartExecutor
val channel = MethodChannel(messenger, WIDGET_DRAW_CHANNEL)
try {
val bytes = suspendCoroutine { cont ->
defaultScope.launch {
FlutterUtils.runOnUiThread {
channel.invokeMethod("drawWidget", hashMapOf(
"widgetId" to widgetId,
"widthPx" to widthPx,
"heightPx" to heightPx,
"devicePixelRatio" to devicePixelRatio,
"drawEntryImage" to drawEntryImage,
"reuseEntry" to reuseEntry,
), object : MethodChannel.Result {
override fun success(result: Any?) {
cont.resume(result)
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
cont.resumeWithException(Exception("$errorCode: $errorMessage\n$errorDetails"))
}
override fun notImplemented() {
cont.resumeWithException(Exception("not implemented"))
}
})
}
}
}
if (bytes is ByteArray) return bytes
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to draw widget for widgetId=$widgetId widthPx=$widthPx heightPx=$heightPx", e)
}
return null
}
private fun updateWidgetImage(
context: Context,
appWidgetManager: AppWidgetManager,
widgetId: Int,
widgetInfo: Bundle,
bytes: ByteArray?,
) {
bytes ?: return
val devicePixelRatio = context.resources.displayMetrics.density
val widthPx = (widgetInfo.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) * devicePixelRatio).roundToInt()
val heightPx = (widgetInfo.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) * devicePixelRatio).roundToInt()
try {
val bitmap = Bitmap.createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888)
bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(bytes))
// set a unique URI to prevent the intent (and its extras) from being shared by different widgets
val intent = Intent(MainActivity.INTENT_ACTION_WIDGET_OPEN, Uri.parse("widget://$widgetId"), context, MainActivity::class.java)
.putExtra(MainActivity.EXTRA_KEY_WIDGET_ID, widgetId)
val activity = PendingIntent.getActivity(
context,
0,
intent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
)
val views = RemoteViews(context.packageName, R.layout.app_widget).apply {
setImageViewBitmap(R.id.widget_img, bitmap)
setOnClickPendingIntent(R.id.widget_img, activity)
}
appWidgetManager.updateAppWidget(widgetId, views)
bitmap.recycle()
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to draw widget", e)
}
}
companion object {
private val LOG_TAG = LogUtils.createTag<HomeWidgetProvider>()
private const val WIDGET_DART_ENTRYPOINT = "widgetMain"
private const val WIDGET_DRAW_CHANNEL = "deckers.thibault/aves/widget_draw"
private var flutterEngine: FlutterEngine? = null
private var imageByteFetchJob: Job? = null
private suspend fun initFlutterEngine(context: Context) {
if (flutterEngine != null) return
FlutterUtils.runOnUiThread {
flutterEngine = FlutterEngine(context.applicationContext)
}
initChannels(context)
flutterEngine!!.apply {
if (!dartExecutor.isExecutingDart) {
val appBundlePathOverride = FlutterInjector.instance().flutterLoader().findAppBundlePath()
val entrypoint = DartExecutor.DartEntrypoint(appBundlePathOverride, WIDGET_DART_ENTRYPOINT)
FlutterUtils.runOnUiThread {
dartExecutor.executeDartEntrypoint(entrypoint)
}
}
}
}
private fun initChannels(context: Context) {
val messenger = flutterEngine!!.dartExecutor
// dart -> platform -> dart
// - need Context
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(context))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(context))
MethodChannel(messenger, MediaFetchHandler.CHANNEL).setMethodCallHandler(MediaFetchHandler(context))
// result streaming: dart -> platform ->->-> dart
// - need Context
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(context, args) }
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(context, args) }
}
}
}

View file

@ -2,6 +2,7 @@ package deckers.thibault.aves
import android.annotation.SuppressLint
import android.app.SearchManager
import android.appwidget.AppWidgetManager
import android.content.ClipData
import android.content.Intent
import android.net.Uri
@ -33,7 +34,7 @@ open class MainActivity : FlutterActivity() {
private lateinit var settingsChangeStreamHandler: SettingsChangeStreamHandler
private lateinit var intentStreamHandler: IntentStreamHandler
private lateinit var analysisStreamHandler: AnalysisStreamHandler
private lateinit var intentDataMap: MutableMap<String, Any?>
internal lateinit var intentDataMap: MutableMap<String, Any?>
private lateinit var analysisHandler: AnalysisHandler
override fun onCreate(savedInstanceState: Bundle?) {
@ -56,7 +57,7 @@ open class MainActivity : FlutterActivity() {
// )
super.onCreate(savedInstanceState)
val messenger = flutterEngine!!.dartExecutor.binaryMessenger
val messenger = flutterEngine!!.dartExecutor
// dart -> platform -> dart
// - need Context
@ -68,12 +69,14 @@ open class MainActivity : FlutterActivity() {
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this))
MethodChannel(messenger, HomeWidgetHandler.CHANNEL).setMethodCallHandler(HomeWidgetHandler(this))
MethodChannel(messenger, MediaFetchHandler.CHANNEL).setMethodCallHandler(MediaFetchHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
// - need ContextWrapper
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
MethodChannel(messenger, MediaFileHandler.CHANNEL).setMethodCallHandler(MediaFileHandler(this))
MethodChannel(messenger, MediaEditHandler.CHANNEL).setMethodCallHandler(MediaEditHandler(this))
MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this))
// - need Activity
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ActivityWindowHandler(this))
@ -211,7 +214,7 @@ open class MainActivity : FlutterActivity() {
}
open fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
when (intent?.action) {
when (val action = intent?.action) {
Intent.ACTION_MAIN -> {
intent.getStringExtra(EXTRA_KEY_PAGE)?.let { page ->
val filters = extractFiltersFromIntent(intent)
@ -253,10 +256,19 @@ open class MainActivity : FlutterActivity() {
INTENT_ACTION_PICK_COLLECTION_FILTERS -> {
val initialFilters = extractFiltersFromIntent(intent)
return hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK_COLLECTION_FILTERS,
INTENT_DATA_KEY_FILTERS to initialFilters
INTENT_DATA_KEY_ACTION to action,
INTENT_DATA_KEY_FILTERS to initialFilters,
)
}
INTENT_ACTION_WIDGET_OPEN -> {
val widgetId = intent.getIntExtra(EXTRA_KEY_WIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
if (widgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
return hashMapOf(
INTENT_DATA_KEY_ACTION to action,
INTENT_DATA_KEY_WIDGET_ID to widgetId,
)
}
}
Intent.ACTION_RUN -> {
// flutter run
}
@ -365,14 +377,6 @@ open class MainActivity : FlutterActivity() {
const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 6
const val PICK_COLLECTION_FILTERS_REQUEST = 7
const val INTENT_DATA_KEY_ACTION = "action"
const val INTENT_DATA_KEY_FILTERS = "filters"
const val INTENT_DATA_KEY_MIME_TYPE = "mimeType"
const val INTENT_DATA_KEY_ALLOW_MULTIPLE = "allowMultiple"
const val INTENT_DATA_KEY_PAGE = "page"
const val INTENT_DATA_KEY_URI = "uri"
const val INTENT_DATA_KEY_QUERY = "query"
const val INTENT_ACTION_PICK_ITEMS = "pick_items"
const val INTENT_ACTION_PICK_COLLECTION_FILTERS = "pick_collection_filters"
const val INTENT_ACTION_SCREEN_SAVER = "screen_saver"
@ -380,10 +384,22 @@ open class MainActivity : FlutterActivity() {
const val INTENT_ACTION_SEARCH = "search"
const val INTENT_ACTION_SET_WALLPAPER = "set_wallpaper"
const val INTENT_ACTION_VIEW = "view"
const val INTENT_ACTION_WIDGET_OPEN = "widget_open"
const val INTENT_ACTION_WIDGET_SETTINGS = "widget_settings"
const val INTENT_DATA_KEY_ACTION = "action"
const val INTENT_DATA_KEY_ALLOW_MULTIPLE = "allowMultiple"
const val INTENT_DATA_KEY_FILTERS = "filters"
const val INTENT_DATA_KEY_MIME_TYPE = "mimeType"
const val INTENT_DATA_KEY_PAGE = "page"
const val INTENT_DATA_KEY_QUERY = "query"
const val INTENT_DATA_KEY_URI = "uri"
const val INTENT_DATA_KEY_WIDGET_ID = "widgetId"
const val EXTRA_KEY_PAGE = "page"
const val EXTRA_KEY_FILTERS_ARRAY = "filters"
const val EXTRA_KEY_FILTERS_STRING = "filtersString"
const val EXTRA_KEY_WIDGET_ID = "widgetId"
// request code to pending runnable
val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>()

View file

@ -92,19 +92,18 @@ class ScreenSaverService : DreamService() {
}
}
private fun initChannels() {
val messenger = flutterEngine!!.dartExecutor.binaryMessenger
val messenger = flutterEngine!!.dartExecutor
// dart -> platform -> dart
// - need Context
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, MediaFetchHandler.CHANNEL).setMethodCallHandler(MediaFetchHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
// - need ContextWrapper
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
MethodChannel(messenger, MediaFileHandler.CHANNEL).setMethodCallHandler(MediaFileHandler(this))
// - need Service
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ServiceWindowHandler(this))

View file

@ -23,7 +23,7 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvider() {
class SearchSuggestionsProvider : ContentProvider() {
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun query(uri: Uri, projection: Array<String>?, selection: String?, selectionArgs: Array<String>?, sortOrder: String?): Cursor? {
@ -67,15 +67,23 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
}
private suspend fun getSuggestions(context: Context, query: String): List<FieldMap> {
if (backgroundFlutterEngine == null) {
if (flutterEngine == null) {
FlutterUtils.initFlutterEngine(context, SHARED_PREFERENCES_KEY, CALLBACK_HANDLE_KEY) {
backgroundFlutterEngine = it
flutterEngine = it
}
}
val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger
val messenger = flutterEngine!!.dartExecutor
val backgroundChannel = MethodChannel(messenger, BACKGROUND_CHANNEL)
backgroundChannel.setMethodCallHandler(this)
backgroundChannel.setMethodCallHandler { call: MethodCall, result: MethodChannel.Result ->
when (call.method) {
"initialized" -> {
Log.d(LOG_TAG, "background channel is ready")
result.success(null)
}
else -> result.notImplemented()
}
}
try {
return suspendCoroutine { cont ->
@ -108,16 +116,6 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
}
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initialized" -> {
Log.d(LOG_TAG, "background channel is ready")
result.success(null)
}
else -> result.notImplemented()
}
}
override fun onCreate(): Boolean = true
override fun getType(uri: Uri): String? = null
@ -137,6 +135,6 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
const val SHARED_PREFERENCES_KEY = "platform_search"
const val CALLBACK_HANDLE_KEY = "callback_handle"
private var backgroundFlutterEngine: FlutterEngine? = null
private var flutterEngine: FlutterEngine? = null
}
}

View file

@ -28,16 +28,16 @@ class WallpaperActivity : FlutterActivity() {
super.onCreate(savedInstanceState)
val messenger = flutterEngine!!.dartExecutor.binaryMessenger
val messenger = flutterEngine!!.dartExecutor
// dart -> platform -> dart
// - need Context
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, MediaFetchHandler.CHANNEL).setMethodCallHandler(MediaFetchHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
// - need ContextWrapper
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
MethodChannel(messenger, MediaFileHandler.CHANNEL).setMethodCallHandler(MediaFileHandler(this))
MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(this))
// - need Activity
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ActivityWindowHandler(this))

View file

@ -16,7 +16,10 @@ import deckers.thibault.aves.utils.ContextUtils.isMyServiceRunning
import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class AnalysisHandler(private val activity: Activity, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler, AnalysisServiceListener {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@ -56,9 +59,9 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp
if (!activity.isMyServiceRunning(AnalysisService::class.java)) {
val intent = Intent(activity, AnalysisService::class.java)
intent.putExtra(AnalysisService.KEY_COMMAND, AnalysisService.COMMAND_START)
intent.putExtra(AnalysisService.KEY_ENTRY_IDS, entryIds?.toIntArray())
intent.putExtra(AnalysisService.KEY_FORCE, force)
.putExtra(AnalysisService.KEY_COMMAND, AnalysisService.COMMAND_START)
.putExtra(AnalysisService.KEY_ENTRY_IDS, entryIds?.toIntArray())
.putExtra(AnalysisService.KEY_FORCE, force)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity.startForegroundService(intent)
} else {

View file

@ -0,0 +1,32 @@
package deckers.thibault.aves.channel.calls
import android.appwidget.AppWidgetManager
import android.content.Context
import deckers.thibault.aves.HomeWidgetProvider
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class HomeWidgetHandler(private val context: Context) : MethodChannel.MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"update" -> Coresult.safe(call, result, ::update)
else -> result.notImplemented()
}
}
private fun update(call: MethodCall, result: MethodChannel.Result) {
val widgetId = call.argument<Int>("widgetId")
if (widgetId == null) {
result.error("update-args", "missing arguments", null)
return
}
val appWidgetManager = AppWidgetManager.getInstance(context)
HomeWidgetProvider().onUpdate(context, appWidgetManager, intArrayOf(widgetId))
result.success(null)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/widget_update"
}
}

View file

@ -0,0 +1,77 @@
package deckers.thibault.aves.channel.calls
import android.content.ContextWrapper
import android.net.Uri
import android.util.Log
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class MediaEditHandler(private val contextWrapper: ContextWrapper) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"cancelFileOp" -> safe(call, result, ::cancelFileOp)
"captureFrame" -> ioScope.launch { safeSuspend(call, result, ::captureFrame) }
else -> result.notImplemented()
}
}
private fun cancelFileOp(call: MethodCall, result: MethodChannel.Result) {
val opId = call.argument<String>("opId")
if (opId == null) {
result.error("cancelFileOp-args", "missing arguments", null)
return
}
Log.i(LOG_TAG, "cancelling file op $opId")
cancelledOps.add(opId)
result.success(null)
}
private suspend fun captureFrame(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val desiredName = call.argument<String>("desiredName")
val exifFields = call.argument<FieldMap>("exif") ?: HashMap()
val bytes = call.argument<ByteArray>("bytes")
var destinationDir = call.argument<String>("destinationPath")
val nameConflictStrategy = NameConflictStrategy.get(call.argument<String>("nameConflictStrategy"))
if (uri == null || desiredName == null || bytes == null || destinationDir == null || nameConflictStrategy == null) {
result.error("captureFrame-args", "missing arguments", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("captureFrame-provider", "failed to find provider for uri=$uri", null)
return
}
destinationDir = ensureTrailingSeparator(destinationDir)
provider.captureFrame(contextWrapper, desiredName, exifFields, bytes, destinationDir, nameConflictStrategy, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame for uri=$uri", throwable.message)
})
}
companion object {
private val LOG_TAG = LogUtils.createTag<MediaEditHandler>()
const val CHANNEL = "deckers.thibault/aves/media_edit"
val cancelledOps = HashSet<String>()
}
}

View file

@ -1,9 +1,8 @@
package deckers.thibault.aves.channel.calls
import android.content.ContextWrapper
import android.content.Context
import android.graphics.Rect
import android.net.Uri
import android.util.Log
import com.bumptech.glide.Glide
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
@ -12,12 +11,9 @@ import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher
import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
@ -27,19 +23,17 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
class MediaFileHandler(private val contextWrapper: ContextWrapper) : MethodCallHandler {
class MediaFetchHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val density = contextWrapper.resources.displayMetrics.density
private val density = context.resources.displayMetrics.density
private val regionFetcher = RegionFetcher(contextWrapper)
private val regionFetcher = RegionFetcher(context)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getEntry" -> ioScope.launch { safe(call, result, ::getEntry) }
"getThumbnail" -> ioScope.launch { safeSuspend(call, result, ::getThumbnail) }
"getRegion" -> ioScope.launch { safeSuspend(call, result, ::getRegion) }
"cancelFileOp" -> safe(call, result, ::cancelFileOp)
"captureFrame" -> ioScope.launch { safeSuspend(call, result, ::captureFrame) }
"clearSizedThumbnailDiskCache" -> ioScope.launch { safe(call, result, ::clearSizedThumbnailDiskCache) }
else -> result.notImplemented()
}
@ -59,7 +53,7 @@ class MediaFileHandler(private val contextWrapper: ContextWrapper) : MethodCallH
return
}
provider.fetchSingle(contextWrapper, uri, mimeType, object : ImageOpCallback {
provider.fetchSingle(context, uri, mimeType, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("getEntry-failure", "failed to get entry for uri=$uri", throwable.message)
})
@ -83,7 +77,7 @@ class MediaFileHandler(private val contextWrapper: ContextWrapper) : MethodCallH
// convert DIP to physical pixels here, instead of using `devicePixelRatio` in Flutter
ThumbnailFetcher(
contextWrapper,
context,
uri,
mimeType,
dateModifiedSecs,
@ -116,14 +110,14 @@ class MediaFileHandler(private val contextWrapper: ContextWrapper) : MethodCallH
val regionRect = Rect(x, y, x + width, y + height)
when (mimeType) {
MimeTypes.SVG -> SvgRegionFetcher(contextWrapper).fetch(
MimeTypes.SVG -> SvgRegionFetcher(context).fetch(
uri = uri,
regionRect = regionRect,
imageWidth = imageWidth,
imageHeight = imageHeight,
result = result,
)
MimeTypes.TIFF -> TiffRegionFetcher(contextWrapper).fetch(
MimeTypes.TIFF -> TiffRegionFetcher(context).fetch(
uri = uri,
page = pageId ?: 0,
sampleSize = sampleSize,
@ -143,53 +137,12 @@ class MediaFileHandler(private val contextWrapper: ContextWrapper) : MethodCallH
}
}
private fun cancelFileOp(call: MethodCall, result: MethodChannel.Result) {
val opId = call.argument<String>("opId")
if (opId == null) {
result.error("cancelFileOp-args", "missing arguments", null)
return
}
Log.i(LOG_TAG, "cancelling file op $opId")
cancelledOps.add(opId)
result.success(null)
}
private suspend fun captureFrame(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val desiredName = call.argument<String>("desiredName")
val exifFields = call.argument<FieldMap>("exif") ?: HashMap()
val bytes = call.argument<ByteArray>("bytes")
var destinationDir = call.argument<String>("destinationPath")
val nameConflictStrategy = NameConflictStrategy.get(call.argument<String>("nameConflictStrategy"))
if (uri == null || desiredName == null || bytes == null || destinationDir == null || nameConflictStrategy == null) {
result.error("captureFrame-args", "missing arguments", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("captureFrame-provider", "failed to find provider for uri=$uri", null)
return
}
destinationDir = ensureTrailingSeparator(destinationDir)
provider.captureFrame(contextWrapper, desiredName, exifFields, bytes, destinationDir, nameConflictStrategy, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame for uri=$uri", throwable.message)
})
}
private fun clearSizedThumbnailDiskCache(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
Glide.get(contextWrapper).clearDiskCache()
Glide.get(context).clearDiskCache()
result.success(null)
}
companion object {
private val LOG_TAG = LogUtils.createTag<MediaFileHandler>()
const val CHANNEL = "deckers.thibault/aves/media_file"
val cancelledOps = HashSet<String>()
const val CHANNEL = "deckers.thibault/aves/media_fetch"
}
}

View file

@ -5,7 +5,7 @@ import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import deckers.thibault.aves.channel.calls.MediaFileHandler.Companion.cancelledOps
import deckers.thibault.aves.channel.calls.MediaEditHandler.Companion.cancelledOps
import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy

View file

@ -0,0 +1,5 @@
<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_img"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter" />

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_widget_label">Photo Frame</string>
<string name="wallpaper">Wallpaper</string>
<string name="search_shortcut_short_label">Search</string>
<string name="videos_shortcut_short_label">Videos</string>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:configure="deckers.thibault.aves.HomeWidgetSettingsActivity"
android:initialLayout="@layout/app_widget"
android:minWidth="40dp"
android:minHeight="40dp"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:updatePeriodMillis="3600000"
android:widgetCategory="home_screen"
android:widgetFeatures="reconfigurable" />

View file

@ -33,7 +33,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
final mimeType = key.mimeType;
final pageId = key.pageId;
try {
final bytes = await mediaFileService.getRegion(
final bytes = await mediaFetchService.getRegion(
uri,
mimeType,
key.rotationDegrees,
@ -58,11 +58,11 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
@override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, RegionProviderKey key, ImageErrorListener handleError) {
mediaFileService.resumeLoading(key);
mediaFetchService.resumeLoading(key);
super.resolveStreamForKey(configuration, stream, key, handleError);
}
void pause() => mediaFileService.cancelRegion(key);
void pause() => mediaFetchService.cancelRegion(key);
}
@immutable

View file

@ -35,7 +35,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
final mimeType = key.mimeType;
final pageId = key.pageId;
try {
final bytes = await mediaFileService.getThumbnail(
final bytes = await mediaFetchService.getThumbnail(
uri: uri,
mimeType: mimeType,
pageId: pageId,
@ -59,11 +59,11 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
@override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) {
mediaFileService.resumeLoading(key);
mediaFetchService.resumeLoading(key);
super.resolveStreamForKey(configuration, stream, key, handleError);
}
void pause() => mediaFileService.cancelThumbnail(key);
void pause() => mediaFetchService.cancelThumbnail(key);
}
@immutable

View file

@ -49,7 +49,7 @@ class UriImage extends ImageProvider<UriImage> with EquatableMixin {
assert(key == this);
try {
final bytes = await mediaFileService.getImage(
final bytes = await mediaFetchService.getImage(
uri,
mimeType,
rotationDegrees,

View file

@ -25,7 +25,8 @@
"showTooltip": "Anzeigen",
"hideTooltip": "Ausblenden",
"actionRemove": "Entfernen",
"resetButtonTooltip": "Zurücksetzen",
"resetTooltip": "Zurücksetzen",
"saveTooltip": "Speichern",
"doubleBackExitMessage": "Zum Verlassen erneut auf „Zurück“ tippen.",
"doNotAskAgain": "Nicht noch einmal fragen",
@ -303,7 +304,6 @@
"aboutBug": "Fehlerbericht",
"aboutBugSaveLogInstruction": "Anwendungsprotokolle in einer Datei speichern",
"aboutBugSaveLogButton": "Speichern",
"aboutBugCopyInfoInstruction": "Systeminformationen kopieren",
"aboutBugCopyInfoButton": "Kopieren",
"aboutBugReportInstruction": "Bericht auf GitHub mit den Protokollen und Systeminformationen",

View file

@ -53,7 +53,8 @@
"showTooltip": "Show",
"hideTooltip": "Hide",
"actionRemove": "Remove",
"resetButtonTooltip": "Reset",
"resetTooltip": "Reset",
"saveTooltip": "Save",
"doubleBackExitMessage": "Tap “back” again to exit.",
"doNotAskAgain": "Do not ask again",
@ -434,7 +435,6 @@
"aboutBug": "Bug Report",
"aboutBugSaveLogInstruction": "Save app logs to a file",
"aboutBugSaveLogButton": "Save",
"aboutBugCopyInfoInstruction": "Copy system information",
"aboutBugCopyInfoButton": "Copy",
"aboutBugReportInstruction": "Report on GitHub with the logs and system information",
@ -767,7 +767,10 @@
"settingsUnitSystemTile": "Units",
"settingsUnitSystemTitle": "Units",
"settingsScreenSaverTitle": "Screen Saver",
"settingsScreenSaverPageTitle": "Screen Saver",
"settingsWidgetPageTitle": "Photo Frame",
"settingsWidgetShowOutline": "Outline",
"statsPageTitle": "Stats",
"statsWithGps": "{count, plural, =1{1 item with location} other{{count} items with location}}",

View file

@ -25,7 +25,8 @@
"showTooltip": "Mostrar",
"hideTooltip": "Ocultar",
"actionRemove": "Remover",
"resetButtonTooltip": "Restablecer",
"resetTooltip": "Restablecer",
"saveTooltip": "Guardar",
"doubleBackExitMessage": "Presione «atrás» nuevamente para salir.",
"doNotAskAgain": "No preguntar nuevamente",
@ -303,7 +304,6 @@
"aboutBug": "Reporte de errores",
"aboutBugSaveLogInstruction": "Guardar registros de la aplicación a un archivo",
"aboutBugSaveLogButton": "Guardar",
"aboutBugCopyInfoInstruction": "Copiar información del sistema",
"aboutBugCopyInfoButton": "Copiar",
"aboutBugReportInstruction": "Reportar en GitHub con los registros y la información del sistema",

View file

@ -25,7 +25,8 @@
"showTooltip": "Afficher",
"hideTooltip": "Masquer",
"actionRemove": "Supprimer",
"resetButtonTooltip": "Réinitialiser",
"resetTooltip": "Réinitialiser",
"saveTooltip": "Sauvegarder",
"doubleBackExitMessage": "Pressez «\u00A0retour\u00A0» à nouveau pour quitter.",
"doNotAskAgain": "Ne pas demander de nouveau",
@ -303,7 +304,6 @@
"aboutBug": "Rapports derreur",
"aboutBugSaveLogInstruction": "Sauvegarder les logs de lapp vers un fichier",
"aboutBugSaveLogButton": "Sauvegarder",
"aboutBugCopyInfoInstruction": "Copier les informations denvironnement",
"aboutBugCopyInfoButton": "Copier",
"aboutBugReportInstruction": "Créer une «\u00A0issue\u00A0» sur GitHub en attachant les logs et informations denvironnement",

View file

@ -25,7 +25,8 @@
"showTooltip": "Tampilkan",
"hideTooltip": "Sembunyikan",
"actionRemove": "Hapus",
"resetButtonTooltip": "Ulang",
"resetTooltip": "Ulang",
"saveTooltip": "Simpan",
"doubleBackExitMessage": "Ketuk “kembali” lagi untuk keluar.",
"doNotAskAgain": "Jangan tanya lagi",
@ -303,7 +304,6 @@
"aboutBug": "Lapor Bug",
"aboutBugSaveLogInstruction": "Simpan log aplikasi ke file",
"aboutBugSaveLogButton": "Simpan",
"aboutBugCopyInfoInstruction": "Salin informasi sistem",
"aboutBugCopyInfoButton": "Salin",
"aboutBugReportInstruction": "Laporkan ke GitHub dengan log dan informasi sistem",

View file

@ -25,7 +25,8 @@
"showTooltip": "Mostra",
"hideTooltip": "Nascondi",
"actionRemove": "Rimuovi",
"resetButtonTooltip": "Reimposta",
"resetTooltip": "Reimposta",
"saveTooltip": "Salva",
"doubleBackExitMessage": "Tocca di nuovo «indietro» per uscire",
"doNotAskAgain": "Non chiedere di nuovo",
@ -303,7 +304,6 @@
"aboutBug": "Segnalazione bug",
"aboutBugSaveLogInstruction": "Salva i log dellapp in un file",
"aboutBugSaveLogButton": "Salva",
"aboutBugCopyInfoInstruction": "Copia le informazioni di sistema",
"aboutBugCopyInfoButton": "Copia",
"aboutBugReportInstruction": "Segnala su GitHub con i log e le informazioni di sistema",

View file

@ -25,7 +25,8 @@
"showTooltip": "表示する",
"hideTooltip": "非表示にする",
"actionRemove": "削除",
"resetButtonTooltip": "リセット",
"resetTooltip": "リセット",
"saveTooltip": "保存",
"doubleBackExitMessage": "終了するには「戻る」をもう一度タップしてください。",
"doNotAskAgain": "今後このメッセージを表示しない",
@ -303,7 +304,6 @@
"aboutBug": "バグの報告",
"aboutBugSaveLogInstruction": "アプリのログをファイルに保存",
"aboutBugSaveLogButton": "保存",
"aboutBugCopyInfoInstruction": "システム情報をコピー",
"aboutBugCopyInfoButton": "コピー",
"aboutBugReportInstruction": "ログとシステム情報とともに GitHub で報告",

View file

@ -25,7 +25,8 @@
"showTooltip": "보기",
"hideTooltip": "숨기기",
"actionRemove": "제거",
"resetButtonTooltip": "복원",
"resetTooltip": "복원",
"saveTooltip": "저장",
"doubleBackExitMessage": "종료하려면 한번 더 누르세요.",
"doNotAskAgain": "다시 묻지 않기",
@ -303,7 +304,6 @@
"aboutBug": "버그 보고",
"aboutBugSaveLogInstruction": "앱 로그를 파일에 저장하기",
"aboutBugSaveLogButton": "저장",
"aboutBugCopyInfoInstruction": "시스템 정보를 복사하기",
"aboutBugCopyInfoButton": "복사",
"aboutBugReportInstruction": "로그와 시스템 정보를 첨부하여 깃허브에서 이슈를 제출하기",

View file

@ -25,7 +25,8 @@
"showTooltip": "Mostrar",
"hideTooltip": "Ocultar",
"actionRemove": "Remover",
"resetButtonTooltip": "Resetar",
"resetTooltip": "Resetar",
"saveTooltip": "Salve",
"doubleBackExitMessage": "Toque em “voltar” novamente para sair.",
"doNotAskAgain": "Não pergunte novamente",
@ -303,7 +304,6 @@
"aboutBug": "Relatório de erro",
"aboutBugSaveLogInstruction": "Salvar registros de aplicativos em um arquivo",
"aboutBugSaveLogButton": "Salve",
"aboutBugCopyInfoInstruction": "Copiar informações do sistema",
"aboutBugCopyInfoButton": "Copiar",
"aboutBugReportInstruction": "Relatório no GitHub com os logs e informações do sistema",

View file

@ -25,7 +25,8 @@
"showTooltip": "Показать",
"hideTooltip": "Скрыть",
"actionRemove": "Удалить",
"resetButtonTooltip": "Сбросить",
"resetTooltip": "Сбросить",
"saveTooltip": "Сохранить",
"doubleBackExitMessage": "Нажмите «Назад» еще раз, чтобы выйти.",
"doNotAskAgain": "Больше не спрашивать",
@ -303,7 +304,6 @@
"aboutBug": "Отчет об ошибке",
"aboutBugSaveLogInstruction": "Сохраните логи приложения в файл",
"aboutBugSaveLogButton": "Сохранить",
"aboutBugCopyInfoInstruction": "Скопируйте системную информацию",
"aboutBugCopyInfoButton": "Скопировать",
"aboutBugReportInstruction": "Отправьте отчёт об ошибке на GitHub вместе с логами и системной информацией",

View file

@ -25,7 +25,8 @@
"showTooltip": "Göster",
"hideTooltip": "Gizle",
"actionRemove": "Kaldır",
"resetButtonTooltip": "Sıfırla",
"resetTooltip": "Sıfırla",
"saveTooltip": "Kaydet",
"doubleBackExitMessage": "Çıkmak için tekrar “geri”, düğmesine dokunun.",
"doNotAskAgain": "Bir daha sorma",
@ -295,7 +296,6 @@
"aboutBug": "Hata Bildirimi",
"aboutBugSaveLogInstruction": "Uygulama günlüklerini bir dosyaya kaydet",
"aboutBugSaveLogButton": "Kaydet",
"aboutBugCopyInfoInstruction": "Sistem bilgilerini kopyala",
"aboutBugCopyInfoButton": "Kopyala",
"aboutBugReportInstruction": "GitHub'da günlükleri ve sistem bilgilerini içeren bir rapor oluştur",

View file

@ -25,7 +25,8 @@
"showTooltip": "显示",
"hideTooltip": "隐藏",
"actionRemove": "移除",
"resetButtonTooltip": "重置",
"resetTooltip": "重置",
"saveTooltip": "保存",
"doubleBackExitMessage": "再按一次退出",
"doNotAskAgain": "不再询问",
@ -303,7 +304,6 @@
"aboutBug": "报告错误",
"aboutBugSaveLogInstruction": "将应用日志保存到文件",
"aboutBugSaveLogButton": "保存",
"aboutBugCopyInfoInstruction": "复制系统信息",
"aboutBugCopyInfoButton": "复制",
"aboutBugReportInstruction": "在 GitHub 上报告日志和系统信息",

View file

@ -1,6 +1,10 @@
import 'package:aves/app_flavor.dart';
import 'package:aves/main_common.dart';
import 'package:aves/widget_common.dart';
void main() {
mainCommon(AppFlavor.huawei);
}
const _flavor = AppFlavor.huawei;
void main() => mainCommon(_flavor);
@pragma('vm:entry-point')
void widgetMain() => widgetMainCommon(_flavor);

View file

@ -1,6 +1,10 @@
import 'package:aves/app_flavor.dart';
import 'package:aves/main_common.dart';
import 'package:aves/widget_common.dart';
void main() {
mainCommon(AppFlavor.izzy);
}
const _flavor = AppFlavor.izzy;
void main() => mainCommon(_flavor);
@pragma('vm:entry-point')
void widgetMain() => widgetMainCommon(_flavor);

View file

@ -1,6 +1,10 @@
import 'package:aves/app_flavor.dart';
import 'package:aves/main_common.dart';
import 'package:aves/widget_common.dart';
void main() {
mainCommon(AppFlavor.play);
}
const _flavor = AppFlavor.play;
void main() => mainCommon(_flavor);
@pragma('vm:entry-point')
void widgetMain() => widgetMainCommon(_flavor);

View file

@ -691,7 +691,7 @@ class AvesEntry {
await metadataDb.removeIds({id}, dataTypes: dataTypes);
}
final updatedEntry = await mediaFileService.getEntry(uri, mimeType);
final updatedEntry = await mediaFetchService.getEntry(uri, mimeType);
if (updatedEntry != null) {
await applyNewFields(updatedEntry.toMap(), persist: persist);
}
@ -701,7 +701,7 @@ class AvesEntry {
Future<bool> delete() {
final completer = Completer<bool>();
mediaFileService.delete(entries: {this}).listen(
mediaEditService.delete(entries: {this}).listen(
(event) => completer.complete(event.success && !event.skipped),
onError: completer.completeError,
onDone: () {

View file

@ -133,6 +133,10 @@ class SettingsDefaults {
static const slideshowVideoPlayback = SlideshowVideoPlayback.playMuted;
static const slideshowInterval = SlideshowInterval.s5;
// widget
static const widgetOutline = false;
static const widgetShape = WidgetShape.rrect;
// platform settings
static const isRotationLocked = false;
static const areAnimationsRemoved = false;

View file

@ -29,3 +29,5 @@ enum VideoControls { play, playSeek, playOutside, none }
enum VideoLoopMode { never, shortOnly, always }
enum ViewerTransition { slide, parallax, fade, zoomIn }
enum WidgetShape { rrect, circle, heart }

View file

@ -0,0 +1,44 @@
import 'package:aves/model/settings/enums/enums.dart';
import 'package:flutter/material.dart';
extension ExtraWidgetShape on WidgetShape {
Path path(Size widgetSize, double devicePixelRatio) {
final rect = Rect.fromLTWH(0, 0, widgetSize.width, widgetSize.height);
switch (this) {
case WidgetShape.rrect:
return Path()..addRRect(BorderRadius.circular(24 * devicePixelRatio).toRRect(rect));
case WidgetShape.circle:
return Path()
..addOval(Rect.fromCircle(
center: rect.center,
radius: rect.shortestSide / 2,
));
case WidgetShape.heart:
final center = rect.center;
final dim = rect.shortestSide;
const p0dy = -.4;
const p1dx = .5;
const p1dy = -.4;
const p2dx = .8;
const p2dy = .5;
const p3dy = .5 - p0dy;
return Path()
..moveTo(center.dx, center.dy)
..relativeMoveTo(0, dim * p0dy)
..relativeCubicTo(dim * -p1dx, dim * p1dy, dim * -p2dx, dim * p2dy, 0, dim * p3dy)
..moveTo(center.dx, center.dy)
..relativeMoveTo(0, dim * p0dy)
..relativeCubicTo(dim * p1dx, dim * p1dy, dim * p2dx, dim * p2dy, 0, dim * p3dy);
}
}
Size size(Size widgetSize) {
switch (this) {
case WidgetShape.rrect:
return widgetSize;
case WidgetShape.circle:
case WidgetShape.heart:
return Size.square(widgetSize.shortestSide);
}
}
}

View file

@ -27,7 +27,7 @@ class Settings extends ChangeNotifier {
Settings._private();
static const Set<String> internalKeys = {
static const Set<String> _internalKeys = {
hasAcceptedTermsKey,
catalogTimeZoneKey,
videoShowRawTimedTextKey,
@ -36,6 +36,7 @@ class Settings extends ChangeNotifier {
platformTransitionAnimationScaleKey,
topEntryIdsKey,
};
static const _widgetKeyPrefix = 'widget_';
// app
static const hasAcceptedTermsKey = 'has_accepted_terms';
@ -153,6 +154,12 @@ class Settings extends ChangeNotifier {
static const slideshowVideoPlaybackKey = 'slideshow_video_playback';
static const slideshowIntervalKey = 'slideshow_interval';
// widget
static const widgetOutlinePrefixKey = '${_widgetKeyPrefix}outline_';
static const widgetShapePrefixKey = '${_widgetKeyPrefix}shape_';
static const widgetCollectionFiltersPrefixKey = '${_widgetKeyPrefix}collection_filters_';
static const widgetUriPrefixKey = '${_widgetKeyPrefix}uri_';
// platform settings
// cf Android `Settings.System.ACCELEROMETER_ROTATION`
static const platformAccelerometerRotationKey = 'accelerometer_rotation';
@ -169,14 +176,18 @@ class Settings extends ChangeNotifier {
}
}
Future<void> reload() => settingsStore.reload();
Future<void> reset({required bool includeInternalKeys}) async {
if (includeInternalKeys) {
await settingsStore.clear();
} else {
await Future.forEach<String>(settingsStore.getKeys().whereNot(Settings.internalKeys.contains), settingsStore.remove);
await Future.forEach<String>(settingsStore.getKeys().whereNot(isInternalKey), settingsStore.remove);
}
}
bool isInternalKey(String key) => _internalKeys.contains(key) || key.startsWith(_widgetKeyPrefix);
Future<void> setContextualDefaults() async {
// performance
final performanceClass = await deviceService.getPerformanceClass();
@ -639,6 +650,27 @@ class Settings extends ChangeNotifier {
set slideshowInterval(SlideshowInterval newValue) => setAndNotify(slideshowIntervalKey, newValue.toString());
// widget
Color? getWidgetOutline(int widgetId) {
final value = getInt('$widgetOutlinePrefixKey$widgetId');
return value != null ? Color(value) : null;
}
void setWidgetOutline(int widgetId, Color? newValue) => setAndNotify('$widgetOutlinePrefixKey$widgetId', newValue?.value);
WidgetShape getWidgetShape(int widgetId) => getEnumOrDefault('$widgetShapePrefixKey$widgetId', SettingsDefaults.widgetShape, WidgetShape.values);
void setWidgetShape(int widgetId, WidgetShape newValue) => setAndNotify('$widgetShapePrefixKey$widgetId', newValue.toString());
Set<CollectionFilter> getWidgetCollectionFilters(int widgetId) => (getStringList('$widgetCollectionFiltersPrefixKey$widgetId') ?? []).map(CollectionFilter.fromJson).whereNotNull().toSet();
void setWidgetCollectionFilters(int widgetId, Set<CollectionFilter> newValue) => setAndNotify('$widgetCollectionFiltersPrefixKey$widgetId', newValue.map((filter) => filter.toJson()).toList());
String? getWidgetUri(int widgetId) => getString('$widgetUriPrefixKey$widgetId');
void setWidgetUri(int widgetId, String? newValue) => setAndNotify('$widgetUriPrefixKey$widgetId', newValue);
// convenience methods
int? getInt(String key) => settingsStore.getInt(key);
@ -721,7 +753,7 @@ class Settings extends ChangeNotifier {
// import/export
Map<String, dynamic> export() => Map.fromEntries(
settingsStore.getKeys().whereNot(internalKeys.contains).map((k) => MapEntry(k, settingsStore.get(k))),
settingsStore.getKeys().whereNot(isInternalKey).map((k) => MapEntry(k, settingsStore.get(k))),
);
Future<void> import(dynamic jsonMap) async {

View file

@ -3,6 +3,8 @@ abstract class SettingsStore {
Future<void> init();
Future<void> reload();
Future<bool> clear();
Future<bool> remove(String key);

View file

@ -17,6 +17,9 @@ class SharedPrefSettingsStore implements SettingsStore {
}
}
@override
Future<void> reload() => _prefs!.reload();
@override
Future<bool> clear() => _prefs!.clear();

View file

@ -235,7 +235,7 @@ class MediaStoreSource extends CollectionSource {
for (final kv in uriByContentId.entries) {
final contentId = kv.key;
final uri = kv.value;
final sourceEntry = await mediaFileService.getEntry(uri, null);
final sourceEntry = await mediaFetchService.getEntry(uri, null);
if (sourceEntry != null) {
final existingEntry = allEntries.firstWhereOrNull((entry) => entry.contentId == contentId);
// compare paths because some apps move files without updating their `last modified date`

View file

@ -19,7 +19,7 @@ mixin TrashMixin on SourceBase {
final processed = <ImageOpEvent>{};
final completer = Completer<Set<String>>();
mediaFileService.delete(entries: expiredEntries).listen(
mediaEditService.delete(entries: expiredEntries).listen(
processed.add,
onError: completer.completeError,
onDone: () async {

View file

@ -196,7 +196,7 @@ class PlatformAndroidAppService implements AndroidAppService {
Uint8List? iconBytes;
if (coverEntry != null) {
final size = coverEntry.isVideo ? 0.0 : 256.0;
iconBytes = await mediaFileService.getThumbnail(
iconBytes = await mediaFetchService.getThumbnail(
uri: coverEntry.uri,
mimeType: coverEntry.mimeType,
pageId: coverEntry.pageId,

View file

@ -6,7 +6,8 @@ import 'package:aves/model/settings/store/store_shared_pref.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/device_service.dart';
import 'package:aves/services/media/embedded_data_service.dart';
import 'package:aves/services/media/media_file_service.dart';
import 'package:aves/services/media/media_edit_service.dart';
import 'package:aves/services/media/media_fetch_service.dart';
import 'package:aves/services/media/media_store_service.dart';
import 'package:aves/services/metadata/metadata_edit_service.dart';
import 'package:aves/services/metadata/metadata_fetch_service.dart';
@ -31,7 +32,8 @@ final MetadataDb metadataDb = getIt<MetadataDb>();
final AndroidAppService androidAppService = getIt<AndroidAppService>();
final DeviceService deviceService = getIt<DeviceService>();
final EmbeddedDataService embeddedDataService = getIt<EmbeddedDataService>();
final MediaFileService mediaFileService = getIt<MediaFileService>();
final MediaEditService mediaEditService = getIt<MediaEditService>();
final MediaFetchService mediaFetchService = getIt<MediaFetchService>();
final MediaStoreService mediaStoreService = getIt<MediaStoreService>();
final MetadataEditService metadataEditService = getIt<MetadataEditService>();
final MetadataFetchService metadataFetchService = getIt<MetadataFetchService>();
@ -48,7 +50,8 @@ void initPlatformServices() {
getIt.registerLazySingleton<AndroidAppService>(PlatformAndroidAppService.new);
getIt.registerLazySingleton<DeviceService>(PlatformDeviceService.new);
getIt.registerLazySingleton<EmbeddedDataService>(PlatformEmbeddedDataService.new);
getIt.registerLazySingleton<MediaFileService>(PlatformMediaFileService.new);
getIt.registerLazySingleton<MediaEditService>(PlatformMediaEditService.new);
getIt.registerLazySingleton<MediaFetchService>(PlatformMediaFetchService.new);
getIt.registerLazySingleton<MediaStoreService>(PlatformMediaStoreService.new);
getIt.registerLazySingleton<MetadataEditService>(PlatformMetadataEditService.new);
getIt.registerLazySingleton<MetadataFetchService>(PlatformMetadataFetchService.new);

View file

@ -0,0 +1,216 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:aves/model/entry.dart';
import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:streams_channel/streams_channel.dart';
abstract class MediaEditService {
String get newOpId;
Future<void> cancelFileOp(String opId);
Stream<ImageOpEvent> delete({
String? opId,
required Iterable<AvesEntry> entries,
});
Stream<MoveOpEvent> move({
String? opId,
required Map<String, Iterable<AvesEntry>> entriesByDestination,
required bool copy,
required NameConflictStrategy nameConflictStrategy,
});
Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, {
required EntryExportOptions options,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
});
Stream<MoveOpEvent> rename({
String? opId,
required Map<AvesEntry, String> entriesToNewName,
});
Future<Map<String, dynamic>> captureFrame(
AvesEntry entry, {
required String desiredName,
required Map<String, dynamic> exif,
required Uint8List bytes,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
});
}
class PlatformMediaEditService implements MediaEditService {
static const _platform = MethodChannel('deckers.thibault/aves/media_edit');
static final _opStream = StreamsChannel('deckers.thibault/aves/media_op_stream');
static Map<String, dynamic> _toPlatformEntryMap(AvesEntry entry) {
return {
'uri': entry.uri,
'path': entry.path,
'pageId': entry.pageId,
'mimeType': entry.mimeType,
'width': entry.width,
'height': entry.height,
'rotationDegrees': entry.rotationDegrees,
'isFlipped': entry.isFlipped,
'dateModifiedSecs': entry.dateModifiedSecs,
'sizeBytes': entry.sizeBytes,
'trashed': entry.trashed,
'trashPath': entry.trashDetails?.path,
};
}
@override
String get newOpId => DateTime.now().millisecondsSinceEpoch.toString();
@override
Future<void> cancelFileOp(String opId) async {
try {
await _platform.invokeMethod('cancelFileOp', <String, dynamic>{
'opId': opId,
});
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
@override
Stream<ImageOpEvent> delete({
String? opId,
required Iterable<AvesEntry> entries,
}) {
try {
return _opStream
.receiveBroadcastStream(<String, dynamic>{
'op': 'delete',
'id': opId,
'entries': entries.map(_toPlatformEntryMap).toList(),
})
.where((event) => event is Map)
.map((event) => ImageOpEvent.fromMap(event as Map));
} on PlatformException catch (e, stack) {
reportService.recordError(e, stack);
return Stream.error(e);
}
}
@override
Stream<MoveOpEvent> move({
String? opId,
required Map<String, Iterable<AvesEntry>> entriesByDestination,
required bool copy,
required NameConflictStrategy nameConflictStrategy,
}) {
try {
return _opStream
.receiveBroadcastStream(<String, dynamic>{
'op': 'move',
'id': opId,
'entriesByDestination': entriesByDestination.map((destination, entries) => MapEntry(destination, entries.map(_toPlatformEntryMap).toList())),
'copy': copy,
'nameConflictStrategy': nameConflictStrategy.toPlatform(),
})
.where((event) => event is Map)
.map((event) => MoveOpEvent.fromMap(event as Map));
} on PlatformException catch (e, stack) {
reportService.recordError(e, stack);
return Stream.error(e);
}
}
@override
Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, {
required EntryExportOptions options,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
}) {
try {
return _opStream
.receiveBroadcastStream(<String, dynamic>{
'op': 'export',
'entries': entries.map(_toPlatformEntryMap).toList(),
'mimeType': options.mimeType,
'width': options.width,
'height': options.height,
'destinationPath': destinationAlbum,
'nameConflictStrategy': nameConflictStrategy.toPlatform(),
})
.where((event) => event is Map)
.map((event) => ExportOpEvent.fromMap(event as Map));
} on PlatformException catch (e, stack) {
reportService.recordError(e, stack);
return Stream.error(e);
}
}
@override
Stream<MoveOpEvent> rename({
String? opId,
required Map<AvesEntry, String> entriesToNewName,
}) {
try {
return _opStream
.receiveBroadcastStream(<String, dynamic>{
'op': 'rename',
'id': opId,
'entriesToNewName': entriesToNewName.map((key, value) => MapEntry(_toPlatformEntryMap(key), value)),
})
.where((event) => event is Map)
.map((event) => MoveOpEvent.fromMap(event as Map));
} on PlatformException catch (e, stack) {
reportService.recordError(e, stack);
return Stream.error(e);
}
}
@override
Future<Map<String, dynamic>> captureFrame(
AvesEntry entry, {
required String desiredName,
required Map<String, dynamic> exif,
required Uint8List bytes,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
}) async {
try {
final result = await _platform.invokeMethod('captureFrame', <String, dynamic>{
'uri': entry.uri,
'desiredName': desiredName,
'exif': exif,
'bytes': bytes,
'destinationPath': destinationAlbum,
'nameConflictStrategy': nameConflictStrategy.toPlatform(),
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
}
@immutable
class EntryExportOptions extends Equatable {
final String mimeType;
final int width, height;
@override
List<Object?> get props => [mimeType, width, height];
const EntryExportOptions({
required this.mimeType,
required this.width,
required this.height,
});
}

View file

@ -5,19 +5,14 @@ import 'dart:ui';
import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/output_buffer.dart';
import 'package:aves/services/common/service_policy.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:streams_channel/streams_channel.dart';
abstract class MediaFileService {
String get newOpId;
abstract class MediaFetchService {
Future<AvesEntry?> getEntry(String uri, String? mimeType);
Future<Uint8List> getSvg(
@ -70,69 +65,13 @@ abstract class MediaFileService {
bool cancelThumbnail(Object taskKey);
Future<T>? resumeLoading<T>(Object taskKey);
Future<void> cancelFileOp(String opId);
Stream<ImageOpEvent> delete({
String? opId,
required Iterable<AvesEntry> entries,
});
Stream<MoveOpEvent> move({
String? opId,
required Map<String, Iterable<AvesEntry>> entriesByDestination,
required bool copy,
required NameConflictStrategy nameConflictStrategy,
});
Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, {
required EntryExportOptions options,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
});
Stream<MoveOpEvent> rename({
String? opId,
required Map<AvesEntry, String> entriesToNewName,
});
Future<Map<String, dynamic>> captureFrame(
AvesEntry entry, {
required String desiredName,
required Map<String, dynamic> exif,
required Uint8List bytes,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
});
}
class PlatformMediaFileService implements MediaFileService {
static const _platform = MethodChannel('deckers.thibault/aves/media_file');
class PlatformMediaFetchService implements MediaFetchService {
static const _platform = MethodChannel('deckers.thibault/aves/media_fetch');
static final _byteStream = StreamsChannel('deckers.thibault/aves/media_byte_stream');
static final _opStream = StreamsChannel('deckers.thibault/aves/media_op_stream');
static const double _thumbnailDefaultSize = 64.0;
static Map<String, dynamic> _toPlatformEntryMap(AvesEntry entry) {
return {
'uri': entry.uri,
'path': entry.path,
'pageId': entry.pageId,
'mimeType': entry.mimeType,
'width': entry.width,
'height': entry.height,
'rotationDegrees': entry.rotationDegrees,
'isFlipped': entry.isFlipped,
'dateModifiedSecs': entry.dateModifiedSecs,
'sizeBytes': entry.sizeBytes,
'trashed': entry.trashed,
'trashPath': entry.trashDetails?.path,
};
}
@override
String get newOpId => DateTime.now().millisecondsSinceEpoch.toString();
@override
Future<AvesEntry?> getEntry(String uri, String? mimeType) async {
try {
@ -315,145 +254,4 @@ class PlatformMediaFileService implements MediaFileService {
@override
Future<T>? resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
@override
Future<void> cancelFileOp(String opId) async {
try {
await _platform.invokeMethod('cancelFileOp', <String, dynamic>{
'opId': opId,
});
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
@override
Stream<ImageOpEvent> delete({
String? opId,
required Iterable<AvesEntry> entries,
}) {
try {
return _opStream
.receiveBroadcastStream(<String, dynamic>{
'op': 'delete',
'id': opId,
'entries': entries.map(_toPlatformEntryMap).toList(),
})
.where((event) => event is Map)
.map((event) => ImageOpEvent.fromMap(event as Map));
} on PlatformException catch (e, stack) {
reportService.recordError(e, stack);
return Stream.error(e);
}
}
@override
Stream<MoveOpEvent> move({
String? opId,
required Map<String, Iterable<AvesEntry>> entriesByDestination,
required bool copy,
required NameConflictStrategy nameConflictStrategy,
}) {
try {
return _opStream
.receiveBroadcastStream(<String, dynamic>{
'op': 'move',
'id': opId,
'entriesByDestination': entriesByDestination.map((destination, entries) => MapEntry(destination, entries.map(_toPlatformEntryMap).toList())),
'copy': copy,
'nameConflictStrategy': nameConflictStrategy.toPlatform(),
})
.where((event) => event is Map)
.map((event) => MoveOpEvent.fromMap(event as Map));
} on PlatformException catch (e, stack) {
reportService.recordError(e, stack);
return Stream.error(e);
}
}
@override
Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, {
required EntryExportOptions options,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
}) {
try {
return _opStream
.receiveBroadcastStream(<String, dynamic>{
'op': 'export',
'entries': entries.map(_toPlatformEntryMap).toList(),
'mimeType': options.mimeType,
'width': options.width,
'height': options.height,
'destinationPath': destinationAlbum,
'nameConflictStrategy': nameConflictStrategy.toPlatform(),
})
.where((event) => event is Map)
.map((event) => ExportOpEvent.fromMap(event as Map));
} on PlatformException catch (e, stack) {
reportService.recordError(e, stack);
return Stream.error(e);
}
}
@override
Stream<MoveOpEvent> rename({
String? opId,
required Map<AvesEntry, String> entriesToNewName,
}) {
try {
return _opStream
.receiveBroadcastStream(<String, dynamic>{
'op': 'rename',
'id': opId,
'entriesToNewName': entriesToNewName.map((key, value) => MapEntry(_toPlatformEntryMap(key), value)),
})
.where((event) => event is Map)
.map((event) => MoveOpEvent.fromMap(event as Map));
} on PlatformException catch (e, stack) {
reportService.recordError(e, stack);
return Stream.error(e);
}
}
@override
Future<Map<String, dynamic>> captureFrame(
AvesEntry entry, {
required String desiredName,
required Map<String, dynamic> exif,
required Uint8List bytes,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
}) async {
try {
final result = await _platform.invokeMethod('captureFrame', <String, dynamic>{
'uri': entry.uri,
'desiredName': desiredName,
'exif': exif,
'bytes': bytes,
'destinationPath': destinationAlbum,
'nameConflictStrategy': nameConflictStrategy.toPlatform(),
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
}
@immutable
class EntryExportOptions extends Equatable {
final String mimeType;
final int width, height;
@override
List<Object?> get props => [mimeType, width, height];
const EntryExportOptions({
required this.mimeType,
required this.width,
required this.height,
});
}

View file

@ -17,7 +17,7 @@ class SvgMetadataService {
static Future<Size?> getSize(AvesEntry entry) async {
try {
final data = await mediaFileService.getSvg(entry.uri, entry.mimeType);
final data = await mediaFetchService.getSvg(entry.uri, entry.mimeType);
final document = XmlDocument.parse(utf8.decode(data));
final root = document.rootElement;
@ -63,7 +63,7 @@ class SvgMetadataService {
}
try {
final data = await mediaFileService.getSvg(entry.uri, entry.mimeType);
final data = await mediaFetchService.getSvg(entry.uri, entry.mimeType);
final document = XmlDocument.parse(utf8.decode(data));
final root = document.rootElement;

View file

@ -0,0 +1,29 @@
import 'package:aves/services/common/services.dart';
import 'package:flutter/services.dart';
class WidgetService {
static const _configureChannel = MethodChannel('deckers.thibault/aves/widget_configure');
static const _updateChannel = MethodChannel('deckers.thibault/aves/widget_update');
static Future<bool> configure() async {
try {
await _configureChannel.invokeMethod('configure');
return true;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return false;
}
static Future<bool> update(int widgetId) async {
try {
await _updateChannel.invokeMethod('update', <String, dynamic>{
'widgetId': widgetId,
});
return true;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return false;
}
}

View file

@ -9,6 +9,8 @@ class Constants {
// so we give it a `strutStyle` with a slightly larger height
static const overflowStrutStyle = StrutStyle(height: 1.3);
static const double colorPickerRadius = 16;
static const titleTextStyle = TextStyle(
fontSize: 20,
fontWeight: FontWeight.w300,
@ -22,6 +24,11 @@ class Constants {
)
];
static const boraBoraGradientColors = [
Color(0xff2bc0e4),
Color(0xffeaecc6),
];
// Bidi fun, cf https://www.unicode.org/reports/tr9/
// First Strong Isolate
static const fsi = '\u2068';

82
lib/widget_common.dart Normal file
View file

@ -0,0 +1,82 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:aves/app_flavor.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/media_store_source.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/widgets/home_widget.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
const _widgetDrawChannel = MethodChannel('deckers.thibault/aves/widget_draw');
void widgetMainCommon(AppFlavor flavor) async {
WidgetsFlutterBinding.ensureInitialized();
initPlatformServices();
await settings.init(monitorPlatformSettings: false);
_widgetDrawChannel.setMethodCallHandler((call) async {
// widget settings may be modified in a different process after channel setup
await settings.reload();
switch (call.method) {
case 'drawWidget':
return _drawWidget(call.arguments);
default:
throw PlatformException(code: 'not-implemented', message: 'failed to handle method=${call.method}');
}
});
}
Future<Uint8List> _drawWidget(dynamic args) async {
final widgetId = args['widgetId'] as int;
final widthPx = args['widthPx'] as int;
final heightPx = args['heightPx'] as int;
final devicePixelRatio = args['devicePixelRatio'] as double;
final drawEntryImage = args['drawEntryImage'] as bool;
final reuseEntry = args['reuseEntry'] as bool;
final entry = drawEntryImage ? await _getWidgetEntry(widgetId, reuseEntry) : null;
final painter = HomeWidgetPainter(
entry: entry,
devicePixelRatio: devicePixelRatio,
);
return painter.drawWidget(
widthPx: widthPx,
heightPx: heightPx,
outline: settings.getWidgetOutline(widgetId),
shape: settings.getWidgetShape(widgetId),
);
}
Future<AvesEntry?> _getWidgetEntry(int widgetId, bool reuseEntry) async {
final uri = reuseEntry ? settings.getWidgetUri(widgetId) : null;
if (uri != null) {
final entry = await mediaFetchService.getEntry(uri, null);
if (entry != null) return entry;
}
final filters = settings.getWidgetCollectionFilters(widgetId);
final source = MediaStoreSource();
final readyCompleter = Completer();
source.stateNotifier.addListener(() {
if (source.stateNotifier.value == SourceState.ready) {
readyCompleter.complete();
}
});
await source.init(canAnalyze: false);
await readyCompleter.future;
final entries = CollectionLens(source: source, filters: filters).sortedEntries;
entries.shuffle();
final entry = entries.firstOrNull;
if (entry != null) {
settings.setWidgetUri(widgetId, entry.uri);
}
return entry;
}

View file

@ -68,7 +68,7 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildStep(1, l10n.aboutBugSaveLogInstruction, l10n.aboutBugSaveLogButton, _saveLogs),
_buildStep(1, l10n.aboutBugSaveLogInstruction, l10n.saveTooltip, _saveLogs),
_buildStep(2, l10n.aboutBugCopyInfoInstruction, l10n.aboutBugCopyInfoButton, _copySystemInfo),
FutureBuilder<String>(
future: _infoLoader,

View file

@ -12,12 +12,12 @@ import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/intent_service.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/collection_grid.dart';
import 'package:aves/widgets/common/basic/draggable_scrollbar.dart';
import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/behaviour/double_back_pop.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_fab.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/providers/query_provider.dart';
import 'package:aves/widgets/common/providers/selection_provider.dart';
@ -148,30 +148,11 @@ class _CollectionPageState extends State<CollectionPage> {
}
Widget? _buildFab(BuildContext context, bool hasSelection) {
Widget fab({
required String tooltip,
required VoidCallback onPressed,
}) {
return TooltipTheme(
data: TooltipTheme.of(context).copyWith(
preferBelow: false,
),
child: FloatingActionButton(
tooltip: tooltip,
onPressed: onPressed,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
child: const Icon(AIcons.apply),
),
);
}
final appMode = context.watch<ValueNotifier<AppMode>>().value;
switch (appMode) {
case AppMode.pickMultipleMediaExternal:
return hasSelection
? fab(
? AvesFab(
tooltip: context.l10n.collectionPickPageTitle,
onPressed: () {
final items = context.read<Selection<AvesEntry>>().selectedItems;
@ -181,7 +162,7 @@ class _CollectionPageState extends State<CollectionPage> {
)
: null;
case AppMode.pickCollectionFiltersExternal:
return fab(
return AvesFab(
tooltip: context.l10n.collectionPickPageTitle,
onPressed: () {
final filters = _collection.filters;

View file

@ -283,12 +283,12 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
if (!pureTrash && !await checkStoragePermissionForAlbums(context, selectionDirs, entries: entries)) return;
source.pauseMonitoring();
final opId = mediaFileService.newOpId;
final opId = mediaEditService.newOpId;
await showOpReport<ImageOpEvent>(
context: context,
opStream: mediaFileService.delete(opId: opId, entries: entries),
opStream: mediaEditService.delete(opId: opId, entries: entries),
itemCount: todoCount,
onCancel: () => mediaFileService.cancelFileOp(opId),
onCancel: () => mediaEditService.cancelFileOp(opId),
onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet();
final deletedOps = successOps.where((e) => !e.skipped).toSet();

View file

@ -119,17 +119,17 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
final source = context.read<CollectionSource>();
source.pauseMonitoring();
final opId = mediaFileService.newOpId;
final opId = mediaEditService.newOpId;
await showOpReport<MoveOpEvent>(
context: context,
opStream: mediaFileService.move(
opStream: mediaEditService.move(
opId: opId,
entriesByDestination: entriesByDestination,
copy: copy,
nameConflictStrategy: nameConflictStrategy,
),
itemCount: todoCount,
onCancel: () => mediaFileService.cancelFileOp(opId),
onCancel: () => mediaEditService.cancelFileOp(opId),
onDone: (processed) async {
final successOps = processed.where((v) => v.success).toSet();
@ -226,15 +226,15 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
final source = context.read<CollectionSource>();
source.pauseMonitoring();
final opId = mediaFileService.newOpId;
final opId = mediaEditService.newOpId;
await showOpReport<MoveOpEvent>(
context: context,
opStream: mediaFileService.rename(
opStream: mediaEditService.rename(
opId: opId,
entriesToNewName: entriesToNewName,
),
itemCount: todoCount,
onCancel: () => mediaFileService.cancelFileOp(opId),
onCancel: () => mediaEditService.cancelFileOp(opId),
onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet();
final movedOps = successOps.where((e) => !e.skipped).toSet();

View file

@ -1,3 +1,4 @@
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
@ -9,7 +10,7 @@ class ColorListTile extends StatelessWidget {
final Color value;
final ValueSetter<Color> onChanged;
static const double radius = 16.0;
static const radius = Constants.colorPickerRadius;
const ColorListTile({
super.key,

View file

@ -67,8 +67,8 @@ class OutlinedText extends StatelessWidget {
style: (span.style ?? const TextStyle()).copyWith(
foreground: Paint()
..style = PaintingStyle.stroke
..strokeWidth = outlineWidth
..color = outlineColor,
..color = outlineColor
..strokeWidth = outlineWidth,
),
);

View file

@ -0,0 +1,30 @@
import 'package:aves/theme/icons.dart';
import 'package:flutter/material.dart';
class AvesFab extends StatelessWidget {
final String tooltip;
final VoidCallback onPressed;
const AvesFab({
super.key,
required this.tooltip,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return TooltipTheme(
data: TooltipTheme.of(context).copyWith(
preferBelow: false,
),
child: FloatingActionButton(
tooltip: tooltip,
onPressed: onPressed,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
child: const Icon(AIcons.apply),
),
);
}
}

View file

@ -29,9 +29,9 @@ class CompassPainter extends CustomPainter {
..color = color.withOpacity(.6);
final strokePaint = Paint()
..style = PaintingStyle.stroke
..color = color
..strokeWidth = 1.7
..strokeJoin = StrokeJoin.round
..color = color;
..strokeJoin = StrokeJoin.round;
canvas.drawPath(northTriangle, fillPaint);
canvas.drawPath(northTriangle, strokePaint);

View file

@ -172,7 +172,7 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
if (widget.cancellableNotifier?.value ?? false) {
final key = await _currentProviderStream?.provider.provider.obtainKey(ImageConfiguration.empty);
if (key is ThumbnailProviderKey) {
mediaFileService.cancelThumbnail(key);
mediaFetchService.cancelThumbnail(key);
}
}
}

View file

@ -45,7 +45,7 @@ class _DebugCacheSectionState extends State<DebugCacheSection> with AutomaticKee
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: mediaFileService.clearSizedThumbnailDiskCache,
onPressed: mediaFetchService.clearSizedThumbnailDiskCache,
child: const Text('Clear'),
),
],

View file

@ -64,7 +64,7 @@ class _TagEditorPageState extends State<TagEditorPage> {
IconButton(
icon: const Icon(AIcons.reset),
onPressed: _reset,
tooltip: l10n.resetButtonTooltip,
tooltip: l10n.resetTooltip,
),
],
),

View file

@ -1,6 +1,6 @@
import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/media/media_file_service.dart';
import 'package:aves/services/media/media_edit_service.dart';
import 'package:aves/utils/mime_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart';

View file

@ -9,6 +9,7 @@ import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/basic/color_list_tile.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/borders.dart';
@ -53,7 +54,7 @@ class _CoverSelectionDialogState extends State<CoverSelectionDialog> {
static const double itemPickerExtent = 46;
static const double appPickerExtent = 32;
static const double colorPickerRadius = 16;
static const double colorPickerRadius = Constants.colorPickerRadius;
double tabBarHeight(BuildContext context) => 64 * max(1, MediaQuery.textScaleFactorOf(context));

View file

@ -250,12 +250,12 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
if (!await checkStoragePermissionForAlbums(context, filledAlbums)) return;
source.pauseMonitoring();
final opId = mediaFileService.newOpId;
final opId = mediaEditService.newOpId;
await showOpReport<ImageOpEvent>(
context: context,
opStream: mediaFileService.delete(opId: opId, entries: todoEntries),
opStream: mediaEditService.delete(opId: opId, entries: todoEntries),
itemCount: todoCount,
onCancel: () => mediaFileService.cancelFileOp(opId),
onCancel: () => mediaEditService.cancelFileOp(opId),
onDone: (processed) async {
final successOps = processed.where((event) => event.success);
final deletedOps = successOps.where((e) => !e.skipped).toSet();
@ -314,10 +314,10 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
}
source.pauseMonitoring();
final opId = mediaFileService.newOpId;
final opId = mediaEditService.newOpId;
await showOpReport<MoveOpEvent>(
context: context,
opStream: mediaFileService.move(
opStream: mediaEditService.move(
opId: opId,
entriesByDestination: {destinationAlbum: todoEntries},
copy: false,
@ -325,7 +325,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
nameConflictStrategy: NameConflictStrategy.rename,
),
itemCount: todoCount,
onCancel: () => mediaFileService.cancelFileOp(opId),
onCancel: () => mediaEditService.cancelFileOp(opId),
onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet();
final movedOps = successOps.where((e) => !e.skipped).toSet();

View file

@ -14,6 +14,7 @@ import 'package:aves/services/analysis_service.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/global_search.dart';
import 'package:aves/services/intent_service.dart';
import 'package:aves/services/widget_service.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/behaviour/routes.dart';
@ -22,6 +23,7 @@ import 'package:aves/widgets/common/search/route.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/search/search_delegate.dart';
import 'package:aves/widgets/settings/screen_saver_settings_page.dart';
import 'package:aves/widgets/settings/home_widget_settings_page.dart';
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
import 'package:aves/widgets/viewer/screen_saver_page.dart';
import 'package:aves/widgets/wallpaper_page.dart';
@ -47,6 +49,7 @@ class HomePage extends StatefulWidget {
class _HomePageState extends State<HomePage> {
AvesEntry? _viewerEntry;
int? _widgetId;
String? _initialRouteName, _initialSearchQuery;
Set<CollectionFilter>? _initialFilters;
@ -57,6 +60,17 @@ class _HomePageState extends State<HomePage> {
static const actionSearch = 'search';
static const actionSetWallpaper = 'set_wallpaper';
static const actionView = 'view';
static const actionWidgetOpen = 'widget_open';
static const actionWidgetSettings = 'widget_settings';
static const intentDataKeyAction = 'action';
static const intentDataKeyAllowMultiple = 'allowMultiple';
static const intentDataKeyFilters = 'filters';
static const intentDataKeyMimeType = 'mimeType';
static const intentDataKeyPage = 'page';
static const intentDataKeyQuery = 'query';
static const intentDataKeyUri = 'uri';
static const intentDataKeyWidgetId = 'widgetId';
static const allowedShortcutRoutes = [
CollectionPage.routeName,
@ -88,7 +102,7 @@ class _HomePageState extends State<HomePage> {
var appMode = AppMode.main;
final intentData = widget.intentData ?? await IntentService.getIntentData();
final intentAction = intentData['action'];
final intentAction = intentData[intentDataKeyAction];
if (!{actionScreenSaver, actionSetWallpaper}.contains(intentAction)) {
await androidFileUtils.init();
@ -101,19 +115,31 @@ class _HomePageState extends State<HomePage> {
await reportService.log('Intent data=$intentData');
switch (intentAction) {
case actionView:
_viewerEntry = await _initViewerEntry(
uri: intentData['uri'],
mimeType: intentData['mimeType'],
);
if (_viewerEntry != null) {
appMode = AppMode.view;
case actionWidgetOpen:
String? uri, mimeType;
final widgetId = intentData[intentDataKeyWidgetId];
if (widgetId != null) {
uri = settings.getWidgetUri(widgetId);
unawaited(WidgetService.update(widgetId));
} else {
uri = intentData[intentDataKeyUri];
mimeType = intentData[intentDataKeyMimeType];
}
if (uri != null) {
_viewerEntry = await _initViewerEntry(
uri: uri,
mimeType: mimeType,
);
if (_viewerEntry != null) {
appMode = AppMode.view;
}
}
break;
case actionPickItems:
// TODO TLAD apply pick mimetype(s)
// some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
String? pickMimeTypes = intentData['mimeType'];
final multiple = intentData['allowMultiple'] ?? false;
String? pickMimeTypes = intentData[intentDataKeyMimeType];
final multiple = intentData[intentDataKeyAllowMultiple] ?? false;
debugPrint('pick mimeType=$pickMimeTypes multiple=$multiple');
appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal;
break;
@ -129,23 +155,27 @@ class _HomePageState extends State<HomePage> {
break;
case actionSearch:
_initialRouteName = CollectionSearchDelegate.pageRouteName;
_initialSearchQuery = intentData['query'];
_initialSearchQuery = intentData[intentDataKeyQuery];
break;
case actionSetWallpaper:
appMode = AppMode.setWallpaper;
_viewerEntry = await _initViewerEntry(
uri: intentData['uri'],
mimeType: intentData['mimeType'],
uri: intentData[intentDataKeyUri],
mimeType: intentData[intentDataKeyMimeType],
);
break;
case actionWidgetSettings:
_initialRouteName = HomeWidgetSettingsPage.routeName;
_widgetId = intentData[intentDataKeyWidgetId] ?? 0;
break;
default:
// do not use 'route' as extra key, as the Flutter framework acts on it
final extraRoute = intentData['page'];
final extraRoute = intentData[intentDataKeyPage];
if (allowedShortcutRoutes.contains(extraRoute)) {
_initialRouteName = extraRoute;
}
}
final extraFilters = intentData['filters'];
final extraFilters = intentData[intentDataKeyFilters];
_initialFilters = extraFilters != null ? (extraFilters as List).cast<String>().map(CollectionFilter.fromJson).whereNotNull().toSet() : null;
}
context.read<ValueNotifier<AppMode>>().value = appMode;
@ -207,7 +237,7 @@ class _HomePageState extends State<HomePage> {
// convert this file path to a proper URI
uri = Uri.file(uri).toString();
}
final entry = await mediaFileService.getEntry(uri, mimeType);
final entry = await mediaFetchService.getEntry(uri, mimeType);
if (entry != null) {
// cataloguing is essential for coordinates and video rotation
await entry.catalog(background: false, force: false, persist: false);
@ -309,6 +339,13 @@ class _HomePageState extends State<HomePage> {
settings: RouteSettings(name: routeName),
builder: (context) => const ScreenSaverSettingsPage(),
);
case HomeWidgetSettingsPage.routeName:
return DirectMaterialPageRoute(
settings: RouteSettings(name: routeName),
builder: (context) => HomeWidgetSettingsPage(
widgetId: _widgetId!,
),
);
case CollectionSearchDelegate.pageRouteName:
return SearchPageRoute(
delegate: CollectionSearchDelegate(

View file

@ -0,0 +1,86 @@
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_images.dart';
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/enums/widget_shape.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:flutter/material.dart';
class HomeWidgetPainter {
final AvesEntry? entry;
final double devicePixelRatio;
static const backgroundGradient = LinearGradient(
begin: Alignment.bottomLeft,
end: Alignment.topRight,
colors: Constants.boraBoraGradientColors,
);
HomeWidgetPainter({
required this.entry,
required this.devicePixelRatio,
});
Future<Uint8List> drawWidget({
required int widthPx,
required int heightPx,
required Color? outline,
required WidgetShape shape,
ui.ImageByteFormat format = ui.ImageByteFormat.rawRgba,
}) async {
final widgetSizePx = Size(widthPx.toDouble(), heightPx.toDouble());
final entryImage = await _getEntryImage(entry, shape.size(widgetSizePx));
final recorder = ui.PictureRecorder();
final rect = Rect.fromLTWH(0, 0, widgetSizePx.width, widgetSizePx.height);
final canvas = Canvas(recorder, rect);
final path = shape.path(widgetSizePx, devicePixelRatio);
canvas.clipPath(path);
if (entryImage != null) {
canvas.drawImage(entryImage, Offset(widgetSizePx.width - entryImage.width, widgetSizePx.height - entryImage.height) / 2, Paint());
} else {
canvas.drawPaint(Paint()..shader = backgroundGradient.createShader(rect));
}
if (outline != null) {
drawOutline(canvas, path, devicePixelRatio, outline);
}
final widgetImage = await recorder.endRecording().toImage(widthPx, heightPx);
final byteData = await widgetImage.toByteData(format: format);
return byteData?.buffer.asUint8List() ?? Uint8List(0);
}
static void drawOutline(ui.Canvas canvas, ui.Path outlinePath, double devicePixelRatio, Color color) {
canvas.drawPath(
outlinePath,
Paint()
..style = PaintingStyle.stroke
..color = color
..strokeWidth = AvesFilterChip.outlineWidth * devicePixelRatio * 2
..strokeCap = StrokeCap.round);
}
FutureOr<ui.Image?> _getEntryImage(AvesEntry? entry, Size sizePx) async {
if (entry == null) return null;
final provider = entry.getThumbnail(extent: sizePx.longestSide / devicePixelRatio);
final imageInfoCompleter = Completer<ImageInfo?>();
final imageStream = provider.resolve(ImageConfiguration.empty);
final imageStreamListener = ImageStreamListener((image, synchronousCall) async {
imageInfoCompleter.complete(image);
}, onError: imageInfoCompleter.completeError);
imageStream.addListener(imageStreamListener);
ImageInfo? regionImageInfo;
try {
regionImageInfo = await imageInfoCompleter.future;
} catch (error) {
debugPrint('failed to get widget image for entry=$entry with error=$error');
}
imageStream.removeListener(imageStreamListener);
return regionImageInfo?.image;
}
}

View file

@ -0,0 +1,70 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/services/intent_service.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/filter_bar.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart';
class SettingsCollectionTile extends StatelessWidget {
final Set<CollectionFilter> filters;
final void Function(Set<CollectionFilter>) onSelection;
const SettingsCollectionTile({
super.key,
required this.filters,
required this.onSelection,
});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
final textTheme = theme.textTheme;
final hasSubtitle = filters.isEmpty;
// size and padding to match `ListTile`
return ConstrainedBox(
constraints: BoxConstraints(
minHeight: (hasSubtitle ? 72.0 : 56.0) + theme.visualDensity.baseSizeAdjustment.dy,
),
child: Center(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.collectionPageTitle,
style: textTheme.subtitle1!,
),
if (hasSubtitle)
Text(
l10n.drawerCollectionAll,
style: textTheme.bodyText2!.copyWith(color: textTheme.caption!.color),
),
],
),
const Spacer(),
IconButton(
onPressed: () async {
final selection = await IntentService.pickCollectionFilters(filters);
if (selection != null) {
onSelection(selection);
}
},
icon: const Icon(AIcons.edit),
),
],
),
),
if (filters.isNotEmpty) FilterBar(filters: filters),
],
),
),
);
}
}

View file

@ -0,0 +1,233 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/enums/widget_shape.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/widget_service.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/common/identity/buttons.dart';
import 'package:aves/widgets/home_widget.dart';
import 'package:aves/widgets/settings/common/collection_tile.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class HomeWidgetSettingsPage extends StatefulWidget {
static const routeName = '/settings/home_widget';
final int widgetId;
const HomeWidgetSettingsPage({
super.key,
required this.widgetId,
});
@override
State<HomeWidgetSettingsPage> createState() => _HomeWidgetSettingsPageState();
}
class _HomeWidgetSettingsPageState extends State<HomeWidgetSettingsPage> {
late Color? _outline;
late WidgetShape _shape;
late Set<CollectionFilter> _collectionFilters;
int get widgetId => widget.widgetId;
static const gradient = HomeWidgetPainter.backgroundGradient;
static final deselectedGradient = LinearGradient(
begin: gradient.begin,
end: gradient.end,
colors: gradient.colors.map((v) {
final l = (v.computeLuminance() * 0xFF).toInt();
return Color.fromARGB(0xFF, l, l, l);
}).toList(),
stops: gradient.stops,
tileMode: gradient.tileMode,
transform: gradient.transform,
);
@override
void initState() {
super.initState();
_outline = settings.getWidgetOutline(widgetId);
_shape = settings.getWidgetShape(widgetId);
_collectionFilters = settings.getWidgetCollectionFilters(widgetId);
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(
title: Text(l10n.settingsWidgetPageTitle),
),
body: SafeArea(
child: Column(
children: [
Expanded(
child: ListView(
children: [
_buildShapeSelector(),
ListTile(
title: Text(l10n.settingsWidgetShowOutline),
trailing: HomeWidgetOutlineSelector(
getter: () => _outline,
setter: (v) => setState(() => _outline = v),
),
),
SettingsCollectionTile(
filters: _collectionFilters,
onSelection: (v) => setState(() => _collectionFilters = v),
),
],
),
),
const Divider(height: 0),
Padding(
padding: const EdgeInsets.all(8),
child: AvesOutlinedButton(
label: l10n.saveTooltip,
onPressed: () {
_saveSettings();
WidgetService.configure();
},
),
),
],
),
),
);
}
Widget _buildShapeSelector() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
child: Wrap(
spacing: 16,
runSpacing: 16,
children: WidgetShape.values.map((shape) {
final selected = shape == _shape;
final duration = context.read<DurationsData>().formTransition;
return GestureDetector(
onTap: () => setState(() => _shape = shape),
child: AnimatedOpacity(
duration: duration,
opacity: selected ? 1.0 : .4,
child: AnimatedContainer(
duration: duration,
width: 96,
height: 124,
decoration: ShapeDecoration(
gradient: selected ? gradient : deselectedGradient,
shape: _WidgetShapeBorder(_outline, shape),
),
),
),
);
}).toList(),
),
);
}
void _saveSettings() {
settings.setWidgetOutline(widgetId, _outline);
settings.setWidgetShape(widgetId, _shape);
if (!const SetEquality().equals(_collectionFilters, settings.getWidgetCollectionFilters(widgetId))) {
settings.setWidgetCollectionFilters(widgetId, _collectionFilters);
settings.setWidgetUri(widgetId, null);
}
}
}
class _WidgetShapeBorder extends ShapeBorder {
final Color? outline;
final WidgetShape shape;
static const _devicePixelRatio = 1.0;
const _WidgetShapeBorder(this.outline, this.shape);
@override
EdgeInsetsGeometry get dimensions => EdgeInsets.zero;
@override
Path getInnerPath(Rect rect, {TextDirection? textDirection}) {
return getOuterPath(rect, textDirection: textDirection);
}
@override
Path getOuterPath(Rect rect, {TextDirection? textDirection}) {
return shape.path(rect.size, _devicePixelRatio);
}
@override
void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {
if (outline != null) {
final path = shape.path(rect.size, _devicePixelRatio);
canvas.clipPath(path);
HomeWidgetPainter.drawOutline(canvas, path, _devicePixelRatio, outline!);
}
}
@override
ShapeBorder scale(double t) => this;
}
class HomeWidgetOutlineSelector extends StatefulWidget {
final ValueGetter<Color?> getter;
final ValueSetter<Color?> setter;
const HomeWidgetOutlineSelector({
super.key,
required this.getter,
required this.setter,
});
@override
State<HomeWidgetOutlineSelector> createState() => _HomeWidgetOutlineSelectorState();
}
class _HomeWidgetOutlineSelectorState extends State<HomeWidgetOutlineSelector> {
static const radius = Constants.colorPickerRadius;
static const List<Color?> options = [
null,
Colors.black,
Colors.white,
];
@override
Widget build(BuildContext context) {
return DropdownButtonHideUnderline(
child: DropdownButton<Color?>(
items: _buildItems(context),
value: widget.getter(),
onChanged: (selected) {
widget.setter(selected);
setState(() {});
},
),
);
}
List<DropdownMenuItem<Color?>> _buildItems(BuildContext context) {
return options.map((selected) {
return DropdownMenuItem<Color?>(
value: selected,
child: Container(
height: radius * 2,
width: radius * 2,
decoration: BoxDecoration(
color: selected,
border: AvesBorder.border(context),
shape: BoxShape.circle,
),
child: selected == null ? const Icon(AIcons.clear) : null,
),
);
}).toList();
}
}

View file

@ -4,10 +4,8 @@ import 'package:aves/model/settings/enums/slideshow_interval.dart';
import 'package:aves/model/settings/enums/slideshow_video_playback.dart';
import 'package:aves/model/settings/enums/viewer_transition.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/intent_service.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/filter_bar.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/settings/common/collection_tile.dart';
import 'package:aves/widgets/settings/common/tiles.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -22,7 +20,7 @@ class ScreenSaverSettingsPage extends StatelessWidget {
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(
title: Text(l10n.settingsScreenSaverTitle),
title: Text(l10n.settingsScreenSaverPageTitle),
),
body: SafeArea(
child: ListView(
@ -59,53 +57,9 @@ class ScreenSaverSettingsPage extends StatelessWidget {
Selector<Settings, Set<CollectionFilter>>(
selector: (context, s) => s.screenSaverCollectionFilters,
builder: (context, filters, child) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
final hasSubtitle = filters.isEmpty;
// size and padding to match `ListTile`
return ConstrainedBox(
constraints: BoxConstraints(
minHeight: (hasSubtitle ? 72.0 : 56.0) + theme.visualDensity.baseSizeAdjustment.dy,
),
child: Center(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.collectionPageTitle,
style: textTheme.subtitle1!,
),
if (hasSubtitle)
Text(
l10n.drawerCollectionAll,
style: textTheme.bodyText2!.copyWith(color: textTheme.caption!.color),
),
],
),
const Spacer(),
IconButton(
onPressed: () async {
final selection = await IntentService.pickCollectionFilters(filters);
if (selection != null) {
settings.screenSaverCollectionFilters = selection;
}
},
icon: const Icon(AIcons.edit),
),
],
),
),
if (filters.isNotEmpty) FilterBar(filters: filters),
],
),
),
return SettingsCollectionTile(
filters: filters,
onSelection: (v) => settings.screenSaverCollectionFilters = v,
);
},
),

View file

@ -1,4 +1,5 @@
import 'package:aves/model/settings/settings.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/basic/outlined_text.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/borders.dart';
@ -26,11 +27,7 @@ class SubtitleSample extends StatelessWidget {
gradient: const LinearGradient(
begin: Alignment.bottomLeft,
end: Alignment.topRight,
colors: [
// Bora Bora
Color(0xff2bc0e4),
Color(0xffeaecc6),
],
colors: Constants.boraBoraGradientColors,
),
border: AvesBorder.border(context),
borderRadius: const BorderRadius.all(Radius.circular(24)),

View file

@ -1,5 +1,6 @@
import 'package:aves/model/settings/enums/entry_background.dart';
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
import 'package:flutter/material.dart';
@ -19,6 +20,8 @@ class EntryBackgroundSelector extends StatefulWidget {
}
class _EntryBackgroundSelectorState extends State<EntryBackgroundSelector> {
static const radius = Constants.colorPickerRadius;
@override
Widget build(BuildContext context) {
return DropdownButtonHideUnderline(
@ -36,7 +39,6 @@ class _EntryBackgroundSelectorState extends State<EntryBackgroundSelector> {
}
List<DropdownMenuItem<EntryBackground>> _buildItems(BuildContext context) {
const radius = 12.0;
return [
EntryBackground.white,
EntryBackground.black,

View file

@ -14,7 +14,7 @@ import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart';
import 'package:aves/services/media/media_file_service.dart';
import 'package:aves/services/media/media_edit_service.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/aves_app.dart';
import 'package:aves/widgets/collection/collection_page.dart';
@ -246,7 +246,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
source.pauseMonitoring();
await showOpReport<ExportOpEvent>(
context: context,
opStream: mediaFileService.export(
opStream: mediaEditService.export(
selection,
options: options,
destinationAlbum: destinationAlbum,
@ -336,7 +336,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
MaterialPageRoute(
settings: const RouteSettings(name: SourceViewerPage.routeName),
builder: (context) => SourceViewerPage(
loader: () => mediaFileService.getSvg(entry.uri, entry.mimeType).then(utf8.decode),
loader: () => mediaFetchService.getSvg(entry.uri, entry.mimeType).then(utf8.decode),
),
),
);

View file

@ -74,7 +74,7 @@ class EntryPrinter with FeedbackMixin {
Future<pdf.Widget?> _buildPageImage(AvesEntry entry) async {
if (entry.isSvg) {
final bytes = await mediaFileService.getSvg(entry.uri, entry.mimeType);
final bytes = await mediaFetchService.getSvg(entry.uri, entry.mimeType);
if (bytes.isNotEmpty) {
return pdf.SvgImage(svg: utf8.decode(bytes));
}

View file

@ -1,7 +1,12 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_images.dart';
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/home_widget.dart';
import 'package:aves/widgets/viewer/debug/db.dart';
import 'package:aves/widgets/viewer/debug/metadata.dart';
import 'package:aves/widgets/viewer/info/common.dart';
@ -27,6 +32,7 @@ class ViewerDebugPage extends StatelessWidget {
if (context.select<ValueNotifier<AppMode>, bool>((vn) => vn.value != AppMode.view)) Tuple2(const Tab(text: 'DB'), DbTab(entry: entry)),
Tuple2(const Tab(icon: Icon(AIcons.android)), MetadataTab(entry: entry)),
Tuple2(const Tab(icon: Icon(AIcons.image)), _buildThumbnailsTabView()),
Tuple2(const Tab(icon: Icon(AIcons.addShortcut)), _buildWidgetTabView()),
];
return Directionality(
textDirection: TextDirection.ltr,
@ -155,7 +161,7 @@ class ViewerDebugPage extends StatelessWidget {
padding: const EdgeInsets.all(16),
children: entry.cachedThumbnails
.expand((provider) => [
Text('Extent: ${provider.key.extent}'),
Text('Thumb extent: ${provider.key.extent}'),
Center(
child: Image(
image: provider,
@ -177,4 +183,32 @@ class ViewerDebugPage extends StatelessWidget {
.toList(),
);
}
Widget _buildWidgetTabView() {
return ListView(
padding: const EdgeInsets.all(16),
children: [303, 636, 972, 1305]
.expand((heightPx) => [
Text('Widget heightPx: $heightPx'),
FutureBuilder<Uint8List>(
future: HomeWidgetPainter(
entry: entry,
devicePixelRatio: ui.window.devicePixelRatio,
).drawWidget(
widthPx: 978,
heightPx: heightPx,
outline: Colors.amber,
shape: WidgetShape.heart,
format: ui.ImageByteFormat.png,
),
builder: (context, snapshot) {
final bytes = snapshot.data;
if (bytes == null) return const SizedBox();
return Image.memory(bytes);
},
),
])
.toList(),
);
}
}

View file

@ -98,7 +98,7 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
}
};
final newFields = await mediaFileService.captureFrame(
final newFields = await mediaEditService.captureFrame(
entry,
desiredName: '${entry.bestTitle}_${'$positionMillis'.padLeft(8, '0')}',
exif: exif,

View file

@ -1,31 +0,0 @@
import 'package:aves/model/entry.dart';
import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/media/media_file_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'media_store_service.dart';
class FakeMediaFileService extends Fake implements MediaFileService {
@override
Stream<MoveOpEvent> rename({
String? opId,
required Map<AvesEntry, String> entriesToNewName,
}) {
final contentId = FakeMediaStoreService.nextId;
final kv = entriesToNewName.entries.first;
final entry = kv.key;
final newName = kv.value;
return Stream.value(MoveOpEvent(
success: true,
skipped: false,
uri: entry.uri,
newFields: {
'uri': 'content://media/external/images/media/$contentId',
'contentId': contentId,
'path': '${entry.directory}/$newName',
'dateModifiedSecs': FakeMediaStoreService.dateSecs,
},
deleted: false,
));
}
}

View file

@ -15,7 +15,6 @@ import 'package:aves/model/source/media_store_source.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/device_service.dart';
import 'package:aves/services/media/media_file_service.dart';
import 'package:aves/services/media/media_store_service.dart';
import 'package:aves/services/metadata/metadata_fetch_service.dart';
import 'package:aves/services/storage_service.dart';
@ -30,7 +29,6 @@ import 'package:path/path.dart' as p;
import '../fake/android_app_service.dart';
import '../fake/availability.dart';
import '../fake/device_service.dart';
import '../fake/media_file_service.dart';
import '../fake/media_store_service.dart';
import '../fake/metadata_db.dart';
import '../fake/metadata_fetch_service.dart';
@ -59,7 +57,6 @@ void main() {
getIt.registerLazySingleton<AndroidAppService>(FakeAndroidAppService.new);
getIt.registerLazySingleton<DeviceService>(FakeDeviceService.new);
getIt.registerLazySingleton<MediaFileService>(FakeMediaFileService.new);
getIt.registerLazySingleton<MediaStoreService>(FakeMediaStoreService.new);
getIt.registerLazySingleton<MetadataFetchService>(FakeMetadataFetchService.new);
getIt.registerLazySingleton<ReportService>(FakeReportService.new);

View file

@ -2,55 +2,73 @@
"de": [
"filterOnThisDayLabel",
"settingsSlideshowFillScreen",
"settingsScreenSaverTitle"
"settingsScreenSaverPageTitle",
"settingsWidgetPageTitle",
"settingsWidgetShowOutline"
],
"es": [
"filterOnThisDayLabel",
"settingsSlideshowFillScreen",
"settingsScreenSaverTitle"
"settingsScreenSaverPageTitle",
"settingsWidgetPageTitle",
"settingsWidgetShowOutline"
],
"fr": [
"filterOnThisDayLabel",
"settingsSlideshowFillScreen",
"settingsScreenSaverTitle"
"settingsScreenSaverPageTitle",
"settingsWidgetPageTitle",
"settingsWidgetShowOutline"
],
"id": [
"filterOnThisDayLabel",
"settingsSlideshowFillScreen",
"settingsScreenSaverTitle"
"settingsScreenSaverPageTitle",
"settingsWidgetPageTitle",
"settingsWidgetShowOutline"
],
"it": [
"filterOnThisDayLabel",
"settingsSlideshowFillScreen",
"settingsScreenSaverTitle"
"settingsScreenSaverPageTitle",
"settingsWidgetPageTitle",
"settingsWidgetShowOutline"
],
"ja": [
"filterOnThisDayLabel",
"settingsSlideshowFillScreen",
"settingsScreenSaverTitle"
"settingsScreenSaverPageTitle",
"settingsWidgetPageTitle",
"settingsWidgetShowOutline"
],
"ko": [
"filterOnThisDayLabel",
"settingsSlideshowFillScreen",
"settingsScreenSaverTitle"
"settingsScreenSaverPageTitle",
"settingsWidgetPageTitle",
"settingsWidgetShowOutline"
],
"pt": [
"filterOnThisDayLabel",
"settingsSlideshowFillScreen",
"settingsScreenSaverTitle"
"settingsScreenSaverPageTitle",
"settingsWidgetPageTitle",
"settingsWidgetShowOutline"
],
"ru": [
"filterOnThisDayLabel",
"settingsSlideshowFillScreen",
"settingsScreenSaverTitle"
"settingsScreenSaverPageTitle",
"settingsWidgetPageTitle",
"settingsWidgetShowOutline"
],
"tr": [
@ -79,13 +97,17 @@
"settingsSlideshowIntervalTitle",
"settingsSlideshowVideoPlaybackTile",
"settingsSlideshowVideoPlaybackTitle",
"settingsScreenSaverTitle",
"settingsScreenSaverPageTitle",
"settingsWidgetPageTitle",
"settingsWidgetShowOutline",
"viewerSetWallpaperButtonLabel"
],
"zh": [
"filterOnThisDayLabel",
"settingsSlideshowFillScreen",
"settingsScreenSaverTitle"
"settingsScreenSaverPageTitle",
"settingsWidgetPageTitle",
"settingsWidgetShowOutline"
]
}