mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-23 12:14:07 +00:00
fixing multiple issues within the widget
This commit is contained in:
parent
deb712f21d
commit
a44e6347c9
115 changed files with 6929 additions and 640 deletions
|
|
@ -86,4 +86,6 @@ dependencies {
|
|||
implementation 'androidx.media3:media3-effect:1.11.0'
|
||||
implementation 'androidx.media3:media3-common:1.11.0'
|
||||
implementation 'androidx.core:core-splashscreen:1.0.1'
|
||||
// Material 3 components for the native widget configuration screen.
|
||||
implementation 'com.google.android.material:material:1.13.0'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,20 @@
|
|||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name=".widget.TwonlyWidgetProvider"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/twonly_widget_info" />
|
||||
</receiver>
|
||||
<activity
|
||||
android:name=".widget.TwonlyWidgetConfigureActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Twonly.WidgetConfigure" />
|
||||
<meta-data
|
||||
android:name="eu.twonly.service.TWONLY_LOGO"
|
||||
android:resource="@drawable/ic_launcher_foreground" />
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import androidx.activity.result.ActivityResultLauncher
|
|||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import eu.twonly.notifications.NotificationTapChannel
|
||||
import eu.twonly.widget.WidgetRuntimeChannel
|
||||
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
private val CHANNEL = "eu.twonly/photo_picker"
|
||||
|
|
@ -70,6 +71,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|||
|
||||
|
||||
NotificationTapChannel.configure(flutterEngine, applicationContext)
|
||||
WidgetRuntimeChannel.configure(flutterEngine, applicationContext)
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
|
|
@ -104,6 +106,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|||
|
||||
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
NotificationTapChannel.detach()
|
||||
WidgetRuntimeChannel.detach()
|
||||
super.cleanUpFlutterEngine(flutterEngine)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ internal object NativeNotificationBridge {
|
|||
@JvmStatic
|
||||
external fun acknowledge(eventIdsJson: String): String
|
||||
|
||||
@JvmStatic
|
||||
external fun finalizeWakeup(deadlineMs: Long): String
|
||||
|
||||
@JvmStatic
|
||||
external fun storeFcmToken(
|
||||
databaseDirectory: String,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ internal data class NativeNotificationBatch(
|
|||
|
||||
internal data class NativeNotificationResponse(
|
||||
val ok: Boolean,
|
||||
val widgetRefresh: Boolean,
|
||||
val batch: NativeNotificationBatch?,
|
||||
val fallback: NativeNotificationPresentation?,
|
||||
) {
|
||||
|
|
@ -70,7 +71,12 @@ internal data class NativeNotificationResponse(
|
|||
body = it.getString("body"),
|
||||
)
|
||||
}
|
||||
return NativeNotificationResponse(root.optBoolean("ok"), batch, fallback)
|
||||
return NativeNotificationResponse(
|
||||
root.optBoolean("ok"),
|
||||
root.optBoolean("widget_refresh"),
|
||||
batch,
|
||||
fallback,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import androidx.work.Worker
|
|||
import androidx.work.WorkerParameters
|
||||
import eu.twonly.MainActivity
|
||||
import eu.twonly.R
|
||||
import eu.twonly.widget.TwonlyWidgetProvider
|
||||
import java.util.Locale
|
||||
import org.json.JSONArray
|
||||
|
||||
|
|
@ -26,16 +27,17 @@ class TwonlyNotificationWorker(
|
|||
directory,
|
||||
directory,
|
||||
Locale.getDefault().toLanguageTag(),
|
||||
RUST_DEADLINE_MS,
|
||||
PROCESS_DEADLINE_MS,
|
||||
),
|
||||
)
|
||||
if (response.widgetRefresh) TwonlyWidgetProvider.refreshAll(applicationContext)
|
||||
ensureNotificationChannel(applicationContext)
|
||||
val batch = response.batch
|
||||
if (!response.ok || batch == null) {
|
||||
// Rust could not reach the mailbox. Show the generic alert so a
|
||||
// high-priority wake-up still produces a notification, and retry.
|
||||
response.fallback?.let(::showFallback)
|
||||
return if (runAttemptCount < MAX_RETRIES) Result.retry() else Result.success()
|
||||
return settle(retry = true)
|
||||
}
|
||||
|
||||
val manager = NotificationManagerCompat.from(applicationContext)
|
||||
|
|
@ -52,17 +54,31 @@ class TwonlyNotificationWorker(
|
|||
// An empty batch is the normal outcome of a duplicate wake-up or of
|
||||
// traffic that is not user visible, so it must not retry. Only an
|
||||
// undrained mailbox is worth another attempt.
|
||||
if (!batch.completed && runAttemptCount < MAX_RETRIES) {
|
||||
Result.retry()
|
||||
} else {
|
||||
Result.success()
|
||||
}
|
||||
settle(retry = !batch.completed)
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "Native notification processing failed", error)
|
||||
if (runAttemptCount < MAX_RETRIES) Result.retry() else Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
// Ends the attempt, running the deferred wake-up work first when no further
|
||||
// attempt is coming. The notification is already on screen by then, so media
|
||||
// downloads, widget upkeep, and the socket shutdown no longer sit between
|
||||
// the message and the alert. A retry skips it and keeps the connection for
|
||||
// the next attempt.
|
||||
private fun settle(retry: Boolean): Result {
|
||||
if (retry && runAttemptCount < MAX_RETRIES) return Result.retry()
|
||||
try {
|
||||
val finalized = NativeNotificationResponse.parse(
|
||||
NativeNotificationBridge.finalizeWakeup(FINALIZE_DEADLINE_MS),
|
||||
)
|
||||
if (finalized.widgetRefresh) TwonlyWidgetProvider.refreshAll(applicationContext)
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "Deferred notification maintenance failed", error)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun showAddition(
|
||||
manager: NotificationManagerCompat,
|
||||
addition: NativeNotificationAddition,
|
||||
|
|
@ -122,6 +138,10 @@ class TwonlyNotificationWorker(
|
|||
const val TAG = "TwonlyNotification"
|
||||
const val FALLBACK_ID = 0x74776F
|
||||
const val MAX_RETRIES = 2
|
||||
const val RUST_DEADLINE_MS = 25_000L
|
||||
|
||||
// Split from the old single 25s budget: the drain only has to produce
|
||||
// the batch, and everything deferred behind it gets its own window.
|
||||
const val PROCESS_DEADLINE_MS = 20_000L
|
||||
const val FINALIZE_DEADLINE_MS = 25_000L
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
package eu.twonly.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import com.google.android.material.checkbox.MaterialCheckBox
|
||||
import eu.twonly.R
|
||||
import java.io.File
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class TwonlyWidgetConfigureActivity : AppCompatActivity() {
|
||||
private var widgetId = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setResult(RESULT_CANCELED)
|
||||
widgetId = intent?.getIntExtra(
|
||||
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||
AppWidgetManager.INVALID_APPWIDGET_ID,
|
||||
) ?: AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
setContentView(R.layout.twonly_widget_configure)
|
||||
applySystemBarInsets()
|
||||
|
||||
findViewById<MaterialToolbar>(R.id.toolbar).setNavigationOnClickListener { finish() }
|
||||
|
||||
val groupCard = findViewById<MaterialCardView>(R.id.group_card)
|
||||
val groupList = findViewById<LinearLayout>(R.id.group_list)
|
||||
val emptyState = findViewById<View>(R.id.empty_state)
|
||||
val saveButton = findViewById<MaterialButton>(R.id.save_button)
|
||||
|
||||
val selected = TwonlyWidgetProvider.preferences(this)
|
||||
.getStringSet(TwonlyWidgetProvider.groupsKey(widgetId), emptySet())
|
||||
.orEmpty()
|
||||
.toMutableSet()
|
||||
val groups = runCatching {
|
||||
JSONObject(File(filesDir, "widget/manifest.json").readText()).getJSONArray("groups")
|
||||
}.getOrNull()
|
||||
|
||||
if (groups == null || groups.length() == 0) {
|
||||
groupCard.visibility = View.GONE
|
||||
emptyState.visibility = View.VISIBLE
|
||||
saveButton.isEnabled = false
|
||||
} else {
|
||||
val inflater = LayoutInflater.from(this)
|
||||
for (index in 0 until groups.length()) {
|
||||
val group = groups.getJSONObject(index)
|
||||
val id = group.getLong("id").toString()
|
||||
val checkBox = inflater.inflate(
|
||||
R.layout.twonly_widget_configure_group,
|
||||
groupList,
|
||||
false,
|
||||
) as MaterialCheckBox
|
||||
checkBox.text = group.getString("name")
|
||||
checkBox.isChecked = selected.contains(id)
|
||||
checkBox.setOnCheckedChangeListener { _, checked ->
|
||||
if (checked) selected.add(id) else selected.remove(id)
|
||||
saveButton.isEnabled = selected.isNotEmpty()
|
||||
}
|
||||
groupList.addView(checkBox)
|
||||
}
|
||||
saveButton.isEnabled = selected.isNotEmpty()
|
||||
}
|
||||
|
||||
saveButton.setOnClickListener {
|
||||
TwonlyWidgetProvider.preferences(this)
|
||||
.edit().putStringSet(TwonlyWidgetProvider.groupsKey(widgetId), selected).apply()
|
||||
persistNativeConfiguration(this)
|
||||
TwonlyWidgetProvider.update(
|
||||
this,
|
||||
AppWidgetManager.getInstance(this),
|
||||
widgetId,
|
||||
advance = false,
|
||||
)
|
||||
setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId))
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The window draws edge to edge, so the system bars are kept clear by the
|
||||
* views that touch them: the top app bar and the bottom action bar.
|
||||
*/
|
||||
private fun applySystemBarInsets() {
|
||||
val root = findViewById<ViewGroup>(R.id.root)
|
||||
val appBar = findViewById<AppBarLayout>(R.id.app_bar)
|
||||
val actionBar = findViewById<View>(R.id.action_bar)
|
||||
val appBarPaddingTop = appBar.paddingTop
|
||||
val actionBarPaddingBottom = actionBar.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root) { target, windowInsets ->
|
||||
val bars = windowInsets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
|
||||
)
|
||||
target.setPadding(bars.left, 0, bars.right, 0)
|
||||
appBar.updatePaddingTop(appBarPaddingTop + bars.top)
|
||||
actionBar.updatePaddingBottom(actionBarPaddingBottom + bars.bottom)
|
||||
windowInsets
|
||||
}
|
||||
ViewCompat.requestApplyInsets(root)
|
||||
}
|
||||
|
||||
private fun View.updatePaddingTop(top: Int) =
|
||||
setPadding(paddingLeft, top, paddingRight, paddingBottom)
|
||||
|
||||
private fun View.updatePaddingBottom(bottom: Int) =
|
||||
setPadding(paddingLeft, paddingTop, paddingRight, bottom)
|
||||
|
||||
companion object {
|
||||
fun persistNativeConfiguration(context: Context) {
|
||||
val manager = AppWidgetManager.getInstance(context)
|
||||
val ids = manager.getAppWidgetIds(android.content.ComponentName(context, TwonlyWidgetProvider::class.java))
|
||||
val widgets = JSONArray()
|
||||
val preferences = TwonlyWidgetProvider.preferences(context)
|
||||
ids.forEach { id ->
|
||||
val groups = JSONArray()
|
||||
preferences.getStringSet(TwonlyWidgetProvider.groupsKey(id), emptySet()).orEmpty()
|
||||
.mapNotNull(String::toLongOrNull).forEach(groups::put)
|
||||
widgets.put(JSONObject().put("id", "android:$id").put("platform", "android").put("group_ids", groups))
|
||||
}
|
||||
val directory = File(context.filesDir, "widget").apply { mkdirs() }
|
||||
val temporary = File(directory, "native-config.json.tmp")
|
||||
temporary.writeText(JSONObject().put("widgets", widgets).toString())
|
||||
temporary.renameTo(File(directory, "native-config.json"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
package eu.twonly.widget
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import eu.twonly.R
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
|
||||
class TwonlyWidgetProvider : AppWidgetProvider() {
|
||||
override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) {
|
||||
ids.forEach { update(context, manager, it, advance = true) }
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
super.onReceive(context, intent)
|
||||
if (intent.action != ACTION_ADVANCE) return
|
||||
val widgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
|
||||
if (widgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||
update(context, AppWidgetManager.getInstance(context), widgetId, advance = true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||
val preferences = preferences(context)
|
||||
appWidgetIds.forEach { id ->
|
||||
preferences.edit().remove(groupsKey(id)).remove(indexKey(id)).remove(newestKey(id)).apply()
|
||||
}
|
||||
TwonlyWidgetConfigureActivity.persistNativeConfiguration(context)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_ADVANCE = "eu.twonly.widget.ADVANCE"
|
||||
private const val PREFERENCES = "twonly_home_widgets"
|
||||
|
||||
fun preferences(context: Context) = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
fun groupsKey(widgetId: Int) = "groups_$widgetId"
|
||||
private fun indexKey(widgetId: Int) = "index_$widgetId"
|
||||
|
||||
/** The newest image this widget has already drawn. */
|
||||
private fun newestKey(widgetId: Int) = "newest_$widgetId"
|
||||
|
||||
fun refreshAll(context: Context) {
|
||||
val manager = AppWidgetManager.getInstance(context)
|
||||
val ids = manager.getAppWidgetIds(ComponentName(context, TwonlyWidgetProvider::class.java))
|
||||
ids.forEach { update(context, manager, it, advance = false) }
|
||||
}
|
||||
|
||||
fun update(context: Context, manager: AppWidgetManager, widgetId: Int, advance: Boolean) {
|
||||
val views = RemoteViews(context.packageName, R.layout.twonly_widget)
|
||||
val selected = preferences(context).getStringSet(groupsKey(widgetId), emptySet()).orEmpty()
|
||||
val manifestFile = File(context.filesDir, "widget/manifest.json")
|
||||
val matching = runCatching {
|
||||
val images = JSONObject(manifestFile.readText()).getJSONArray("images")
|
||||
buildList {
|
||||
for (index in 0 until images.length()) {
|
||||
val image = images.getJSONObject(index)
|
||||
val groups = image.getJSONArray("group_ids")
|
||||
val visible = (0 until groups.length()).any { selected.contains(groups.getLong(it).toString()) }
|
||||
if (visible && image.optLong("expires_at") > System.currentTimeMillis() / 1000) add(image)
|
||||
}
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
if (matching.isEmpty()) {
|
||||
views.setImageViewResource(R.id.twonly_widget_image, R.drawable.logo)
|
||||
views.setInt(R.id.twonly_widget_image, "setImageAlpha", 110)
|
||||
views.setViewVisibility(R.id.twonly_widget_sender, View.GONE)
|
||||
} else {
|
||||
// The manifest is newest first, so an arriving image is prepended
|
||||
// and the stored index keeps pointing at an older one: a redraw
|
||||
// alone would never show what just came in. Remembering which
|
||||
// image was newest last time is what separates an arrival from
|
||||
// every other reason this widget is asked to redraw, and an
|
||||
// arrival outranks the rotation — including a tap that has not
|
||||
// seen the new image yet.
|
||||
val preferences = preferences(context)
|
||||
val newest = matching[0].optString("media_id")
|
||||
val hasArrived = preferences.getString(newestKey(widgetId), null) != newest
|
||||
val oldIndex = preferences.getInt(indexKey(widgetId), -1)
|
||||
val index = when {
|
||||
hasArrived -> 0
|
||||
advance -> (oldIndex + 1).mod(matching.size)
|
||||
else -> oldIndex.coerceAtLeast(0).mod(matching.size)
|
||||
}
|
||||
preferences.edit().putInt(indexKey(widgetId), index).putString(newestKey(widgetId), newest).apply()
|
||||
val image = matching[index]
|
||||
val bitmap = BitmapFactory.decodeFile(image.getString("path"))
|
||||
if (bitmap == null) {
|
||||
views.setImageViewResource(R.id.twonly_widget_image, R.drawable.logo)
|
||||
views.setInt(R.id.twonly_widget_image, "setImageAlpha", 110)
|
||||
views.setViewVisibility(R.id.twonly_widget_sender, View.GONE)
|
||||
} else {
|
||||
views.setImageViewBitmap(R.id.twonly_widget_image, bitmap)
|
||||
views.setInt(R.id.twonly_widget_image, "setImageAlpha", 255)
|
||||
views.setTextViewText(R.id.twonly_widget_sender, image.optString("sender"))
|
||||
views.setViewVisibility(R.id.twonly_widget_sender, View.VISIBLE)
|
||||
}
|
||||
}
|
||||
|
||||
val intent = Intent(context, TwonlyWidgetProvider::class.java).apply {
|
||||
action = ACTION_ADVANCE
|
||||
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
|
||||
}
|
||||
views.setOnClickPendingIntent(
|
||||
R.id.twonly_widget_root,
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
widgetId,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
),
|
||||
)
|
||||
manager.updateAppWidget(widgetId, views)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package eu.twonly.widget
|
||||
|
||||
import android.content.Context
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* Lets the running app redraw its home-screen widgets.
|
||||
*
|
||||
* Rust rewrites the widget manifest from inside this process, but an AppWidget
|
||||
* only redraws when its provider is asked to, so nothing on the home screen
|
||||
* changes until this runs. The notification worker covers pushes that arrive
|
||||
* while the app is gone; this covers everything the running app changes.
|
||||
*
|
||||
* Shares its name with the iOS channel of the same purpose, so Dart talks to
|
||||
* one channel on both platforms.
|
||||
*/
|
||||
object WidgetRuntimeChannel {
|
||||
private const val CHANNEL = "eu.twonly/runtime_storage"
|
||||
|
||||
private var channel: MethodChannel? = null
|
||||
|
||||
fun configure(flutterEngine: FlutterEngine, context: Context) {
|
||||
val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
||||
channel.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"reloadWidgets" -> {
|
||||
TwonlyWidgetProvider.refreshAll(context.applicationContext)
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
this.channel = channel
|
||||
}
|
||||
|
||||
fun detach() {
|
||||
channel?.setMethodCallHandler(null)
|
||||
channel = null
|
||||
}
|
||||
}
|
||||
10
android/app/src/main/res/drawable/ic_close_24.xml
Normal file
10
android/app/src/main/res/drawable/ic_close_24.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorOnSurfaceVariant">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
|
||||
</vector>
|
||||
10
android/app/src/main/res/drawable/ic_group_24.xml
Normal file
10
android/app/src/main/res/drawable/ic_group_24.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorOnSurfaceVariant">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M16,11c1.66,0 2.99,-1.34 2.99,-3S17.66,5 16,5c-1.66,0 -3,1.34 -3,3s1.34,3 3,3zM8,11c1.66,0 2.99,-1.34 2.99,-3S9.66,5 8,5C6.34,5 5,6.34 5,8s1.34,3 3,3zM8,13c-2.33,0 -7,1.17 -7,3.5L1,19h14v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5zM16,13c-0.29,0 -0.62,0.02 -0.97,0.05 1.16,0.84 1.97,1.97 1.97,3.45L17,19h6v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5z" />
|
||||
</vector>
|
||||
26
android/app/src/main/res/layout/twonly_widget.xml
Normal file
26
android/app/src/main/res/layout/twonly_widget.xml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/twonly_widget_root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/twonly_widget_image"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/widget_image_description"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/twonly_widget_sender"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_margin="8dp"
|
||||
android:background="#66000000"
|
||||
android:paddingHorizontal="6dp"
|
||||
android:paddingVertical="3dp"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
118
android/app/src/main/res/layout/twonly_widget_configure.xml
Normal file
118
android/app/src/main/res/layout/twonly_widget_configure.xml
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/colorSurface"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/app_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:liftOnScroll="true">
|
||||
|
||||
<com.google.android.material.appbar.CollapsingToolbarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="152dp"
|
||||
app:title="@string/widget_choose_groups"
|
||||
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap"
|
||||
style="@style/Widget.Material3.CollapsingToolbar.Large">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:layout_collapseMode="pin"
|
||||
app:navigationIcon="@drawable/ic_close_24"
|
||||
app:navigationContentDescription="@android:string/cancel" />
|
||||
</com.google.android.material.appbar.CollapsingToolbarLayout>
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:paddingBottom="16dp"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingBottom="16dp"
|
||||
android:text="@string/widget_choose_groups_description"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/group_card"
|
||||
style="@style/Widget.Material3.CardView.Filled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/group_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingVertical="8dp" />
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/empty_state"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingVertical="48dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:contentDescription="@null"
|
||||
android:src="@drawable/ic_group_24" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:paddingTop="16dp"
|
||||
android:text="@string/widget_open_app_first"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/action_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="16dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/save_button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="56dp"
|
||||
android:text="@string/widget_save"
|
||||
android:textAppearance="?attr/textAppearanceLabelLarge" />
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The Material checkbox drawable is 32dp wide with the 18dp box centred in
|
||||
it, so 7dp of the leading and 7dp of the trailing offset come from the
|
||||
drawable itself. The margin and padding below add to that: the box sits
|
||||
16dp inside the card and the label follows 16dp after it. -->
|
||||
<com.google.android.material.checkbox.MaterialCheckBox
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/group_checkbox"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="9dp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="56dp"
|
||||
android:paddingStart="9dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
9
android/app/src/main/res/values-de/strings.xml
Normal file
9
android/app/src/main/res/values-de/strings.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="widget_description">Fotos, die dir deine Freunde senden</string>
|
||||
<string name="widget_choose_groups">Kontaktgruppen auswählen</string>
|
||||
<string name="widget_choose_groups_description">Wähle aus, wer Fotos an dieses Widget senden darf.</string>
|
||||
<string name="widget_open_app_first">Öffne twonly einmal, um deine Kontaktgruppen zu laden.</string>
|
||||
<string name="widget_save">Widget hinzufügen</string>
|
||||
<string name="widget_image_description">Neuestes Foto in deinem Widget</string>
|
||||
</resources>
|
||||
9
android/app/src/main/res/values-night/colors.xml
Normal file
9
android/app/src/main/res/values-night/colors.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- The Flutter themes pin the same primary in light and dark, so the
|
||||
native widget configuration screen does too. -->
|
||||
<color name="brand_primary">#32BE80</color>
|
||||
<color name="brand_on_primary">#092016</color>
|
||||
<color name="brand_primary_container">#1C6947</color>
|
||||
<color name="brand_on_primary_container">#CFF2E2</color>
|
||||
</resources>
|
||||
|
|
@ -1,4 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FF57CC99</color>
|
||||
|
||||
<!-- Material 3 primary roles. colorPrimary is twonly's primary color as
|
||||
defined in lib/src/visual/themes/light.dart; the container tones are
|
||||
derived from it. -->
|
||||
<color name="brand_primary">#32BE80</color>
|
||||
<color name="brand_on_primary">#092016</color>
|
||||
<color name="brand_primary_container">#CFF2E2</color>
|
||||
<color name="brand_on_primary_container">#092016</color>
|
||||
</resources>
|
||||
9
android/app/src/main/res/values/strings.xml
Normal file
9
android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="widget_description">Photos your friends send you</string>
|
||||
<string name="widget_choose_groups">Choose contact groups</string>
|
||||
<string name="widget_choose_groups_description">Choose who may send photos to this widget.</string>
|
||||
<string name="widget_open_app_first">Open twonly once to load your contact groups.</string>
|
||||
<string name="widget_save">Add widget</string>
|
||||
<string name="widget_image_description">Latest photo in your widget</string>
|
||||
</resources>
|
||||
14
android/app/src/main/res/values/themes.xml
Normal file
14
android/app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme for the native app widget configuration screen. Follows the
|
||||
system light/dark setting; DynamicColors overrides the brand palette
|
||||
with the wallpaper colors where the platform supports it. -->
|
||||
<style name="Theme.Twonly.WidgetConfigure" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="colorPrimary">@color/brand_primary</item>
|
||||
<item name="colorOnPrimary">@color/brand_on_primary</item>
|
||||
<item name="colorPrimaryContainer">@color/brand_primary_container</item>
|
||||
<item name="colorOnPrimaryContainer">@color/brand_on_primary_container</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
11
android/app/src/main/res/xml/twonly_widget_info.xml
Normal file
11
android/app/src/main/res/xml/twonly_widget_info.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:configure="eu.twonly.widget.TwonlyWidgetConfigureActivity"
|
||||
android:description="@string/widget_description"
|
||||
android:initialLayout="@layout/twonly_widget"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:previewImage="@drawable/logo"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen" />
|
||||
|
|
@ -1,15 +1,28 @@
|
|||
import Foundation
|
||||
import Intents
|
||||
import UserNotifications
|
||||
import WidgetKit
|
||||
import rust_lib_twonly
|
||||
|
||||
private let runtimeAppGroup = "group.eu.twonly.runtime"
|
||||
|
||||
/// Budget for the work deferred behind the alert. Behind a rendered alert it
|
||||
/// runs after the content handler has fired, so the system may reclaim the
|
||||
/// extension before it ends; a wake-up with nothing to render spends it up
|
||||
/// front instead, where the whole budget is actually available.
|
||||
private let finalizeDeadlineMs: UInt64 = 5_000
|
||||
|
||||
private struct NativeNotificationResponse: Decodable {
|
||||
let ok: Bool
|
||||
let widgetRefresh: Bool
|
||||
let batch: NativeNotificationBatch?
|
||||
let fallback: NativeNotificationPresentation?
|
||||
let error: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case ok, batch, fallback, error
|
||||
case widgetRefresh = "widget_refresh"
|
||||
}
|
||||
}
|
||||
|
||||
private struct NativeNotificationPresentation: Decodable {
|
||||
|
|
@ -90,7 +103,15 @@ final class NotificationService: UNNotificationServiceExtension {
|
|||
)
|
||||
return
|
||||
}
|
||||
if response.widgetRefresh {
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
guard let batch = response.batch, !batch.additions.isEmpty else {
|
||||
// Nothing is waiting on screen for this wake-up — widget-only media is
|
||||
// the ordinary case — so the deferred work runs while the extension is
|
||||
// still guaranteed its time, rather than after the content handler has
|
||||
// made it eligible for termination.
|
||||
Self.finalizeRuntime()
|
||||
self.deliverFallback(reason: "notification worker returned no messages")
|
||||
return
|
||||
}
|
||||
|
|
@ -148,6 +169,10 @@ final class NotificationService: UNNotificationServiceExtension {
|
|||
stateLock.unlock()
|
||||
Self.acknowledge(eventIds: eventIds)
|
||||
self?.finish(with: content)
|
||||
// The alert is on screen. Whatever time the system still grants this
|
||||
// extension goes to the deferred work; being terminated part-way only
|
||||
// defers it to the next wake-up or app launch.
|
||||
Self.finalizeRuntime()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -268,7 +293,7 @@ final class NotificationService: UNNotificationServiceExtension {
|
|||
let pointer = runtimeDirectory.withCString { databaseDirectory in
|
||||
runtimeDirectory.withCString { dataDirectory in
|
||||
locale.withCString { locale in
|
||||
twonly_notification_process(databaseDirectory, dataDirectory, locale, 24_000)
|
||||
twonly_notification_process(databaseDirectory, dataDirectory, locale, 22_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -286,6 +311,24 @@ final class NotificationService: UNNotificationServiceExtension {
|
|||
}
|
||||
}
|
||||
|
||||
private static func finalizeRuntime() {
|
||||
guard let pointer = twonly_notification_finalize(finalizeDeadlineMs) else { return }
|
||||
defer { twonly_notification_string_free(pointer) }
|
||||
let json = String(cString: pointer)
|
||||
guard
|
||||
let response = try? JSONDecoder().decode(
|
||||
NativeNotificationResponse.self,
|
||||
from: Data(json.utf8)
|
||||
)
|
||||
else { return }
|
||||
if let error = response.error {
|
||||
NSLog("Deferred Twonly notification maintenance failed: \(error)")
|
||||
}
|
||||
if response.widgetRefresh {
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
|
||||
private static func acknowledge(eventIds: [String]) {
|
||||
guard !eventIds.isEmpty, let data = try? JSONEncoder().encode(eventIds),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@
|
|||
D21FCEAB2D9F2B750088701D /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D21FCEA42D9F2B750088701D /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
D25D4D1E2EF626E30029F805 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D25D4D1D2EF626E30029F805 /* StoreKit.framework */; };
|
||||
D25D4D7A2EFF41DB0029F805 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D25D4D702EFF41DB0029F805 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
D4A100022F81000100A10002 /* TwonlyWidgetShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A100012F81000100A10001 /* TwonlyWidgetShared.swift */; };
|
||||
D4A100032F81000100A10003 /* TwonlyWidgetShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A100012F81000100A10001 /* TwonlyWidgetShared.swift */; };
|
||||
D4A0000C2F80000100A0000C /* TwonlyWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D4A000012F80000100A00001 /* TwonlyWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
D4A000102F80000100A00010 /* logo.png in Resources */ = {isa = PBXBuildFile; fileRef = D4A0000F2F80000100A0000F /* logo.png */; };
|
||||
D3A100022F70000100D1A001 /* DirectMediaTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100012F70000100D1A001 /* DirectMediaTransfer.swift */; };
|
||||
D3A100062F70000100D1A006 /* BackgroundWork.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100052F70000100D1A005 /* BackgroundWork.swift */; };
|
||||
D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100042F70000100D1A002 /* NativeImageCodec.swift */; };
|
||||
|
|
@ -52,6 +56,13 @@
|
|||
remoteGlobalIDString = D25D4D6F2EFF41DB0029F805;
|
||||
remoteInfo = ShareExtension;
|
||||
};
|
||||
D4A0000D2F80000100A0000D /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = D4A000042F80000100A00004;
|
||||
remoteInfo = TwonlyWidget;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
|
|
@ -71,6 +82,7 @@
|
|||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
D4A0000C2F80000100A0000C /* TwonlyWidget.appex in Embed Foundation Extensions */,
|
||||
D25D4D7A2EFF41DB0029F805 /* ShareExtension.appex in Embed Foundation Extensions */,
|
||||
D21FCEAB2D9F2B750088701D /* NotificationService.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
|
|
@ -113,6 +125,9 @@
|
|||
D24E27CC2F38ABC10055D9D1 /* RunnerRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerRelease.entitlements; sourceTree = "<group>"; };
|
||||
D25D4D1D2EF626E30029F805 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
|
||||
D25D4D702EFF41DB0029F805 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D4A100012F81000100A10001 /* TwonlyWidgetShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwonlyWidgetShared.swift; sourceTree = "<group>"; };
|
||||
D4A000012F80000100A00001 /* TwonlyWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TwonlyWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D4A0000F2F80000100A0000F /* logo.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = logo.png; path = ../assets/images/logo.png; sourceTree = SOURCE_ROOT; };
|
||||
D25D4D802EFF437F0029F805 /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = "<group>"; };
|
||||
D3A100012F70000100D1A001 /* DirectMediaTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMediaTransfer.swift; sourceTree = "<group>"; };
|
||||
D3A100052F70000100D1A005 /* BackgroundWork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWork.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -141,6 +156,13 @@
|
|||
);
|
||||
target = D25D4D6F2EFF41DB0029F805 /* ShareExtension */;
|
||||
};
|
||||
D4A000032F80000100A00003 /* Exceptions for "TwonlyWidget" folder in "TwonlyWidget" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = D4A000042F80000100A00004 /* TwonlyWidget */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
|
|
@ -160,6 +182,22 @@
|
|||
path = ShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D4A100042F81000100A10004 /* Shared */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D4A100012F81000100A10001 /* TwonlyWidgetShared.swift */,
|
||||
);
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D4A000022F80000100A00002 /* TwonlyWidget */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
D4A000032F80000100A00003 /* Exceptions for "TwonlyWidget" folder in "TwonlyWidget" target */,
|
||||
);
|
||||
path = TwonlyWidget;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
|
@ -197,6 +235,13 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D4A000062F80000100A00006 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
|
|
@ -227,6 +272,9 @@
|
|||
97C146F01CF9000F007C117D /* Runner */,
|
||||
D21FCEA52D9F2B750088701D /* NotificationService */,
|
||||
D25D4D712EFF41DB0029F805 /* ShareExtension */,
|
||||
D4A100042F81000100A10004 /* Shared */,
|
||||
D4A000022F80000100A00002 /* TwonlyWidget */,
|
||||
D4A0000F2F80000100A0000F /* logo.png */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
16FBC6F5B58E1C6646F5D447 /* GoogleService-Info.plist */,
|
||||
|
|
@ -242,6 +290,7 @@
|
|||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||
D21FCEA42D9F2B750088701D /* NotificationService.appex */,
|
||||
D25D4D702EFF41DB0029F805 /* ShareExtension.appex */,
|
||||
D4A000012F80000100A00001 /* TwonlyWidget.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -342,6 +391,7 @@
|
|||
dependencies = (
|
||||
D21FCEAA2D9F2B750088701D /* PBXTargetDependency */,
|
||||
D25D4D792EFF41DB0029F805 /* PBXTargetDependency */,
|
||||
D4A0000E2F80000100A0000E /* PBXTargetDependency */,
|
||||
);
|
||||
name = Runner;
|
||||
packageProductDependencies = (
|
||||
|
|
@ -393,6 +443,26 @@
|
|||
productReference = D25D4D702EFF41DB0029F805 /* ShareExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
D4A000042F80000100A00004 /* TwonlyWidget */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = D4A000082F80000100A00008 /* Build configuration list for PBXNativeTarget "TwonlyWidget" */;
|
||||
buildPhases = (
|
||||
D4A000052F80000100A00005 /* Sources */,
|
||||
D4A000062F80000100A00006 /* Frameworks */,
|
||||
D4A000072F80000100A00007 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
D4A000022F80000100A00002 /* TwonlyWidget */,
|
||||
);
|
||||
name = TwonlyWidget;
|
||||
productName = TwonlyWidget;
|
||||
productReference = D4A000012F80000100A00001 /* TwonlyWidget.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
|
|
@ -418,6 +488,9 @@
|
|||
D25D4D6F2EFF41DB0029F805 = {
|
||||
CreatedOnToolsVersion = 26.1.1;
|
||||
};
|
||||
D4A000042F80000100A00004 = {
|
||||
CreatedOnToolsVersion = 16.2;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
|
||||
|
|
@ -440,6 +513,7 @@
|
|||
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||
D21FCEA32D9F2B750088701D /* NotificationService */,
|
||||
D25D4D6F2EFF41DB0029F805 /* ShareExtension */,
|
||||
D4A000042F80000100A00004 /* TwonlyWidget */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
|
@ -478,6 +552,14 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D4A000072F80000100A00007 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D4A000102F80000100A00010 /* logo.png in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
|
|
@ -654,6 +736,7 @@
|
|||
D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */,
|
||||
D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */,
|
||||
D3A100072F70000100D1A004 /* NativeGallery.swift in Sources */,
|
||||
D4A100022F81000100A10002 /* TwonlyWidgetShared.swift in Sources */,
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
);
|
||||
|
|
@ -673,6 +756,14 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D4A000052F80000100A00005 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D4A100032F81000100A10003 /* TwonlyWidgetShared.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
|
|
@ -691,6 +782,11 @@
|
|||
target = D25D4D6F2EFF41DB0029F805 /* ShareExtension */;
|
||||
targetProxy = D25D4D782EFF41DB0029F805 /* PBXContainerItemProxy */;
|
||||
};
|
||||
D4A0000E2F80000100A0000E /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = D4A000042F80000100A00004 /* TwonlyWidget */;
|
||||
targetProxy = D4A0000D2F80000100A0000D /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
|
|
@ -788,7 +884,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.0.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
|
|
@ -810,7 +906,7 @@
|
|||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.connect.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
|
|
@ -828,7 +924,7 @@
|
|||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.connect.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
|
|
@ -844,7 +940,7 @@
|
|||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.connect.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
|
|
@ -987,7 +1083,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.0.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
|
|
@ -1065,7 +1161,7 @@
|
|||
MARKETING_VERSION = 1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing.NotificationService;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.NotificationService;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
|
|
@ -1143,7 +1239,7 @@
|
|||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing.NotificationService;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.NotificationService;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
|
|
@ -1183,7 +1279,7 @@
|
|||
MARKETING_VERSION = 1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing.ShareExtension;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
|
|
@ -1269,7 +1365,7 @@
|
|||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing.ShareExtension;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
|
|
@ -1281,6 +1377,92 @@
|
|||
};
|
||||
name = Profile;
|
||||
};
|
||||
D4A000092F80000100A00009 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = TwonlyWidget/TwonlyWidget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = CN332ZUGRP;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = TwonlyWidget/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = twonly;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.TwonlyWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
D4A0000A2F80000100A0000A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = TwonlyWidget/TwonlyWidget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = CN332ZUGRP;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = TwonlyWidget/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = twonly;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.TwonlyWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
D4A0000B2F80000100A0000B /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = TwonlyWidget/TwonlyWidget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = CN332ZUGRP;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = TwonlyWidget/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = twonly;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.TwonlyWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
|
|
@ -1334,6 +1516,16 @@
|
|||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
D4A000082F80000100A00008 /* Build configuration list for PBXNativeTarget "TwonlyWidget" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
D4A000092F80000100A00009 /* Debug */,
|
||||
D4A0000A2F80000100A0000A /* Release */,
|
||||
D4A0000B2F80000100A0000B /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D4A000042F80000100A00004"
|
||||
BuildableName = "TwonlyWidget.appex"
|
||||
BlueprintName = "TwonlyWidget"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
askForAppToLaunch = "Yes"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D4A000042F80000100A00004"
|
||||
BuildableName = "TwonlyWidget.appex"
|
||||
BlueprintName = "TwonlyWidget"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Profile"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D4A000042F80000100A00004"
|
||||
BuildableName = "TwonlyWidget.appex"
|
||||
BlueprintName = "TwonlyWidget"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import AppIntents
|
||||
import CryptoKit
|
||||
import Flutter
|
||||
import Foundation
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
import WidgetKit
|
||||
import flutter_sharing_intent
|
||||
|
||||
@main
|
||||
|
|
@ -222,6 +224,99 @@ class RuntimeStorageChannel {
|
|||
binaryMessenger: messenger
|
||||
)
|
||||
channel.setMethodCallHandler { call, result in
|
||||
// Rust rewrites the widget manifest from inside this process, and
|
||||
// WidgetKit only re-reads it when a timeline reload is requested. The
|
||||
// notification service extension covers pushes; this covers everything
|
||||
// the running app changes.
|
||||
if call.method == "reloadWidgets" {
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
// Only the app can ask which widgets are actually on the home screen. A
|
||||
// widget extension is never told that its widget was removed, so without
|
||||
// this the placement file keeps describing widgets that are long gone.
|
||||
if call.method == "reconcileWidgets" {
|
||||
// Widgets need iOS 17; on anything older there is nothing to reconcile.
|
||||
guard #available(iOS 17.0, *) else {
|
||||
result(["supported": false, "matched": 0, "widgets": []])
|
||||
return
|
||||
}
|
||||
// `getCurrentConfigurations` answers on a background queue, but a
|
||||
// FlutterResult must be delivered on the platform thread — replying off
|
||||
// it is undefined and the reply can be dropped, leaving Dart awaiting a
|
||||
// future that never completes.
|
||||
let reply: (Any?) -> Void = { value in
|
||||
DispatchQueue.main.async { result(value) }
|
||||
}
|
||||
// Ask every widget that is really on a home screen to rebuild, then give
|
||||
// the extension a moment to answer. A widget the system merely still has
|
||||
// a record of is never displayed, so it is never asked for a timeline
|
||||
// and never stamps itself — which is what separates the two.
|
||||
let askedAt = Int(Date().timeIntervalSince1970)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 2) {
|
||||
WidgetCenter.shared.getCurrentConfigurations { configurations in
|
||||
switch configurations {
|
||||
case .success(let placed):
|
||||
var selections: [[Int64]] = []
|
||||
var report: [[String: Any]] = []
|
||||
// A widget that rebuilt just before the reload was asked for still
|
||||
// counts; the window only has to exclude records that never rebuild.
|
||||
let rebuilt = WidgetStorage.recentlyRebuiltIds(since: askedAt - 90)
|
||||
for (index, info) in placed.enumerated() {
|
||||
var entry: [String: Any] = [
|
||||
"kind": info.kind,
|
||||
"family": "\(info.family)",
|
||||
"mine": info.kind == widgetKind,
|
||||
]
|
||||
if info.kind == widgetKind {
|
||||
if let intent = try? info.widgetConfigurationIntent(
|
||||
of: TwonlyWidgetIntent.self)
|
||||
{
|
||||
let ids = (intent.groups ?? []).compactMap { Int64($0.id) }
|
||||
let live = rebuilt.contains("ios:\(WidgetStorage.selectionKey(ids))")
|
||||
entry["group_ids"] = ids
|
||||
entry["id"] = "ios:\(index):\(WidgetStorage.selectionKey(ids))"
|
||||
entry["live"] = live
|
||||
if live { selections.append(ids) }
|
||||
} else {
|
||||
// The widget is placed even though its configuration will not
|
||||
// decode. Recording it as unconfigured keeps it in the file:
|
||||
// dropping it would report a removal that did not happen.
|
||||
entry["configuration"] = "unreadable"
|
||||
entry["group_ids"] = [Int64]()
|
||||
entry["id"] = "ios:\(index)"
|
||||
entry["live"] = false
|
||||
}
|
||||
}
|
||||
report.append(entry)
|
||||
}
|
||||
WidgetStorage.replaceIosSelections(selections)
|
||||
widgetLog.debug(
|
||||
"WidgetKit reports \(placed.count, privacy: .public) widget(s); \(selections.count, privacy: .public) rebuilt a timeline and are counted as placed"
|
||||
)
|
||||
reply([
|
||||
"supported": true,
|
||||
"matched": selections.count,
|
||||
"widgets": report,
|
||||
])
|
||||
case .failure(let error):
|
||||
// Leave the file alone: a failed query is not evidence of removal.
|
||||
widgetLog.error(
|
||||
"getCurrentConfigurations failed: \(error.localizedDescription, privacy: .public)"
|
||||
)
|
||||
reply(
|
||||
FlutterError(
|
||||
code: "widget_configurations_unavailable",
|
||||
message: error.localizedDescription,
|
||||
details: nil
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
guard call.method == "runtimeSupportDirectory" else {
|
||||
result(FlutterMethodNotImplemented)
|
||||
return
|
||||
|
|
|
|||
321
ios/Shared/TwonlyWidgetShared.swift
Normal file
321
ios/Shared/TwonlyWidgetShared.swift
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
import AppIntents
|
||||
import Foundation
|
||||
import OSLog
|
||||
import WidgetKit
|
||||
|
||||
// Compiled into BOTH the app and the widget extension.
|
||||
//
|
||||
// The extension owns the widget's appearance, but only the app can ask
|
||||
// WidgetKit which widgets are actually on the home screen
|
||||
// (`getCurrentConfigurations`), and reading their configuration requires the
|
||||
// very same intent and entity types the extension declares. Everything both
|
||||
// sides need therefore lives here rather than inside the extension.
|
||||
//
|
||||
// The app deploys further back than the widget extension does, so the
|
||||
// AppIntents declarations carry an explicit availability gate.
|
||||
|
||||
let runtimeAppGroup = "group.eu.twonly.runtime"
|
||||
let widgetKind = "TwonlyWidget"
|
||||
|
||||
/// How long a widget may go without rebuilding its timeline before it is taken
|
||||
/// to have been removed from the home screen. Timelines cover four hours, so a
|
||||
/// widget that is still placed refreshes far inside this.
|
||||
let staleWidgetSeconds: Double = 48 * 60 * 60
|
||||
|
||||
/// A widget extension is launched by WidgetKit, off any debugger, at moments
|
||||
/// nobody is watching, and a failure just renders as an empty square. Every
|
||||
/// step that can silently produce "no image" logs here instead.
|
||||
///
|
||||
/// log stream --predicate 'subsystem == "eu.twonly.widget"' --level debug
|
||||
let widgetLog = Logger(subsystem: "eu.twonly.widget", category: "timeline")
|
||||
|
||||
struct Manifest: Decodable {
|
||||
let groups: [ManifestGroup]
|
||||
let images: [ManifestImage]
|
||||
}
|
||||
|
||||
struct ManifestGroup: Decodable {
|
||||
let id: Int64
|
||||
let name: String
|
||||
let emoji: String?
|
||||
}
|
||||
|
||||
struct ManifestImage: Decodable {
|
||||
let mediaId: String
|
||||
let path: String
|
||||
let sender: String
|
||||
let groupIds: [Int64]
|
||||
let expiresAt: Int64
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case path, sender
|
||||
case mediaId = "media_id"
|
||||
case groupIds = "group_ids"
|
||||
case expiresAt = "expires_at"
|
||||
}
|
||||
}
|
||||
|
||||
enum WidgetStorage {
|
||||
static var runtimeDirectory: URL? {
|
||||
FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: runtimeAppGroup)?
|
||||
.appendingPathComponent("runtime", isDirectory: true)
|
||||
}
|
||||
|
||||
/// Every way reading the manifest can come up empty. The widget renders the
|
||||
/// reason, because none of these are distinguishable from the home screen
|
||||
/// otherwise: an unprovisioned App Group, a manifest the app never wrote and
|
||||
/// a manifest with nothing in it all look like one blank widget.
|
||||
enum ManifestFault: Error, Equatable {
|
||||
case appGroupUnavailable
|
||||
case missing
|
||||
case unreadable(String)
|
||||
|
||||
var summary: String {
|
||||
switch self {
|
||||
case .appGroupUnavailable: return "No shared storage"
|
||||
case .missing: return "Open twonly once"
|
||||
case .unreadable(let detail): return "Unreadable data (\(detail))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func manifest() -> Result<Manifest, ManifestFault> {
|
||||
guard let url = runtimeDirectory?.appendingPathComponent("widget/manifest.json") else {
|
||||
widgetLog.error("App Group \(runtimeAppGroup, privacy: .public) is not reachable")
|
||||
return .failure(.appGroupUnavailable)
|
||||
}
|
||||
guard let data = try? Data(contentsOf: url) else {
|
||||
let exists = FileManager.default.fileExists(atPath: url.path)
|
||||
widgetLog.error(
|
||||
"manifest unreadable at \(url.path, privacy: .public) (exists: \(exists, privacy: .public))")
|
||||
return .failure(exists ? .unreadable("locked") : .missing)
|
||||
}
|
||||
do {
|
||||
let manifest = try JSONDecoder().decode(Manifest.self, from: data)
|
||||
widgetLog.debug(
|
||||
"""
|
||||
manifest \(data.count, privacy: .public) bytes, \
|
||||
\(manifest.groups.count, privacy: .public) groups, \
|
||||
\(manifest.images.count, privacy: .public) images
|
||||
""")
|
||||
return .success(manifest)
|
||||
} catch {
|
||||
widgetLog.error("manifest decode failed: \(error, privacy: .public)")
|
||||
return .failure(.unreadable("\(error)"))
|
||||
}
|
||||
}
|
||||
|
||||
static func images() -> Result<[ManifestImage], ManifestFault> {
|
||||
manifest().map(\.images)
|
||||
}
|
||||
|
||||
static func selectionKey(_ ids: [Int64]) -> String {
|
||||
ids.sorted().map(String.init).joined(separator: "-")
|
||||
}
|
||||
|
||||
static func index(for ids: [Int64]) -> Int {
|
||||
UserDefaults(suiteName: runtimeAppGroup)?.integer(forKey: "widget-index-\(selectionKey(ids))") ?? 0
|
||||
}
|
||||
|
||||
/// Where the rotation starts, sent back to the front whenever an image has
|
||||
/// arrived since this selection was last drawn.
|
||||
///
|
||||
/// The manifest is newest first, so an arriving image is prepended and a
|
||||
/// stored index keeps pointing at an older one: a timeline reload alone would
|
||||
/// never show what just came in. Recording which image was newest last time
|
||||
/// is what separates an arrival from every other reason a timeline is rebuilt.
|
||||
static func startIndex(for ids: [Int64], newestMediaId: String?) -> Int {
|
||||
guard let newestMediaId else { return index(for: ids) }
|
||||
let defaults = UserDefaults(suiteName: runtimeAppGroup)
|
||||
let newestKey = "widget-newest-\(selectionKey(ids))"
|
||||
guard defaults?.string(forKey: newestKey) != newestMediaId else {
|
||||
return index(for: ids)
|
||||
}
|
||||
defaults?.set(newestMediaId, forKey: newestKey)
|
||||
defaults?.set(0, forKey: "widget-index-\(selectionKey(ids))")
|
||||
return 0
|
||||
}
|
||||
|
||||
static func advance(_ ids: [Int64]) {
|
||||
let defaults = UserDefaults(suiteName: runtimeAppGroup)
|
||||
let key = "widget-index-\(selectionKey(ids))"
|
||||
defaults?.set((defaults?.integer(forKey: key) ?? 0) + 1, forKey: key)
|
||||
}
|
||||
|
||||
/// Records this widget's contact groups for Rust to import.
|
||||
///
|
||||
/// Each placed widget builds its own timeline, so this only ever knows about
|
||||
/// one of them and has to merge into what the others wrote — replacing the
|
||||
/// list would make every widget erase its neighbours. Nothing tells an
|
||||
/// extension that a widget was removed either, so each entry carries the time
|
||||
/// it was last rebuilt and readers drop the ones that stopped reporting.
|
||||
/// Replaces every iOS entry with the widgets WidgetKit says are placed.
|
||||
///
|
||||
/// The extension can only ever add itself, and is never told that its widget
|
||||
/// was removed, so a removal is invisible from that side. The app can ask
|
||||
/// outright, which makes this the only exact answer — call it before reading
|
||||
/// the configuration back.
|
||||
static func storedWidgets() -> [[String: Any]] {
|
||||
guard let directory = runtimeDirectory?.appendingPathComponent("widget", isDirectory: true),
|
||||
let data = try? Data(contentsOf: directory.appendingPathComponent("native-config.json")),
|
||||
let decoded = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let stored = decoded["widgets"] as? [[String: Any]]
|
||||
else { return [] }
|
||||
return stored
|
||||
}
|
||||
|
||||
/// Selections that have rebuilt a timeline at or after `since`.
|
||||
///
|
||||
/// This is the only evidence available that a widget is really on a home
|
||||
/// screen. WidgetKit keeps records of widgets it has known about and
|
||||
/// `getCurrentConfigurations` hands them all back, so its answer alone cannot
|
||||
/// tell a placed widget from one the system has simply not forgotten. Only a
|
||||
/// widget that is actually being displayed is asked for a timeline, and
|
||||
/// `persistSelection` — called from nowhere else — stamps the moment it was.
|
||||
static func recentlyRebuiltIds(since: Int) -> Set<String> {
|
||||
var ids = Set<String>()
|
||||
for entry in storedWidgets() {
|
||||
guard let id = entry["id"] as? String, let seen = entry["last_seen"] as? Int
|
||||
else { continue }
|
||||
if seen >= since { ids.insert(id) }
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/// Rewrites the placement file from the widgets the app has established are
|
||||
/// really placed. Each entry keeps the timestamp its widget wrote: stamping
|
||||
/// `now` here would forge the very evidence the next reconcile depends on.
|
||||
static func replaceIosSelections(_ selections: [[Int64]]) {
|
||||
guard let directory = runtimeDirectory?.appendingPathComponent("widget", isDirectory: true)
|
||||
else { return }
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
|
||||
var lastSeen: [String: Int] = [:]
|
||||
for entry in storedWidgets() {
|
||||
if let id = entry["id"] as? String, let seen = entry["last_seen"] as? Int {
|
||||
lastSeen[id] = seen
|
||||
}
|
||||
}
|
||||
|
||||
// Two widgets configured with the same contact groups are interchangeable
|
||||
// here: the id is the selection, and Rust only reads the union anyway.
|
||||
var seen = Set<String>()
|
||||
var widgets: [[String: Any]] = []
|
||||
for ids in selections {
|
||||
let identifier = "ios:\(selectionKey(ids))"
|
||||
guard seen.insert(identifier).inserted else { continue }
|
||||
widgets.append([
|
||||
"id": identifier,
|
||||
"platform": "ios",
|
||||
"group_ids": ids,
|
||||
"last_seen": lastSeen[identifier] ?? 0,
|
||||
])
|
||||
}
|
||||
write(widgets: widgets, to: directory)
|
||||
}
|
||||
|
||||
/// Overwrites the placement file.
|
||||
///
|
||||
/// `Data.write(.atomic)` already writes to a neighbouring temporary file and
|
||||
/// exchanges it, so this does not hand-roll that. The previous version did,
|
||||
/// and swallowed a failed exchange: the destination still existed, so its
|
||||
/// fallback never ran and the stale file survived a write that looked like it
|
||||
/// had succeeded.
|
||||
private static func write(widgets: [[String: Any]], to directory: URL) {
|
||||
let destination = directory.appendingPathComponent("native-config.json")
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: ["widgets": widgets])
|
||||
try data.write(
|
||||
to: destination,
|
||||
options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]
|
||||
)
|
||||
widgetLog.debug(
|
||||
"wrote \(widgets.count, privacy: .public) widget(s) to \(destination.path, privacy: .public)"
|
||||
)
|
||||
} catch {
|
||||
widgetLog.error("could not write the placement file: \(error, privacy: .public)")
|
||||
}
|
||||
// A crash between the two steps of the old implementation could have left
|
||||
// this behind, and it would otherwise sit in the App Group forever.
|
||||
try? FileManager.default.removeItem(
|
||||
at: directory.appendingPathComponent("native-config.json.tmp"))
|
||||
}
|
||||
|
||||
static func persistSelection(_ ids: [Int64]) {
|
||||
guard let directory = runtimeDirectory?.appendingPathComponent("widget", isDirectory: true)
|
||||
else { return }
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try? (directory as NSURL).setResourceValue(
|
||||
URLFileProtection.completeUntilFirstUserAuthentication,
|
||||
forKey: .fileProtectionKey
|
||||
)
|
||||
let identifier = "ios:\(selectionKey(ids))"
|
||||
let widget: [String: Any] = [
|
||||
"id": identifier,
|
||||
"platform": "ios",
|
||||
"group_ids": ids,
|
||||
"last_seen": Int(Date().timeIntervalSince1970),
|
||||
]
|
||||
|
||||
let destination = directory.appendingPathComponent("native-config.json")
|
||||
var widgets: [[String: Any]] = []
|
||||
if let existing = try? Data(contentsOf: destination),
|
||||
let decoded = try? JSONSerialization.jsonObject(with: existing) as? [String: Any],
|
||||
let stored = decoded["widgets"] as? [[String: Any]] {
|
||||
// Android's entries are authoritative and carry no timestamp, so they are
|
||||
// always kept; only this platform's own stale rows are dropped.
|
||||
widgets = stored.filter { entry in
|
||||
guard entry["id"] as? String != identifier else { return false }
|
||||
guard let seen = entry["last_seen"] as? Int else { return true }
|
||||
return Date().timeIntervalSince1970 - Double(seen) < staleWidgetSeconds
|
||||
}
|
||||
}
|
||||
widgets.append(widget)
|
||||
|
||||
write(widgets: widgets, to: directory)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
struct ContactGroupEntity: AppEntity {
|
||||
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Contact group")
|
||||
static let defaultQuery = ContactGroupQuery()
|
||||
|
||||
let id: String
|
||||
let name: String
|
||||
let emoji: String?
|
||||
|
||||
var displayRepresentation: DisplayRepresentation {
|
||||
DisplayRepresentation(title: "\(emoji.map { "\($0) " } ?? "")\(name)")
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
struct ContactGroupQuery: EntityQuery {
|
||||
func entities(for identifiers: [String]) async throws -> [ContactGroupEntity] {
|
||||
let wanted = Set(identifiers)
|
||||
return allEntities().filter { wanted.contains($0.id) }
|
||||
}
|
||||
|
||||
private func allEntities() -> [ContactGroupEntity] {
|
||||
let groups = (try? WidgetStorage.manifest().get())?.groups ?? []
|
||||
return groups.map {
|
||||
ContactGroupEntity(id: String($0.id), name: $0.name, emoji: $0.emoji)
|
||||
}
|
||||
}
|
||||
|
||||
func suggestedEntities() async throws -> [ContactGroupEntity] { allEntities() }
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
struct TwonlyWidgetIntent: WidgetConfigurationIntent {
|
||||
static let title: LocalizedStringResource = "twonly contact groups"
|
||||
static let description = IntentDescription("Choose who may share images with this widget.")
|
||||
|
||||
@Parameter(title: "Contact groups")
|
||||
var groups: [ContactGroupEntity]?
|
||||
|
||||
init() { groups = nil }
|
||||
}
|
||||
|
||||
6
ios/TwonlyWidget/Assets.xcassets/Contents.json
Normal file
6
ios/TwonlyWidget/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
13
ios/TwonlyWidget/Assets.xcassets/logo.imageset/Contents.json
vendored
Normal file
13
ios/TwonlyWidget/Assets.xcassets/logo.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "logo.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
ios/TwonlyWidget/Assets.xcassets/logo.imageset/logo.png
vendored
Normal file
BIN
ios/TwonlyWidget/Assets.xcassets/logo.imageset/logo.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
15
ios/TwonlyWidget/Info.plist
Normal file
15
ios/TwonlyWidget/Info.plist
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
10
ios/TwonlyWidget/TwonlyWidget.entitlements
Normal file
10
ios/TwonlyWidget/TwonlyWidget.entitlements
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.eu.twonly.runtime</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
339
ios/TwonlyWidget/TwonlyWidget.swift
Normal file
339
ios/TwonlyWidget/TwonlyWidget.swift
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
import AppIntents
|
||||
import ImageIO
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct AdvanceWidgetIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Next image"
|
||||
static let openAppWhenRun = false
|
||||
|
||||
@Parameter(title: "Contact groups") var groupIds: [String]
|
||||
|
||||
init() { groupIds = [] }
|
||||
init(groupIds: [Int64]) { self.groupIds = groupIds.map(String.init) }
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
WidgetStorage.advance(groupIds.compactMap(Int64.init))
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: widgetKind)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the widget has nothing to show, so the placeholder can say it.
|
||||
enum EmptyReason: Equatable {
|
||||
case notConfigured
|
||||
case noMatchingImages(total: Int)
|
||||
case fault(WidgetStorage.ManifestFault)
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .notConfigured:
|
||||
return "Hold to choose contact groups"
|
||||
case .noMatchingImages(let total):
|
||||
// The count separates "the app never delivered anything" from "images
|
||||
// arrived but carry other groups than the ones this widget selects".
|
||||
return total == 0 ? "No images yet" : "\(total) shared, none in your groups"
|
||||
case .fault(let fault):
|
||||
return fault.summary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TwonlyEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let image: ManifestImage?
|
||||
let groupIds: [Int64]
|
||||
let emptyReason: EmptyReason?
|
||||
/// Longest edge this entry will ever be drawn at, in pixels. Decoding to the
|
||||
/// size actually shown is what keeps the extension inside its memory budget.
|
||||
let maxPixelSize: CGFloat
|
||||
}
|
||||
|
||||
/// WidgetKit renders every timeline entry up front, so a timeline is a
|
||||
/// multiplier on whatever one entry costs. Eight half-hour steps keep the
|
||||
/// rotation going for four hours on a fraction of the memory a full day took.
|
||||
private let timelineEntryCount = 8
|
||||
private let timelineStepMinutes = 30
|
||||
|
||||
struct TwonlyProvider: AppIntentTimelineProvider {
|
||||
/// The widget is never drawn larger than its container, so this is the most
|
||||
/// detail any image needs. `displaySize` is in points; 3x covers the densest
|
||||
/// screen the extension can be asked to render for.
|
||||
private func maxPixelSize(in context: Context) -> CGFloat {
|
||||
max(context.displaySize.width, context.displaySize.height) * 3
|
||||
}
|
||||
|
||||
func placeholder(in context: Context) -> TwonlyEntry {
|
||||
TwonlyEntry(
|
||||
date: .now,
|
||||
image: nil,
|
||||
groupIds: [],
|
||||
emptyReason: .noMatchingImages(total: 0),
|
||||
maxPixelSize: maxPixelSize(in: context)
|
||||
)
|
||||
}
|
||||
|
||||
func snapshot(for configuration: TwonlyWidgetIntent, in context: Context) async -> TwonlyEntry {
|
||||
let ids = (configuration.groups ?? []).compactMap { Int64($0.id) }
|
||||
return entry(
|
||||
images: WidgetStorage.images(),
|
||||
ids: ids,
|
||||
index: WidgetStorage.index(for: ids),
|
||||
date: .now,
|
||||
maxPixelSize: maxPixelSize(in: context)
|
||||
)
|
||||
}
|
||||
|
||||
func timeline(for configuration: TwonlyWidgetIntent, in context: Context) async -> Timeline<TwonlyEntry> {
|
||||
let ids = (configuration.groups ?? []).compactMap { Int64($0.id) }
|
||||
WidgetStorage.persistSelection(ids)
|
||||
// Read once: an extension that re-reads the manifest for every entry spends
|
||||
// its whole budget on JSON.
|
||||
let available = WidgetStorage.images()
|
||||
// The rotation starts at whatever just arrived, so an image shared into
|
||||
// this widget is on the home screen as soon as the timeline is rebuilt
|
||||
// rather than whenever the rotation next comes back around to it.
|
||||
let start = WidgetStorage.startIndex(
|
||||
for: ids,
|
||||
newestMediaId: newestMatching(in: available, ids: ids)?.mediaId
|
||||
)
|
||||
let pixels = maxPixelSize(in: context)
|
||||
let entries = (0..<timelineEntryCount).map { offset in
|
||||
entry(
|
||||
images: available,
|
||||
ids: ids,
|
||||
index: start + offset,
|
||||
date: Calendar.current.date(
|
||||
byAdding: .minute, value: offset * timelineStepMinutes, to: .now) ?? .now,
|
||||
maxPixelSize: pixels
|
||||
)
|
||||
}
|
||||
widgetLog.debug(
|
||||
"built \(entries.count, privacy: .public) entries at \(pixels, privacy: .public)px")
|
||||
return Timeline(entries: entries, policy: .atEnd)
|
||||
}
|
||||
|
||||
/// The most recent image this selection can show right now. The manifest is
|
||||
/// written newest first, so that is simply the first one that matches.
|
||||
private func newestMatching(
|
||||
in images: Result<[ManifestImage], WidgetStorage.ManifestFault>,
|
||||
ids: [Int64]
|
||||
) -> ManifestImage? {
|
||||
guard !ids.isEmpty, let available = try? images.get() else { return nil }
|
||||
let selected = Set(ids)
|
||||
let now = Int64(Date().timeIntervalSince1970)
|
||||
return available.first { $0.expiresAt > now && !selected.isDisjoint(with: $0.groupIds) }
|
||||
}
|
||||
|
||||
private func entry(
|
||||
images: Result<[ManifestImage], WidgetStorage.ManifestFault>,
|
||||
ids: [Int64],
|
||||
index: Int,
|
||||
date: Date,
|
||||
maxPixelSize: CGFloat
|
||||
) -> TwonlyEntry {
|
||||
let available: [ManifestImage]
|
||||
switch images {
|
||||
case .success(let value):
|
||||
available = value
|
||||
case .failure(let fault):
|
||||
return TwonlyEntry(
|
||||
date: date, image: nil, groupIds: ids,
|
||||
emptyReason: .fault(fault), maxPixelSize: maxPixelSize)
|
||||
}
|
||||
|
||||
// An unconfigured widget selects nothing, and an empty set is disjoint from
|
||||
// every image, so this would silently filter everything away.
|
||||
guard !ids.isEmpty else {
|
||||
widgetLog.notice("no contact group configured; nothing can match")
|
||||
return TwonlyEntry(
|
||||
date: date, image: nil, groupIds: ids,
|
||||
emptyReason: .notConfigured, maxPixelSize: maxPixelSize)
|
||||
}
|
||||
|
||||
let selected = Set(ids)
|
||||
let now = Int64(date.timeIntervalSince1970)
|
||||
let unexpired = available.filter { $0.expiresAt > now }
|
||||
let matching = unexpired.filter { !selected.isDisjoint(with: $0.groupIds) }
|
||||
guard !matching.isEmpty else {
|
||||
// The two counts separate "everything aged out" from "the sender is in
|
||||
// groups this widget did not select", which look identical on screen.
|
||||
widgetLog.notice(
|
||||
"""
|
||||
no match: selected \(ids, privacy: .public), \
|
||||
\(available.count, privacy: .public) images, \
|
||||
\(unexpired.count, privacy: .public) unexpired, \
|
||||
offered groups \(Set(available.flatMap(\.groupIds)).sorted(), privacy: .public)
|
||||
""")
|
||||
return TwonlyEntry(
|
||||
date: date,
|
||||
image: nil,
|
||||
groupIds: ids,
|
||||
emptyReason: .noMatchingImages(total: available.count),
|
||||
maxPixelSize: maxPixelSize
|
||||
)
|
||||
}
|
||||
return TwonlyEntry(
|
||||
date: date,
|
||||
image: matching[index % matching.count],
|
||||
groupIds: ids,
|
||||
emptyReason: nil,
|
||||
maxPixelSize: maxPixelSize
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The widget extension has its own bundle, so it carries its own copy of the
|
||||
/// logo in `TwonlyWidget/Assets.xcassets`; the Runner catalog is not visible
|
||||
/// here.
|
||||
private let placeholderLogo = UIImage(named: "logo")
|
||||
|
||||
/// Decodes straight to the size the widget draws at.
|
||||
///
|
||||
/// `UIImage(contentsOfFile:)` expands the whole file into a bitmap first — a
|
||||
/// 1200px image costs about 5.8 MB that way, and WidgetKit renders every
|
||||
/// timeline entry, so a handful of them is enough to pass the extension's
|
||||
/// memory limit. Exceeding it is not an error the code can catch: the system
|
||||
/// kills the extension and the home screen keeps showing a blank square.
|
||||
/// ImageIO never materialises the full bitmap.
|
||||
private func downsampledImage(at path: String, maxPixelSize: CGFloat) -> UIImage? {
|
||||
guard maxPixelSize > 0,
|
||||
let source = CGImageSourceCreateWithURL(
|
||||
URL(fileURLWithPath: path) as CFURL,
|
||||
[kCGImageSourceShouldCache: false] as CFDictionary
|
||||
)
|
||||
else { return nil }
|
||||
|
||||
let options =
|
||||
[
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceShouldCacheImmediately: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
|
||||
] as CFDictionary
|
||||
guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, options) else {
|
||||
return nil
|
||||
}
|
||||
return UIImage(cgImage: thumbnail)
|
||||
}
|
||||
|
||||
struct TwonlyWidgetView: View {
|
||||
let entry: TwonlyEntry
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.overlay {
|
||||
Button(intent: AdvanceWidgetIntent(groupIds: entry.groupIds)) {
|
||||
Color.clear
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Show next image")
|
||||
}
|
||||
.containerBackground(for: .widget) { brandBackground }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let image = entry.image,
|
||||
let uiImage = downsampledImage(at: image.path, maxPixelSize: entry.maxPixelSize) {
|
||||
photo(uiImage, sender: image.sender)
|
||||
.onAppear {
|
||||
widgetLog.debug(
|
||||
"rendering \(image.mediaId, privacy: .public) at \(uiImage.size.debugDescription, privacy: .public)")
|
||||
}
|
||||
} else if let image = entry.image {
|
||||
// The manifest named an image the extension cannot open: the file is
|
||||
// gone, or its data protection class keeps it sealed out here.
|
||||
placeholder(message: "Image could not be loaded")
|
||||
.onAppear {
|
||||
let manager = FileManager.default
|
||||
widgetLog.error(
|
||||
"""
|
||||
cannot open \(image.path, privacy: .public) \
|
||||
(exists: \(manager.fileExists(atPath: image.path), privacy: .public), \
|
||||
readable: \(manager.isReadableFile(atPath: image.path), privacy: .public))
|
||||
""")
|
||||
}
|
||||
} else {
|
||||
placeholder(message: (entry.emptyReason ?? .noMatchingImages(total: 0)).message)
|
||||
}
|
||||
}
|
||||
|
||||
/// A filled photo with the sender's name in the corner.
|
||||
///
|
||||
/// `scaledToFill` reports a size larger than the space it was given, so an
|
||||
/// image placed directly in a stack drives that stack's bounds past the edges
|
||||
/// of the widget — and anything aligned to a corner goes off screen with it.
|
||||
/// Overlaying the image on a flexible, zero-cost base keeps the layout the
|
||||
/// size of the widget, so the name lands on the visible bottom-right corner.
|
||||
private func photo(_ uiImage: UIImage, sender: String) -> some View {
|
||||
Color.clear
|
||||
.overlay {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
}
|
||||
.clipped()
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
Text(sender)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.black.opacity(0.42), in: Capsule())
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shown whenever there is nothing to display, always with the reason: a
|
||||
/// blank widget is the one outcome that cannot be diagnosed from the home
|
||||
/// screen.
|
||||
private func placeholder(message: String) -> some View {
|
||||
VStack(spacing: 8) {
|
||||
Group {
|
||||
if let placeholderLogo {
|
||||
Image(uiImage: placeholderLogo).resizable().scaledToFit()
|
||||
} else {
|
||||
Image(systemName: "photo.on.rectangle.angled").resizable().scaledToFit()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 64, maxHeight: 64)
|
||||
.foregroundStyle(.white)
|
||||
.opacity(0.9)
|
||||
Text(message)
|
||||
.font(.caption2)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.lineLimit(3)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
/// `defaultPrimaryColor` from the Flutter theme (0xFF32BE80).
|
||||
private var brandBackground: some View {
|
||||
Color(red: 50.0 / 255.0, green: 190.0 / 255.0, blue: 128.0 / 255.0)
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct TwonlyWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
TwonlyHomeWidget()
|
||||
}
|
||||
}
|
||||
|
||||
struct TwonlyHomeWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: widgetKind, intent: TwonlyWidgetIntent.self, provider: TwonlyProvider()) {
|
||||
TwonlyWidgetView(entry: $0)
|
||||
}
|
||||
.configurationDisplayName("twonly")
|
||||
.description("Images your friends share with your widget.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||
.contentMarginsDisabled()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -16,6 +16,7 @@ import 'package:twonly/src/localization/generated/app_localizations.dart';
|
|||
import 'package:twonly/src/model/json/onboarding_state.model.dart';
|
||||
import 'package:twonly/src/providers/routing.provider.dart';
|
||||
import 'package:twonly/src/providers/settings.provider.dart';
|
||||
import 'package:twonly/src/services/home_widget.service.dart';
|
||||
import 'package:twonly/src/services/intent/links.intent.dart';
|
||||
import 'package:twonly/src/services/notifications/native.notifications.dart';
|
||||
import 'package:twonly/src/utils/keyvalue.dart';
|
||||
|
|
@ -73,6 +74,10 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||
unawaited(
|
||||
rust_api.RustApi.setBackground(inBackground: true),
|
||||
);
|
||||
// Anything this session downloaded rewrote the widget manifest, but
|
||||
// WidgetKit still shows the timeline it built earlier. Leaving the app is
|
||||
// the moment the home screen becomes visible again.
|
||||
unawaited(HomeWidgetService.refresh());
|
||||
} else if (state == AppLifecycleState.detached) {
|
||||
// Last chance before the engine goes away: hand anything still unsent to
|
||||
// the OS, which delivers it once there is a network again whether or not
|
||||
|
|
|
|||
|
|
@ -55,6 +55,11 @@ enum ApiEventKind {
|
|||
appOutdated,
|
||||
newDeviceRegistered,
|
||||
loginTokenMigrated,
|
||||
|
||||
/// An image shared into a home-screen widget finished downloading. The
|
||||
/// manifest is already rewritten; only the native widgets still have to be
|
||||
/// told to redraw, which is something Flutter has to ask for.
|
||||
widgetMediaReceived,
|
||||
}
|
||||
|
||||
class FrbAdditionalAccount {
|
||||
|
|
@ -361,6 +366,12 @@ class RustApi {
|
|||
static Future<void> deleteMemory({required String mediaId}) =>
|
||||
RustLib.instance.api.crateBridgeApiRustApiDeleteMemory(mediaId: mediaId);
|
||||
|
||||
/// Drops a single image a widget is showing, at the user's request.
|
||||
static Future<void> deleteWidgetMedia({required String mediaId}) => RustLib
|
||||
.instance
|
||||
.api
|
||||
.crateBridgeApiRustApiDeleteWidgetMedia(mediaId: mediaId);
|
||||
|
||||
static Future<void> disableMemoriesBackup() =>
|
||||
RustLib.instance.api.crateBridgeApiRustApiDisableMemoriesBackup();
|
||||
|
||||
|
|
@ -557,6 +568,19 @@ class RustApi {
|
|||
static Future<void> purgeMediaTempFolder() =>
|
||||
RustLib.instance.api.crateBridgeApiRustApiPurgeMediaTempFolder();
|
||||
|
||||
static Future<void> purgeWidgetMedia() =>
|
||||
RustLib.instance.api.crateBridgeApiRustApiPurgeWidgetMedia();
|
||||
|
||||
/// Republishes the widget manifest.
|
||||
///
|
||||
/// The contact groups a widget offers in its configuration UI are read from
|
||||
/// that file, so it has to be rewritten whenever the groups change — not
|
||||
/// only when images arrive. Cheaper than a full permission sync, which this
|
||||
/// deliberately does not do: editing a group does not change which widgets
|
||||
/// are placed.
|
||||
static Future<void> refreshWidgetManifest() =>
|
||||
RustLib.instance.api.crateBridgeApiRustApiRefreshWidgetManifest();
|
||||
|
||||
static Future<PlatformInt64> register({
|
||||
required String username,
|
||||
required PlatformInt64 proofOfWork,
|
||||
|
|
@ -569,6 +593,14 @@ class RustApi {
|
|||
isIos: isIos,
|
||||
);
|
||||
|
||||
static Future<void> registerHomeWidget({
|
||||
required String widgetId,
|
||||
required String platform,
|
||||
}) => RustLib.instance.api.crateBridgeApiRustApiRegisterHomeWidget(
|
||||
widgetId: widgetId,
|
||||
platform: platform,
|
||||
);
|
||||
|
||||
static Future<void> registerPasswordlessNotification({
|
||||
required String notificationId,
|
||||
required List<int> downloadAuthToken,
|
||||
|
|
@ -696,10 +728,12 @@ class RustApi {
|
|||
required String mediaId,
|
||||
required List<String> groupIds,
|
||||
Uint8List? additionalMessageData,
|
||||
required bool widgetOnly,
|
||||
}) => RustLib.instance.api.crateBridgeApiRustApiSendMediaToGroups(
|
||||
mediaId: mediaId,
|
||||
groupIds: groupIds,
|
||||
additionalMessageData: additionalMessageData,
|
||||
widgetOnly: widgetOnly,
|
||||
);
|
||||
|
||||
static Future<void> sendQueuedMessage({required String receiptId}) => RustLib
|
||||
|
|
@ -728,6 +762,16 @@ class RustApi {
|
|||
.api
|
||||
.crateBridgeApiRustApiSetBackground(inBackground: inBackground);
|
||||
|
||||
static Future<void> setHomeWidgetGroups({
|
||||
required String widgetId,
|
||||
required String platform,
|
||||
required Int64List contactGroupIds,
|
||||
}) => RustLib.instance.api.crateBridgeApiRustApiSetHomeWidgetGroups(
|
||||
widgetId: widgetId,
|
||||
platform: platform,
|
||||
contactGroupIds: contactGroupIds,
|
||||
);
|
||||
|
||||
static Future<void> setLoginToken({required List<int> token}) =>
|
||||
RustLib.instance.api.crateBridgeApiRustApiSetLoginToken(token: token);
|
||||
|
||||
|
|
@ -776,6 +820,9 @@ class RustApi {
|
|||
encryptedMessage: encryptedMessage,
|
||||
);
|
||||
|
||||
static Future<void> syncWidgetPermissions() =>
|
||||
RustLib.instance.api.crateBridgeApiRustApiSyncWidgetPermissions();
|
||||
|
||||
static Future<void> toggleMediaRemoveAudio({required String mediaId}) =>
|
||||
RustLib.instance.api.crateBridgeApiRustApiToggleMediaRemoveAudio(
|
||||
mediaId: mediaId,
|
||||
|
|
@ -789,6 +836,11 @@ class RustApi {
|
|||
expectedPublicKey: expectedPublicKey,
|
||||
);
|
||||
|
||||
static Future<void> unregisterHomeWidget({required String widgetId}) =>
|
||||
RustLib.instance.api.crateBridgeApiRustApiUnregisterHomeWidget(
|
||||
widgetId: widgetId,
|
||||
);
|
||||
|
||||
static Future<void> updateFcmToken({required String token}) =>
|
||||
RustLib.instance.api.crateBridgeApiRustApiUpdateFcmToken(token: token);
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -21,6 +21,7 @@ import 'package:twonly/src/providers/image_editor.provider.dart';
|
|||
import 'package:twonly/src/providers/purchases.provider.dart';
|
||||
import 'package:twonly/src/providers/settings.provider.dart';
|
||||
import 'package:twonly/src/services/backup.service.dart';
|
||||
import 'package:twonly/src/services/home_widget.service.dart';
|
||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||
import 'package:twonly/src/services/memories/memories.service.dart';
|
||||
import 'package:twonly/src/services/migrations.service.dart';
|
||||
|
|
@ -288,6 +289,8 @@ Future<void> postStartupTasks() async {
|
|||
await twonlyDB.messagesDao.purgeMessageTable();
|
||||
unawaited(twonlyDB.receiptsDao.purgeReceivedReceipts());
|
||||
unawaited(MediaFileService.purgeTempFolder());
|
||||
unawaited(HomeWidgetService.purgeExpiredMedia());
|
||||
unawaited(HomeWidgetService.syncPermissions());
|
||||
|
||||
// 2. Service initializations
|
||||
unawaitedRustCall(
|
||||
|
|
|
|||
|
|
@ -2,4 +2,6 @@ class KeyValueKeys {
|
|||
static const String currentBackupState = 'current_backup_state';
|
||||
static const String backupRecoveryState = 'backup_recovery_state';
|
||||
static const String onboardingState = 'onboarding_state';
|
||||
static const String shareImageWidgetExplainer =
|
||||
'share_image_widget_explainer';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ class Routes {
|
|||
static const String settingsPrivacyUserDiscovery =
|
||||
'/settings/privacy/user_discovery';
|
||||
static const String settingsNotification = '/settings/notification';
|
||||
static const String settingsWidgets = '/settings/widgets';
|
||||
static const String settingsStorage = '/settings/storage_data';
|
||||
static const String settingsStorageManage = '/settings/storage_data/manage';
|
||||
static const String settingsStorageImport = '/settings/storage_data/import';
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ Future<void> deleteGroup(String groupId) async {
|
|||
groupMembers,
|
||||
)..where((t) => t.groupId.equals(groupId))).get();
|
||||
}
|
||||
|
||||
Future<Group?> createNewGroup(GroupsCompanion group) async {
|
||||
return _insertGroup(group);
|
||||
}
|
||||
|
|
@ -82,6 +83,7 @@ Future<void> deleteGroup(String groupId) async {
|
|||
..orderBy([(t) => OrderingTerm.asc(t.actionAt)]))
|
||||
.watch();
|
||||
}
|
||||
|
||||
Future<Group?> createNewDirectChat(
|
||||
int contactId,
|
||||
GroupsCompanion group,
|
||||
|
|
@ -175,6 +177,26 @@ Future<Group?> createNewDirectChat(
|
|||
.watch();
|
||||
}
|
||||
|
||||
Stream<List<Group>> watchGroupsAllowedForWidgetShare() {
|
||||
final query =
|
||||
select(groups).join([
|
||||
innerJoin(
|
||||
groupMembers,
|
||||
groupMembers.groupId.equalsExp(groups.groupId),
|
||||
),
|
||||
innerJoin(
|
||||
contacts,
|
||||
contacts.userId.equalsExp(groupMembers.contactId),
|
||||
),
|
||||
])..where(
|
||||
groups.isDirectChat.equals(true) &
|
||||
groups.leftGroup.equals(false) &
|
||||
groups.deletedContent.equals(false) &
|
||||
contacts.widgetSharingAllowed.equals(true),
|
||||
);
|
||||
return query.map((row) => row.readTable(groups)).watch();
|
||||
}
|
||||
|
||||
Stream<List<GroupMember>> watchContactGroupMember(int contactId) {
|
||||
return (select(groupMembers)..where(
|
||||
(g) => g.contactId.equals(contactId),
|
||||
|
|
@ -193,6 +215,7 @@ Future<Group?> createNewDirectChat(
|
|||
groups,
|
||||
)..where((t) => t.groupId.equals(groupId))).watchSingleOrNull();
|
||||
}
|
||||
|
||||
Stream<List<Group>> watchGroupsForChatList() {
|
||||
return (select(groups)
|
||||
..where((t) => t.deletedContent.equals(false))
|
||||
|
|
@ -231,6 +254,7 @@ Stream<List<Group>> watchGroupsForChatList() {
|
|||
Future<List<Group>> getAllGroups() {
|
||||
return select(groups).get();
|
||||
}
|
||||
|
||||
Future<Group?> getDirectChat(int userId) async {
|
||||
final query =
|
||||
((select(groups)..where((t) => t.isDirectChat.equals(true))).join([
|
||||
|
|
@ -242,6 +266,7 @@ Future<Group?> getDirectChat(int userId) async {
|
|||
|
||||
return query.map((row) => row.readTable(groups)).getSingleOrNull();
|
||||
}
|
||||
|
||||
Stream<int> watchSumTotalMediaCounter() {
|
||||
final query = selectOnly(groups)
|
||||
..addColumns([groups.totalMediaCounter.sum()]);
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
|||
mediaFiles,
|
||||
)..where((t) => t.mediaId.equals(mediaId))).watchSingleOrNull();
|
||||
}
|
||||
|
||||
Future<List<MediaFile>> getAllMediaFilesPendingDownload() async {
|
||||
return (select(mediaFiles)..where(
|
||||
(t) =>
|
||||
|
|
@ -144,6 +145,7 @@ Future<List<MediaFile>> getAllMediaFilesPendingDownload() async {
|
|||
]);
|
||||
return query.map((row) => row.readTable(mediaFiles)).watch();
|
||||
}
|
||||
|
||||
Stream<List<MediaFile>> watchMediaFilesByIds(Set<String> mediaIds) {
|
||||
if (mediaIds.isEmpty) return Stream.value(const []);
|
||||
return (select(
|
||||
|
|
@ -152,7 +154,8 @@ Stream<List<MediaFile>> watchMediaFilesByIds(Set<String> mediaIds) {
|
|||
}
|
||||
|
||||
Stream<List<MediaFile>> watchMediaFilesForGroup(String groupId) {
|
||||
final query = select(mediaFiles).join([
|
||||
final query =
|
||||
select(mediaFiles).join([
|
||||
innerJoin(
|
||||
db.messages,
|
||||
db.messages.mediaId.equalsExp(mediaFiles.mediaId),
|
||||
|
|
@ -201,6 +204,7 @@ Stream<List<MediaFile>> watchMediaFilesByIds(Set<String> mediaIds) {
|
|||
])..where(
|
||||
mediaFiles.storedFileHash.equals(hash) &
|
||||
db.messages.senderId.equals(senderId) &
|
||||
db.messages.isWidgetMedia.equals(false) &
|
||||
db.messages.openedAt.isNull(),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
])
|
||||
..where(
|
||||
messages.openedAt.isNull() &
|
||||
messages.isWidgetMedia.equals(false) &
|
||||
messages.groupId.equals(groupId) &
|
||||
messages.isDeletedFromSender.equals(false) &
|
||||
(messages.mediaId.isNull() |
|
||||
|
|
@ -63,6 +64,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
])
|
||||
..where(
|
||||
messages.openedAt.isNull() &
|
||||
messages.isWidgetMedia.equals(false) &
|
||||
messages.isDeletedFromSender.equals(false) &
|
||||
(messages.mediaId.isNull() |
|
||||
mediaFiles.downloadState.isNull() |
|
||||
|
|
@ -89,6 +91,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
.not()) &
|
||||
mediaFiles.type.equals(MediaType.audio.name).not() &
|
||||
messages.openedAt.isNull() &
|
||||
messages.isWidgetMedia.equals(false) &
|
||||
messages.groupId.equals(groupId) &
|
||||
messages.mediaId.isNotNull() &
|
||||
messages.senderId.isNotNull() &
|
||||
|
|
@ -107,6 +110,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
),
|
||||
])..where(
|
||||
messages.openedAt.isNull() &
|
||||
messages.isWidgetMedia.equals(false) &
|
||||
messages.mediaId.isNotNull() &
|
||||
messages.type.equals(MessageType.media.name) &
|
||||
mediaFiles.downloadState.equals(DownloadState.ready.name) &
|
||||
|
|
@ -285,6 +289,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
.map((row) => (row.readTable(groupMembers), row.readTable(contacts)))
|
||||
.watch();
|
||||
}
|
||||
|
||||
Future<void> purgeMessageTable() async {
|
||||
final allGroups = await select(groups).get();
|
||||
|
||||
|
|
@ -622,6 +627,7 @@ Future<void> purgeMessageTable() async {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteMessagesById(String messageId) {
|
||||
return (delete(messages)..where((t) => t.messageId.equals(messageId))).go();
|
||||
}
|
||||
|
|
@ -665,6 +671,7 @@ Future<void> deleteMessagesById(String messageId) {
|
|||
))
|
||||
.watch();
|
||||
}
|
||||
|
||||
Stream<List<MessageHistory>> watchMessageHistory(String messageId) {
|
||||
return (select(messageHistories)
|
||||
..where((t) => t.messageId.equals(messageId))
|
||||
|
|
|
|||
|
|
@ -48,6 +48,11 @@ class Contacts extends Table {
|
|||
|
||||
BoolColumn get askForFriendPromotions => boolean().nullable()();
|
||||
|
||||
BoolColumn get widgetSharingAllowed =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
BoolColumn get widgetSharingGranted =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
|
||||
IntColumn get mediaSendCounter => integer().withDefault(const Constant(0))();
|
||||
IntColumn get mediaReceivedCounter =>
|
||||
integer().withDefault(const Constant(0))();
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ class MediaFiles extends Table {
|
|||
|
||||
BoolColumn get stored => boolean().withDefault(const Constant(false))();
|
||||
BoolColumn get isDraftMedia => boolean().withDefault(const Constant(false))();
|
||||
BoolColumn get isWidgetMedia =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
BoolColumn get isFavorite => boolean().withDefault(const Constant(false))();
|
||||
BoolColumn get hasCropAnalyzed =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ class Messages extends Table {
|
|||
|
||||
BoolColumn get isDeletedFromSender =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
BoolColumn get isWidgetMedia =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
|
||||
DateTimeColumn get openedAt => dateTime().nullable()();
|
||||
DateTimeColumn get openedByAll => dateTime().nullable()();
|
||||
|
|
|
|||
|
|
@ -309,6 +309,34 @@ class $ContactsTable extends Contacts with TableInfo<$ContactsTable, Contact> {
|
|||
'CHECK ("ask_for_friend_promotions" IN (0, 1))',
|
||||
),
|
||||
);
|
||||
static const VerificationMeta _widgetSharingAllowedMeta =
|
||||
const VerificationMeta('widgetSharingAllowed');
|
||||
@override
|
||||
late final GeneratedColumn<bool> widgetSharingAllowed = GeneratedColumn<bool>(
|
||||
'widget_sharing_allowed',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.bool,
|
||||
requiredDuringInsert: false,
|
||||
defaultConstraints: GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("widget_sharing_allowed" IN (0, 1))',
|
||||
),
|
||||
defaultValue: const Constant(false),
|
||||
);
|
||||
static const VerificationMeta _widgetSharingGrantedMeta =
|
||||
const VerificationMeta('widgetSharingGranted');
|
||||
@override
|
||||
late final GeneratedColumn<bool> widgetSharingGranted = GeneratedColumn<bool>(
|
||||
'widget_sharing_granted',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.bool,
|
||||
requiredDuringInsert: false,
|
||||
defaultConstraints: GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("widget_sharing_granted" IN (0, 1))',
|
||||
),
|
||||
defaultValue: const Constant(false),
|
||||
);
|
||||
static const VerificationMeta _mediaSendCounterMeta = const VerificationMeta(
|
||||
'mediaSendCounter',
|
||||
);
|
||||
|
|
@ -358,6 +386,8 @@ class $ContactsTable extends Contacts with TableInfo<$ContactsTable, Contact> {
|
|||
recoveryContactsLastHeartbeat,
|
||||
recoveryContactsThreshold,
|
||||
askForFriendPromotions,
|
||||
widgetSharingAllowed,
|
||||
widgetSharingGranted,
|
||||
mediaSendCounter,
|
||||
mediaReceivedCounter,
|
||||
];
|
||||
|
|
@ -558,6 +588,24 @@ class $ContactsTable extends Contacts with TableInfo<$ContactsTable, Contact> {
|
|||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('widget_sharing_allowed')) {
|
||||
context.handle(
|
||||
_widgetSharingAllowedMeta,
|
||||
widgetSharingAllowed.isAcceptableOrUnknown(
|
||||
data['widget_sharing_allowed']!,
|
||||
_widgetSharingAllowedMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('widget_sharing_granted')) {
|
||||
context.handle(
|
||||
_widgetSharingGrantedMeta,
|
||||
widgetSharingGranted.isAcceptableOrUnknown(
|
||||
data['widget_sharing_granted']!,
|
||||
_widgetSharingGrantedMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('media_send_counter')) {
|
||||
context.handle(
|
||||
_mediaSendCounterMeta,
|
||||
|
|
@ -683,6 +731,14 @@ class $ContactsTable extends Contacts with TableInfo<$ContactsTable, Contact> {
|
|||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}ask_for_friend_promotions'],
|
||||
),
|
||||
widgetSharingAllowed: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}widget_sharing_allowed'],
|
||||
)!,
|
||||
widgetSharingGranted: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}widget_sharing_granted'],
|
||||
)!,
|
||||
mediaSendCounter: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}media_send_counter'],
|
||||
|
|
@ -730,6 +786,8 @@ class Contact extends DataClass implements Insertable<Contact> {
|
|||
final DateTime? recoveryContactsLastHeartbeat;
|
||||
final int? recoveryContactsThreshold;
|
||||
final bool? askForFriendPromotions;
|
||||
final bool widgetSharingAllowed;
|
||||
final bool widgetSharingGranted;
|
||||
final int mediaSendCounter;
|
||||
final int mediaReceivedCounter;
|
||||
const Contact({
|
||||
|
|
@ -757,6 +815,8 @@ class Contact extends DataClass implements Insertable<Contact> {
|
|||
this.recoveryContactsLastHeartbeat,
|
||||
this.recoveryContactsThreshold,
|
||||
this.askForFriendPromotions,
|
||||
required this.widgetSharingAllowed,
|
||||
required this.widgetSharingGranted,
|
||||
required this.mediaSendCounter,
|
||||
required this.mediaReceivedCounter,
|
||||
});
|
||||
|
|
@ -823,6 +883,8 @@ class Contact extends DataClass implements Insertable<Contact> {
|
|||
if (!nullToAbsent || askForFriendPromotions != null) {
|
||||
map['ask_for_friend_promotions'] = Variable<bool>(askForFriendPromotions);
|
||||
}
|
||||
map['widget_sharing_allowed'] = Variable<bool>(widgetSharingAllowed);
|
||||
map['widget_sharing_granted'] = Variable<bool>(widgetSharingGranted);
|
||||
map['media_send_counter'] = Variable<int>(mediaSendCounter);
|
||||
map['media_received_counter'] = Variable<int>(mediaReceivedCounter);
|
||||
return map;
|
||||
|
|
@ -880,6 +942,8 @@ class Contact extends DataClass implements Insertable<Contact> {
|
|||
askForFriendPromotions: askForFriendPromotions == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(askForFriendPromotions),
|
||||
widgetSharingAllowed: Value(widgetSharingAllowed),
|
||||
widgetSharingGranted: Value(widgetSharingGranted),
|
||||
mediaSendCounter: Value(mediaSendCounter),
|
||||
mediaReceivedCounter: Value(mediaReceivedCounter),
|
||||
);
|
||||
|
|
@ -941,6 +1005,12 @@ class Contact extends DataClass implements Insertable<Contact> {
|
|||
askForFriendPromotions: serializer.fromJson<bool?>(
|
||||
json['askForFriendPromotions'],
|
||||
),
|
||||
widgetSharingAllowed: serializer.fromJson<bool>(
|
||||
json['widgetSharingAllowed'],
|
||||
),
|
||||
widgetSharingGranted: serializer.fromJson<bool>(
|
||||
json['widgetSharingGranted'],
|
||||
),
|
||||
mediaSendCounter: serializer.fromJson<int>(json['mediaSendCounter']),
|
||||
mediaReceivedCounter: serializer.fromJson<int>(
|
||||
json['mediaReceivedCounter'],
|
||||
|
|
@ -993,6 +1063,8 @@ class Contact extends DataClass implements Insertable<Contact> {
|
|||
'askForFriendPromotions': serializer.toJson<bool?>(
|
||||
askForFriendPromotions,
|
||||
),
|
||||
'widgetSharingAllowed': serializer.toJson<bool>(widgetSharingAllowed),
|
||||
'widgetSharingGranted': serializer.toJson<bool>(widgetSharingGranted),
|
||||
'mediaSendCounter': serializer.toJson<int>(mediaSendCounter),
|
||||
'mediaReceivedCounter': serializer.toJson<int>(mediaReceivedCounter),
|
||||
};
|
||||
|
|
@ -1023,6 +1095,8 @@ class Contact extends DataClass implements Insertable<Contact> {
|
|||
Value<DateTime?> recoveryContactsLastHeartbeat = const Value.absent(),
|
||||
Value<int?> recoveryContactsThreshold = const Value.absent(),
|
||||
Value<bool?> askForFriendPromotions = const Value.absent(),
|
||||
bool? widgetSharingAllowed,
|
||||
bool? widgetSharingGranted,
|
||||
int? mediaSendCounter,
|
||||
int? mediaReceivedCounter,
|
||||
}) => Contact(
|
||||
|
|
@ -1069,6 +1143,8 @@ class Contact extends DataClass implements Insertable<Contact> {
|
|||
askForFriendPromotions: askForFriendPromotions.present
|
||||
? askForFriendPromotions.value
|
||||
: this.askForFriendPromotions,
|
||||
widgetSharingAllowed: widgetSharingAllowed ?? this.widgetSharingAllowed,
|
||||
widgetSharingGranted: widgetSharingGranted ?? this.widgetSharingGranted,
|
||||
mediaSendCounter: mediaSendCounter ?? this.mediaSendCounter,
|
||||
mediaReceivedCounter: mediaReceivedCounter ?? this.mediaReceivedCounter,
|
||||
);
|
||||
|
|
@ -1130,6 +1206,12 @@ class Contact extends DataClass implements Insertable<Contact> {
|
|||
askForFriendPromotions: data.askForFriendPromotions.present
|
||||
? data.askForFriendPromotions.value
|
||||
: this.askForFriendPromotions,
|
||||
widgetSharingAllowed: data.widgetSharingAllowed.present
|
||||
? data.widgetSharingAllowed.value
|
||||
: this.widgetSharingAllowed,
|
||||
widgetSharingGranted: data.widgetSharingGranted.present
|
||||
? data.widgetSharingGranted.value
|
||||
: this.widgetSharingGranted,
|
||||
mediaSendCounter: data.mediaSendCounter.present
|
||||
? data.mediaSendCounter.value
|
||||
: this.mediaSendCounter,
|
||||
|
|
@ -1168,6 +1250,8 @@ class Contact extends DataClass implements Insertable<Contact> {
|
|||
)
|
||||
..write('recoveryContactsThreshold: $recoveryContactsThreshold, ')
|
||||
..write('askForFriendPromotions: $askForFriendPromotions, ')
|
||||
..write('widgetSharingAllowed: $widgetSharingAllowed, ')
|
||||
..write('widgetSharingGranted: $widgetSharingGranted, ')
|
||||
..write('mediaSendCounter: $mediaSendCounter, ')
|
||||
..write('mediaReceivedCounter: $mediaReceivedCounter')
|
||||
..write(')'))
|
||||
|
|
@ -1200,6 +1284,8 @@ class Contact extends DataClass implements Insertable<Contact> {
|
|||
recoveryContactsLastHeartbeat,
|
||||
recoveryContactsThreshold,
|
||||
askForFriendPromotions,
|
||||
widgetSharingAllowed,
|
||||
widgetSharingGranted,
|
||||
mediaSendCounter,
|
||||
mediaReceivedCounter,
|
||||
]);
|
||||
|
|
@ -1245,6 +1331,8 @@ class Contact extends DataClass implements Insertable<Contact> {
|
|||
this.recoveryContactsLastHeartbeat &&
|
||||
other.recoveryContactsThreshold == this.recoveryContactsThreshold &&
|
||||
other.askForFriendPromotions == this.askForFriendPromotions &&
|
||||
other.widgetSharingAllowed == this.widgetSharingAllowed &&
|
||||
other.widgetSharingGranted == this.widgetSharingGranted &&
|
||||
other.mediaSendCounter == this.mediaSendCounter &&
|
||||
other.mediaReceivedCounter == this.mediaReceivedCounter);
|
||||
}
|
||||
|
|
@ -1274,6 +1362,8 @@ class ContactsCompanion extends UpdateCompanion<Contact> {
|
|||
final Value<DateTime?> recoveryContactsLastHeartbeat;
|
||||
final Value<int?> recoveryContactsThreshold;
|
||||
final Value<bool?> askForFriendPromotions;
|
||||
final Value<bool> widgetSharingAllowed;
|
||||
final Value<bool> widgetSharingGranted;
|
||||
final Value<int> mediaSendCounter;
|
||||
final Value<int> mediaReceivedCounter;
|
||||
const ContactsCompanion({
|
||||
|
|
@ -1301,6 +1391,8 @@ class ContactsCompanion extends UpdateCompanion<Contact> {
|
|||
this.recoveryContactsLastHeartbeat = const Value.absent(),
|
||||
this.recoveryContactsThreshold = const Value.absent(),
|
||||
this.askForFriendPromotions = const Value.absent(),
|
||||
this.widgetSharingAllowed = const Value.absent(),
|
||||
this.widgetSharingGranted = const Value.absent(),
|
||||
this.mediaSendCounter = const Value.absent(),
|
||||
this.mediaReceivedCounter = const Value.absent(),
|
||||
});
|
||||
|
|
@ -1329,6 +1421,8 @@ class ContactsCompanion extends UpdateCompanion<Contact> {
|
|||
this.recoveryContactsLastHeartbeat = const Value.absent(),
|
||||
this.recoveryContactsThreshold = const Value.absent(),
|
||||
this.askForFriendPromotions = const Value.absent(),
|
||||
this.widgetSharingAllowed = const Value.absent(),
|
||||
this.widgetSharingGranted = const Value.absent(),
|
||||
this.mediaSendCounter = const Value.absent(),
|
||||
this.mediaReceivedCounter = const Value.absent(),
|
||||
}) : username = Value(username);
|
||||
|
|
@ -1357,6 +1451,8 @@ class ContactsCompanion extends UpdateCompanion<Contact> {
|
|||
Expression<DateTime>? recoveryContactsLastHeartbeat,
|
||||
Expression<int>? recoveryContactsThreshold,
|
||||
Expression<bool>? askForFriendPromotions,
|
||||
Expression<bool>? widgetSharingAllowed,
|
||||
Expression<bool>? widgetSharingGranted,
|
||||
Expression<int>? mediaSendCounter,
|
||||
Expression<int>? mediaReceivedCounter,
|
||||
}) {
|
||||
|
|
@ -1397,6 +1493,10 @@ class ContactsCompanion extends UpdateCompanion<Contact> {
|
|||
'recovery_contacts_threshold': recoveryContactsThreshold,
|
||||
if (askForFriendPromotions != null)
|
||||
'ask_for_friend_promotions': askForFriendPromotions,
|
||||
if (widgetSharingAllowed != null)
|
||||
'widget_sharing_allowed': widgetSharingAllowed,
|
||||
if (widgetSharingGranted != null)
|
||||
'widget_sharing_granted': widgetSharingGranted,
|
||||
if (mediaSendCounter != null) 'media_send_counter': mediaSendCounter,
|
||||
if (mediaReceivedCounter != null)
|
||||
'media_received_counter': mediaReceivedCounter,
|
||||
|
|
@ -1428,6 +1528,8 @@ class ContactsCompanion extends UpdateCompanion<Contact> {
|
|||
Value<DateTime?>? recoveryContactsLastHeartbeat,
|
||||
Value<int?>? recoveryContactsThreshold,
|
||||
Value<bool?>? askForFriendPromotions,
|
||||
Value<bool>? widgetSharingAllowed,
|
||||
Value<bool>? widgetSharingGranted,
|
||||
Value<int>? mediaSendCounter,
|
||||
Value<int>? mediaReceivedCounter,
|
||||
}) {
|
||||
|
|
@ -1464,6 +1566,8 @@ class ContactsCompanion extends UpdateCompanion<Contact> {
|
|||
recoveryContactsThreshold ?? this.recoveryContactsThreshold,
|
||||
askForFriendPromotions:
|
||||
askForFriendPromotions ?? this.askForFriendPromotions,
|
||||
widgetSharingAllowed: widgetSharingAllowed ?? this.widgetSharingAllowed,
|
||||
widgetSharingGranted: widgetSharingGranted ?? this.widgetSharingGranted,
|
||||
mediaSendCounter: mediaSendCounter ?? this.mediaSendCounter,
|
||||
mediaReceivedCounter: mediaReceivedCounter ?? this.mediaReceivedCounter,
|
||||
);
|
||||
|
|
@ -1568,6 +1672,16 @@ class ContactsCompanion extends UpdateCompanion<Contact> {
|
|||
askForFriendPromotions.value,
|
||||
);
|
||||
}
|
||||
if (widgetSharingAllowed.present) {
|
||||
map['widget_sharing_allowed'] = Variable<bool>(
|
||||
widgetSharingAllowed.value,
|
||||
);
|
||||
}
|
||||
if (widgetSharingGranted.present) {
|
||||
map['widget_sharing_granted'] = Variable<bool>(
|
||||
widgetSharingGranted.value,
|
||||
);
|
||||
}
|
||||
if (mediaSendCounter.present) {
|
||||
map['media_send_counter'] = Variable<int>(mediaSendCounter.value);
|
||||
}
|
||||
|
|
@ -1606,6 +1720,8 @@ class ContactsCompanion extends UpdateCompanion<Contact> {
|
|||
)
|
||||
..write('recoveryContactsThreshold: $recoveryContactsThreshold, ')
|
||||
..write('askForFriendPromotions: $askForFriendPromotions, ')
|
||||
..write('widgetSharingAllowed: $widgetSharingAllowed, ')
|
||||
..write('widgetSharingGranted: $widgetSharingGranted, ')
|
||||
..write('mediaSendCounter: $mediaSendCounter, ')
|
||||
..write('mediaReceivedCounter: $mediaReceivedCounter')
|
||||
..write(')'))
|
||||
|
|
@ -3223,6 +3339,21 @@ class $MediaFilesTable extends MediaFiles
|
|||
),
|
||||
defaultValue: const Constant(false),
|
||||
);
|
||||
static const VerificationMeta _isWidgetMediaMeta = const VerificationMeta(
|
||||
'isWidgetMedia',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<bool> isWidgetMedia = GeneratedColumn<bool>(
|
||||
'is_widget_media',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.bool,
|
||||
requiredDuringInsert: false,
|
||||
defaultConstraints: GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("is_widget_media" IN (0, 1))',
|
||||
),
|
||||
defaultValue: const Constant(false),
|
||||
);
|
||||
static const VerificationMeta _isFavoriteMeta = const VerificationMeta(
|
||||
'isFavorite',
|
||||
);
|
||||
|
|
@ -3439,6 +3570,7 @@ class $MediaFilesTable extends MediaFiles
|
|||
requiresAuthentication,
|
||||
stored,
|
||||
isDraftMedia,
|
||||
isWidgetMedia,
|
||||
isFavorite,
|
||||
hasCropAnalyzed,
|
||||
preProgressingProcess,
|
||||
|
|
@ -3507,6 +3639,15 @@ class $MediaFilesTable extends MediaFiles
|
|||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('is_widget_media')) {
|
||||
context.handle(
|
||||
_isWidgetMediaMeta,
|
||||
isWidgetMedia.isAcceptableOrUnknown(
|
||||
data['is_widget_media']!,
|
||||
_isWidgetMediaMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('is_favorite')) {
|
||||
context.handle(
|
||||
_isFavoriteMeta,
|
||||
|
|
@ -3695,6 +3836,10 @@ class $MediaFilesTable extends MediaFiles
|
|||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}is_draft_media'],
|
||||
)!,
|
||||
isWidgetMedia: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}is_widget_media'],
|
||||
)!,
|
||||
isFavorite: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}is_favorite'],
|
||||
|
|
@ -3806,6 +3951,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
final bool requiresAuthentication;
|
||||
final bool stored;
|
||||
final bool isDraftMedia;
|
||||
final bool isWidgetMedia;
|
||||
final bool isFavorite;
|
||||
final bool hasCropAnalyzed;
|
||||
final int? preProgressingProcess;
|
||||
|
|
@ -3838,6 +3984,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
required this.requiresAuthentication,
|
||||
required this.stored,
|
||||
required this.isDraftMedia,
|
||||
required this.isWidgetMedia,
|
||||
required this.isFavorite,
|
||||
required this.hasCropAnalyzed,
|
||||
this.preProgressingProcess,
|
||||
|
|
@ -3886,6 +4033,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
map['requires_authentication'] = Variable<bool>(requiresAuthentication);
|
||||
map['stored'] = Variable<bool>(stored);
|
||||
map['is_draft_media'] = Variable<bool>(isDraftMedia);
|
||||
map['is_widget_media'] = Variable<bool>(isWidgetMedia);
|
||||
map['is_favorite'] = Variable<bool>(isFavorite);
|
||||
map['has_crop_analyzed'] = Variable<bool>(hasCropAnalyzed);
|
||||
if (!nullToAbsent || preProgressingProcess != null) {
|
||||
|
|
@ -3955,6 +4103,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
requiresAuthentication: Value(requiresAuthentication),
|
||||
stored: Value(stored),
|
||||
isDraftMedia: Value(isDraftMedia),
|
||||
isWidgetMedia: Value(isWidgetMedia),
|
||||
isFavorite: Value(isFavorite),
|
||||
hasCropAnalyzed: Value(hasCropAnalyzed),
|
||||
preProgressingProcess: preProgressingProcess == null && nullToAbsent
|
||||
|
|
@ -4027,6 +4176,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
),
|
||||
stored: serializer.fromJson<bool>(json['stored']),
|
||||
isDraftMedia: serializer.fromJson<bool>(json['isDraftMedia']),
|
||||
isWidgetMedia: serializer.fromJson<bool>(json['isWidgetMedia']),
|
||||
isFavorite: serializer.fromJson<bool>(json['isFavorite']),
|
||||
hasCropAnalyzed: serializer.fromJson<bool>(json['hasCropAnalyzed']),
|
||||
preProgressingProcess: serializer.fromJson<int?>(
|
||||
|
|
@ -4073,6 +4223,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
'requiresAuthentication': serializer.toJson<bool>(requiresAuthentication),
|
||||
'stored': serializer.toJson<bool>(stored),
|
||||
'isDraftMedia': serializer.toJson<bool>(isDraftMedia),
|
||||
'isWidgetMedia': serializer.toJson<bool>(isWidgetMedia),
|
||||
'isFavorite': serializer.toJson<bool>(isFavorite),
|
||||
'hasCropAnalyzed': serializer.toJson<bool>(hasCropAnalyzed),
|
||||
'preProgressingProcess': serializer.toJson<int?>(preProgressingProcess),
|
||||
|
|
@ -4105,6 +4256,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
bool? requiresAuthentication,
|
||||
bool? stored,
|
||||
bool? isDraftMedia,
|
||||
bool? isWidgetMedia,
|
||||
bool? isFavorite,
|
||||
bool? hasCropAnalyzed,
|
||||
Value<int?> preProgressingProcess = const Value.absent(),
|
||||
|
|
@ -4135,6 +4287,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
requiresAuthentication ?? this.requiresAuthentication,
|
||||
stored: stored ?? this.stored,
|
||||
isDraftMedia: isDraftMedia ?? this.isDraftMedia,
|
||||
isWidgetMedia: isWidgetMedia ?? this.isWidgetMedia,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
hasCropAnalyzed: hasCropAnalyzed ?? this.hasCropAnalyzed,
|
||||
preProgressingProcess: preProgressingProcess.present
|
||||
|
|
@ -4192,6 +4345,9 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
isDraftMedia: data.isDraftMedia.present
|
||||
? data.isDraftMedia.value
|
||||
: this.isDraftMedia,
|
||||
isWidgetMedia: data.isWidgetMedia.present
|
||||
? data.isWidgetMedia.value
|
||||
: this.isWidgetMedia,
|
||||
isFavorite: data.isFavorite.present
|
||||
? data.isFavorite.value
|
||||
: this.isFavorite,
|
||||
|
|
@ -4254,6 +4410,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
..write('requiresAuthentication: $requiresAuthentication, ')
|
||||
..write('stored: $stored, ')
|
||||
..write('isDraftMedia: $isDraftMedia, ')
|
||||
..write('isWidgetMedia: $isWidgetMedia, ')
|
||||
..write('isFavorite: $isFavorite, ')
|
||||
..write('hasCropAnalyzed: $hasCropAnalyzed, ')
|
||||
..write('preProgressingProcess: $preProgressingProcess, ')
|
||||
|
|
@ -4286,6 +4443,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
requiresAuthentication,
|
||||
stored,
|
||||
isDraftMedia,
|
||||
isWidgetMedia,
|
||||
isFavorite,
|
||||
hasCropAnalyzed,
|
||||
preProgressingProcess,
|
||||
|
|
@ -4317,6 +4475,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
other.requiresAuthentication == this.requiresAuthentication &&
|
||||
other.stored == this.stored &&
|
||||
other.isDraftMedia == this.isDraftMedia &&
|
||||
other.isWidgetMedia == this.isWidgetMedia &&
|
||||
other.isFavorite == this.isFavorite &&
|
||||
other.hasCropAnalyzed == this.hasCropAnalyzed &&
|
||||
other.preProgressingProcess == this.preProgressingProcess &&
|
||||
|
|
@ -4352,6 +4511,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
final Value<bool> requiresAuthentication;
|
||||
final Value<bool> stored;
|
||||
final Value<bool> isDraftMedia;
|
||||
final Value<bool> isWidgetMedia;
|
||||
final Value<bool> isFavorite;
|
||||
final Value<bool> hasCropAnalyzed;
|
||||
final Value<int?> preProgressingProcess;
|
||||
|
|
@ -4380,6 +4540,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
this.requiresAuthentication = const Value.absent(),
|
||||
this.stored = const Value.absent(),
|
||||
this.isDraftMedia = const Value.absent(),
|
||||
this.isWidgetMedia = const Value.absent(),
|
||||
this.isFavorite = const Value.absent(),
|
||||
this.hasCropAnalyzed = const Value.absent(),
|
||||
this.preProgressingProcess = const Value.absent(),
|
||||
|
|
@ -4409,6 +4570,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
this.requiresAuthentication = const Value.absent(),
|
||||
this.stored = const Value.absent(),
|
||||
this.isDraftMedia = const Value.absent(),
|
||||
this.isWidgetMedia = const Value.absent(),
|
||||
this.isFavorite = const Value.absent(),
|
||||
this.hasCropAnalyzed = const Value.absent(),
|
||||
this.preProgressingProcess = const Value.absent(),
|
||||
|
|
@ -4439,6 +4601,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
Expression<bool>? requiresAuthentication,
|
||||
Expression<bool>? stored,
|
||||
Expression<bool>? isDraftMedia,
|
||||
Expression<bool>? isWidgetMedia,
|
||||
Expression<bool>? isFavorite,
|
||||
Expression<bool>? hasCropAnalyzed,
|
||||
Expression<int>? preProgressingProcess,
|
||||
|
|
@ -4469,6 +4632,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
'requires_authentication': requiresAuthentication,
|
||||
if (stored != null) 'stored': stored,
|
||||
if (isDraftMedia != null) 'is_draft_media': isDraftMedia,
|
||||
if (isWidgetMedia != null) 'is_widget_media': isWidgetMedia,
|
||||
if (isFavorite != null) 'is_favorite': isFavorite,
|
||||
if (hasCropAnalyzed != null) 'has_crop_analyzed': hasCropAnalyzed,
|
||||
if (preProgressingProcess != null)
|
||||
|
|
@ -4503,6 +4667,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
Value<bool>? requiresAuthentication,
|
||||
Value<bool>? stored,
|
||||
Value<bool>? isDraftMedia,
|
||||
Value<bool>? isWidgetMedia,
|
||||
Value<bool>? isFavorite,
|
||||
Value<bool>? hasCropAnalyzed,
|
||||
Value<int?>? preProgressingProcess,
|
||||
|
|
@ -4533,6 +4698,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
requiresAuthentication ?? this.requiresAuthentication,
|
||||
stored: stored ?? this.stored,
|
||||
isDraftMedia: isDraftMedia ?? this.isDraftMedia,
|
||||
isWidgetMedia: isWidgetMedia ?? this.isWidgetMedia,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
hasCropAnalyzed: hasCropAnalyzed ?? this.hasCropAnalyzed,
|
||||
preProgressingProcess:
|
||||
|
|
@ -4596,6 +4762,9 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
if (isDraftMedia.present) {
|
||||
map['is_draft_media'] = Variable<bool>(isDraftMedia.value);
|
||||
}
|
||||
if (isWidgetMedia.present) {
|
||||
map['is_widget_media'] = Variable<bool>(isWidgetMedia.value);
|
||||
}
|
||||
if (isFavorite.present) {
|
||||
map['is_favorite'] = Variable<bool>(isFavorite.value);
|
||||
}
|
||||
|
|
@ -4673,6 +4842,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
..write('requiresAuthentication: $requiresAuthentication, ')
|
||||
..write('stored: $stored, ')
|
||||
..write('isDraftMedia: $isDraftMedia, ')
|
||||
..write('isWidgetMedia: $isWidgetMedia, ')
|
||||
..write('isFavorite: $isFavorite, ')
|
||||
..write('hasCropAnalyzed: $hasCropAnalyzed, ')
|
||||
..write('preProgressingProcess: $preProgressingProcess, ')
|
||||
|
|
@ -4852,6 +5022,21 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> {
|
|||
),
|
||||
defaultValue: const Constant(false),
|
||||
);
|
||||
static const VerificationMeta _isWidgetMediaMeta = const VerificationMeta(
|
||||
'isWidgetMedia',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<bool> isWidgetMedia = GeneratedColumn<bool>(
|
||||
'is_widget_media',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.bool,
|
||||
requiredDuringInsert: false,
|
||||
defaultConstraints: GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("is_widget_media" IN (0, 1))',
|
||||
),
|
||||
defaultValue: const Constant(false),
|
||||
);
|
||||
static const VerificationMeta _openedAtMeta = const VerificationMeta(
|
||||
'openedAt',
|
||||
);
|
||||
|
|
@ -4933,6 +5118,7 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> {
|
|||
downloadToken,
|
||||
quotesMessageId,
|
||||
isDeletedFromSender,
|
||||
isWidgetMedia,
|
||||
openedAt,
|
||||
openedByAll,
|
||||
createdAt,
|
||||
|
|
@ -5048,6 +5234,15 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> {
|
|||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('is_widget_media')) {
|
||||
context.handle(
|
||||
_isWidgetMediaMeta,
|
||||
isWidgetMedia.isAcceptableOrUnknown(
|
||||
data['is_widget_media']!,
|
||||
_isWidgetMediaMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('opened_at')) {
|
||||
context.handle(
|
||||
_openedAtMeta,
|
||||
|
|
@ -5147,6 +5342,10 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> {
|
|||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}is_deleted_from_sender'],
|
||||
)!,
|
||||
isWidgetMedia: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}is_widget_media'],
|
||||
)!,
|
||||
openedAt: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.dateTime,
|
||||
data['${effectivePrefix}opened_at'],
|
||||
|
|
@ -5193,6 +5392,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
final Uint8List? downloadToken;
|
||||
final String? quotesMessageId;
|
||||
final bool isDeletedFromSender;
|
||||
final bool isWidgetMedia;
|
||||
final DateTime? openedAt;
|
||||
final DateTime? openedByAll;
|
||||
final DateTime createdAt;
|
||||
|
|
@ -5212,6 +5412,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
this.downloadToken,
|
||||
this.quotesMessageId,
|
||||
required this.isDeletedFromSender,
|
||||
required this.isWidgetMedia,
|
||||
this.openedAt,
|
||||
this.openedByAll,
|
||||
required this.createdAt,
|
||||
|
|
@ -5248,6 +5449,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
map['quotes_message_id'] = Variable<String>(quotesMessageId);
|
||||
}
|
||||
map['is_deleted_from_sender'] = Variable<bool>(isDeletedFromSender);
|
||||
map['is_widget_media'] = Variable<bool>(isWidgetMedia);
|
||||
if (!nullToAbsent || openedAt != null) {
|
||||
map['opened_at'] = Variable<DateTime>(openedAt);
|
||||
}
|
||||
|
|
@ -5293,6 +5495,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
? const Value.absent()
|
||||
: Value(quotesMessageId),
|
||||
isDeletedFromSender: Value(isDeletedFromSender),
|
||||
isWidgetMedia: Value(isWidgetMedia),
|
||||
openedAt: openedAt == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(openedAt),
|
||||
|
|
@ -5334,6 +5537,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
isDeletedFromSender: serializer.fromJson<bool>(
|
||||
json['isDeletedFromSender'],
|
||||
),
|
||||
isWidgetMedia: serializer.fromJson<bool>(json['isWidgetMedia']),
|
||||
openedAt: serializer.fromJson<DateTime?>(json['openedAt']),
|
||||
openedByAll: serializer.fromJson<DateTime?>(json['openedByAll']),
|
||||
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
|
||||
|
|
@ -5360,6 +5564,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
'downloadToken': serializer.toJson<Uint8List?>(downloadToken),
|
||||
'quotesMessageId': serializer.toJson<String?>(quotesMessageId),
|
||||
'isDeletedFromSender': serializer.toJson<bool>(isDeletedFromSender),
|
||||
'isWidgetMedia': serializer.toJson<bool>(isWidgetMedia),
|
||||
'openedAt': serializer.toJson<DateTime?>(openedAt),
|
||||
'openedByAll': serializer.toJson<DateTime?>(openedByAll),
|
||||
'createdAt': serializer.toJson<DateTime>(createdAt),
|
||||
|
|
@ -5382,6 +5587,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
Value<Uint8List?> downloadToken = const Value.absent(),
|
||||
Value<String?> quotesMessageId = const Value.absent(),
|
||||
bool? isDeletedFromSender,
|
||||
bool? isWidgetMedia,
|
||||
Value<DateTime?> openedAt = const Value.absent(),
|
||||
Value<DateTime?> openedByAll = const Value.absent(),
|
||||
DateTime? createdAt,
|
||||
|
|
@ -5407,6 +5613,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
? quotesMessageId.value
|
||||
: this.quotesMessageId,
|
||||
isDeletedFromSender: isDeletedFromSender ?? this.isDeletedFromSender,
|
||||
isWidgetMedia: isWidgetMedia ?? this.isWidgetMedia,
|
||||
openedAt: openedAt.present ? openedAt.value : this.openedAt,
|
||||
openedByAll: openedByAll.present ? openedByAll.value : this.openedByAll,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
|
|
@ -5440,6 +5647,9 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
isDeletedFromSender: data.isDeletedFromSender.present
|
||||
? data.isDeletedFromSender.value
|
||||
: this.isDeletedFromSender,
|
||||
isWidgetMedia: data.isWidgetMedia.present
|
||||
? data.isWidgetMedia.value
|
||||
: this.isWidgetMedia,
|
||||
openedAt: data.openedAt.present ? data.openedAt.value : this.openedAt,
|
||||
openedByAll: data.openedByAll.present
|
||||
? data.openedByAll.value
|
||||
|
|
@ -5470,6 +5680,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
..write('downloadToken: $downloadToken, ')
|
||||
..write('quotesMessageId: $quotesMessageId, ')
|
||||
..write('isDeletedFromSender: $isDeletedFromSender, ')
|
||||
..write('isWidgetMedia: $isWidgetMedia, ')
|
||||
..write('openedAt: $openedAt, ')
|
||||
..write('openedByAll: $openedByAll, ')
|
||||
..write('createdAt: $createdAt, ')
|
||||
|
|
@ -5494,6 +5705,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
$driftBlobEquality.hash(downloadToken),
|
||||
quotesMessageId,
|
||||
isDeletedFromSender,
|
||||
isWidgetMedia,
|
||||
openedAt,
|
||||
openedByAll,
|
||||
createdAt,
|
||||
|
|
@ -5520,6 +5732,7 @@ class Message extends DataClass implements Insertable<Message> {
|
|||
$driftBlobEquality.equals(other.downloadToken, this.downloadToken) &&
|
||||
other.quotesMessageId == this.quotesMessageId &&
|
||||
other.isDeletedFromSender == this.isDeletedFromSender &&
|
||||
other.isWidgetMedia == this.isWidgetMedia &&
|
||||
other.openedAt == this.openedAt &&
|
||||
other.openedByAll == this.openedByAll &&
|
||||
other.createdAt == this.createdAt &&
|
||||
|
|
@ -5541,6 +5754,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
|||
final Value<Uint8List?> downloadToken;
|
||||
final Value<String?> quotesMessageId;
|
||||
final Value<bool> isDeletedFromSender;
|
||||
final Value<bool> isWidgetMedia;
|
||||
final Value<DateTime?> openedAt;
|
||||
final Value<DateTime?> openedByAll;
|
||||
final Value<DateTime> createdAt;
|
||||
|
|
@ -5561,6 +5775,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
|||
this.downloadToken = const Value.absent(),
|
||||
this.quotesMessageId = const Value.absent(),
|
||||
this.isDeletedFromSender = const Value.absent(),
|
||||
this.isWidgetMedia = const Value.absent(),
|
||||
this.openedAt = const Value.absent(),
|
||||
this.openedByAll = const Value.absent(),
|
||||
this.createdAt = const Value.absent(),
|
||||
|
|
@ -5582,6 +5797,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
|||
this.downloadToken = const Value.absent(),
|
||||
this.quotesMessageId = const Value.absent(),
|
||||
this.isDeletedFromSender = const Value.absent(),
|
||||
this.isWidgetMedia = const Value.absent(),
|
||||
this.openedAt = const Value.absent(),
|
||||
this.openedByAll = const Value.absent(),
|
||||
this.createdAt = const Value.absent(),
|
||||
|
|
@ -5605,6 +5821,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
|||
Expression<Uint8List>? downloadToken,
|
||||
Expression<String>? quotesMessageId,
|
||||
Expression<bool>? isDeletedFromSender,
|
||||
Expression<bool>? isWidgetMedia,
|
||||
Expression<DateTime>? openedAt,
|
||||
Expression<DateTime>? openedByAll,
|
||||
Expression<DateTime>? createdAt,
|
||||
|
|
@ -5628,6 +5845,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
|||
if (quotesMessageId != null) 'quotes_message_id': quotesMessageId,
|
||||
if (isDeletedFromSender != null)
|
||||
'is_deleted_from_sender': isDeletedFromSender,
|
||||
if (isWidgetMedia != null) 'is_widget_media': isWidgetMedia,
|
||||
if (openedAt != null) 'opened_at': openedAt,
|
||||
if (openedByAll != null) 'opened_by_all': openedByAll,
|
||||
if (createdAt != null) 'created_at': createdAt,
|
||||
|
|
@ -5651,6 +5869,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
|||
Value<Uint8List?>? downloadToken,
|
||||
Value<String?>? quotesMessageId,
|
||||
Value<bool>? isDeletedFromSender,
|
||||
Value<bool>? isWidgetMedia,
|
||||
Value<DateTime?>? openedAt,
|
||||
Value<DateTime?>? openedByAll,
|
||||
Value<DateTime>? createdAt,
|
||||
|
|
@ -5673,6 +5892,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
|||
downloadToken: downloadToken ?? this.downloadToken,
|
||||
quotesMessageId: quotesMessageId ?? this.quotesMessageId,
|
||||
isDeletedFromSender: isDeletedFromSender ?? this.isDeletedFromSender,
|
||||
isWidgetMedia: isWidgetMedia ?? this.isWidgetMedia,
|
||||
openedAt: openedAt ?? this.openedAt,
|
||||
openedByAll: openedByAll ?? this.openedByAll,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
|
|
@ -5724,6 +5944,9 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
|||
if (isDeletedFromSender.present) {
|
||||
map['is_deleted_from_sender'] = Variable<bool>(isDeletedFromSender.value);
|
||||
}
|
||||
if (isWidgetMedia.present) {
|
||||
map['is_widget_media'] = Variable<bool>(isWidgetMedia.value);
|
||||
}
|
||||
if (openedAt.present) {
|
||||
map['opened_at'] = Variable<DateTime>(openedAt.value);
|
||||
}
|
||||
|
|
@ -5763,6 +5986,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
|
|||
..write('downloadToken: $downloadToken, ')
|
||||
..write('quotesMessageId: $quotesMessageId, ')
|
||||
..write('isDeletedFromSender: $isDeletedFromSender, ')
|
||||
..write('isWidgetMedia: $isWidgetMedia, ')
|
||||
..write('openedAt: $openedAt, ')
|
||||
..write('openedByAll: $openedByAll, ')
|
||||
..write('createdAt: $createdAt, ')
|
||||
|
|
@ -12592,6 +12816,8 @@ typedef $$ContactsTableCreateCompanionBuilder =
|
|||
Value<DateTime?> recoveryContactsLastHeartbeat,
|
||||
Value<int?> recoveryContactsThreshold,
|
||||
Value<bool?> askForFriendPromotions,
|
||||
Value<bool> widgetSharingAllowed,
|
||||
Value<bool> widgetSharingGranted,
|
||||
Value<int> mediaSendCounter,
|
||||
Value<int> mediaReceivedCounter,
|
||||
});
|
||||
|
|
@ -12621,6 +12847,8 @@ typedef $$ContactsTableUpdateCompanionBuilder =
|
|||
Value<DateTime?> recoveryContactsLastHeartbeat,
|
||||
Value<int?> recoveryContactsThreshold,
|
||||
Value<bool?> askForFriendPromotions,
|
||||
Value<bool> widgetSharingAllowed,
|
||||
Value<bool> widgetSharingGranted,
|
||||
Value<int> mediaSendCounter,
|
||||
Value<int> mediaReceivedCounter,
|
||||
});
|
||||
|
|
@ -13030,6 +13258,16 @@ class $$ContactsTableFilterComposer
|
|||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<bool> get widgetSharingAllowed => $composableBuilder(
|
||||
column: $table.widgetSharingAllowed,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<bool> get widgetSharingGranted => $composableBuilder(
|
||||
column: $table.widgetSharingGranted,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<int> get mediaSendCounter => $composableBuilder(
|
||||
column: $table.mediaSendCounter,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
|
|
@ -13480,6 +13718,16 @@ class $$ContactsTableOrderingComposer
|
|||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<bool> get widgetSharingAllowed => $composableBuilder(
|
||||
column: $table.widgetSharingAllowed,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<bool> get widgetSharingGranted => $composableBuilder(
|
||||
column: $table.widgetSharingGranted,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<int> get mediaSendCounter => $composableBuilder(
|
||||
column: $table.mediaSendCounter,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
|
|
@ -13607,6 +13855,16 @@ class $$ContactsTableAnnotationComposer
|
|||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<bool> get widgetSharingAllowed => $composableBuilder(
|
||||
column: $table.widgetSharingAllowed,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<bool> get widgetSharingGranted => $composableBuilder(
|
||||
column: $table.widgetSharingGranted,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<int> get mediaSendCounter => $composableBuilder(
|
||||
column: $table.mediaSendCounter,
|
||||
builder: (column) => column,
|
||||
|
|
@ -13999,6 +14257,8 @@ class $$ContactsTableTableManager
|
|||
const Value.absent(),
|
||||
Value<int?> recoveryContactsThreshold = const Value.absent(),
|
||||
Value<bool?> askForFriendPromotions = const Value.absent(),
|
||||
Value<bool> widgetSharingAllowed = const Value.absent(),
|
||||
Value<bool> widgetSharingGranted = const Value.absent(),
|
||||
Value<int> mediaSendCounter = const Value.absent(),
|
||||
Value<int> mediaReceivedCounter = const Value.absent(),
|
||||
}) => ContactsCompanion(
|
||||
|
|
@ -14026,6 +14286,8 @@ class $$ContactsTableTableManager
|
|||
recoveryContactsLastHeartbeat: recoveryContactsLastHeartbeat,
|
||||
recoveryContactsThreshold: recoveryContactsThreshold,
|
||||
askForFriendPromotions: askForFriendPromotions,
|
||||
widgetSharingAllowed: widgetSharingAllowed,
|
||||
widgetSharingGranted: widgetSharingGranted,
|
||||
mediaSendCounter: mediaSendCounter,
|
||||
mediaReceivedCounter: mediaReceivedCounter,
|
||||
),
|
||||
|
|
@ -14057,6 +14319,8 @@ class $$ContactsTableTableManager
|
|||
const Value.absent(),
|
||||
Value<int?> recoveryContactsThreshold = const Value.absent(),
|
||||
Value<bool?> askForFriendPromotions = const Value.absent(),
|
||||
Value<bool> widgetSharingAllowed = const Value.absent(),
|
||||
Value<bool> widgetSharingGranted = const Value.absent(),
|
||||
Value<int> mediaSendCounter = const Value.absent(),
|
||||
Value<int> mediaReceivedCounter = const Value.absent(),
|
||||
}) => ContactsCompanion.insert(
|
||||
|
|
@ -14084,6 +14348,8 @@ class $$ContactsTableTableManager
|
|||
recoveryContactsLastHeartbeat: recoveryContactsLastHeartbeat,
|
||||
recoveryContactsThreshold: recoveryContactsThreshold,
|
||||
askForFriendPromotions: askForFriendPromotions,
|
||||
widgetSharingAllowed: widgetSharingAllowed,
|
||||
widgetSharingGranted: widgetSharingGranted,
|
||||
mediaSendCounter: mediaSendCounter,
|
||||
mediaReceivedCounter: mediaReceivedCounter,
|
||||
),
|
||||
|
|
@ -15446,6 +15712,7 @@ typedef $$MediaFilesTableCreateCompanionBuilder =
|
|||
Value<bool> requiresAuthentication,
|
||||
Value<bool> stored,
|
||||
Value<bool> isDraftMedia,
|
||||
Value<bool> isWidgetMedia,
|
||||
Value<bool> isFavorite,
|
||||
Value<bool> hasCropAnalyzed,
|
||||
Value<int?> preProgressingProcess,
|
||||
|
|
@ -15476,6 +15743,7 @@ typedef $$MediaFilesTableUpdateCompanionBuilder =
|
|||
Value<bool> requiresAuthentication,
|
||||
Value<bool> stored,
|
||||
Value<bool> isDraftMedia,
|
||||
Value<bool> isWidgetMedia,
|
||||
Value<bool> isFavorite,
|
||||
Value<bool> hasCropAnalyzed,
|
||||
Value<int?> preProgressingProcess,
|
||||
|
|
@ -15577,6 +15845,11 @@ class $$MediaFilesTableFilterComposer
|
|||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<bool> get isWidgetMedia => $composableBuilder(
|
||||
column: $table.isWidgetMedia,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<bool> get isFavorite => $composableBuilder(
|
||||
column: $table.isFavorite,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
|
|
@ -15743,6 +16016,11 @@ class $$MediaFilesTableOrderingComposer
|
|||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<bool> get isWidgetMedia => $composableBuilder(
|
||||
column: $table.isWidgetMedia,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<bool> get isFavorite => $composableBuilder(
|
||||
column: $table.isFavorite,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
|
|
@ -15878,6 +16156,11 @@ class $$MediaFilesTableAnnotationComposer
|
|||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<bool> get isWidgetMedia => $composableBuilder(
|
||||
column: $table.isWidgetMedia,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<bool> get isFavorite => $composableBuilder(
|
||||
column: $table.isFavorite,
|
||||
builder: (column) => column,
|
||||
|
|
@ -16023,6 +16306,7 @@ class $$MediaFilesTableTableManager
|
|||
Value<bool> requiresAuthentication = const Value.absent(),
|
||||
Value<bool> stored = const Value.absent(),
|
||||
Value<bool> isDraftMedia = const Value.absent(),
|
||||
Value<bool> isWidgetMedia = const Value.absent(),
|
||||
Value<bool> isFavorite = const Value.absent(),
|
||||
Value<bool> hasCropAnalyzed = const Value.absent(),
|
||||
Value<int?> preProgressingProcess = const Value.absent(),
|
||||
|
|
@ -16051,6 +16335,7 @@ class $$MediaFilesTableTableManager
|
|||
requiresAuthentication: requiresAuthentication,
|
||||
stored: stored,
|
||||
isDraftMedia: isDraftMedia,
|
||||
isWidgetMedia: isWidgetMedia,
|
||||
isFavorite: isFavorite,
|
||||
hasCropAnalyzed: hasCropAnalyzed,
|
||||
preProgressingProcess: preProgressingProcess,
|
||||
|
|
@ -16081,6 +16366,7 @@ class $$MediaFilesTableTableManager
|
|||
Value<bool> requiresAuthentication = const Value.absent(),
|
||||
Value<bool> stored = const Value.absent(),
|
||||
Value<bool> isDraftMedia = const Value.absent(),
|
||||
Value<bool> isWidgetMedia = const Value.absent(),
|
||||
Value<bool> isFavorite = const Value.absent(),
|
||||
Value<bool> hasCropAnalyzed = const Value.absent(),
|
||||
Value<int?> preProgressingProcess = const Value.absent(),
|
||||
|
|
@ -16109,6 +16395,7 @@ class $$MediaFilesTableTableManager
|
|||
requiresAuthentication: requiresAuthentication,
|
||||
stored: stored,
|
||||
isDraftMedia: isDraftMedia,
|
||||
isWidgetMedia: isWidgetMedia,
|
||||
isFavorite: isFavorite,
|
||||
hasCropAnalyzed: hasCropAnalyzed,
|
||||
preProgressingProcess: preProgressingProcess,
|
||||
|
|
@ -16200,6 +16487,7 @@ typedef $$MessagesTableCreateCompanionBuilder =
|
|||
Value<Uint8List?> downloadToken,
|
||||
Value<String?> quotesMessageId,
|
||||
Value<bool> isDeletedFromSender,
|
||||
Value<bool> isWidgetMedia,
|
||||
Value<DateTime?> openedAt,
|
||||
Value<DateTime?> openedByAll,
|
||||
Value<DateTime> createdAt,
|
||||
|
|
@ -16222,6 +16510,7 @@ typedef $$MessagesTableUpdateCompanionBuilder =
|
|||
Value<Uint8List?> downloadToken,
|
||||
Value<String?> quotesMessageId,
|
||||
Value<bool> isDeletedFromSender,
|
||||
Value<bool> isWidgetMedia,
|
||||
Value<DateTime?> openedAt,
|
||||
Value<DateTime?> openedByAll,
|
||||
Value<DateTime> createdAt,
|
||||
|
|
@ -16420,6 +16709,11 @@ class $$MessagesTableFilterComposer
|
|||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<bool> get isWidgetMedia => $composableBuilder(
|
||||
column: $table.isWidgetMedia,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<DateTime> get openedAt => $composableBuilder(
|
||||
column: $table.openedAt,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
|
|
@ -16674,6 +16968,11 @@ class $$MessagesTableOrderingComposer
|
|||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<bool> get isWidgetMedia => $composableBuilder(
|
||||
column: $table.isWidgetMedia,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<DateTime> get openedAt => $composableBuilder(
|
||||
column: $table.openedAt,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
|
|
@ -16822,6 +17121,11 @@ class $$MessagesTableAnnotationComposer
|
|||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<bool> get isWidgetMedia => $composableBuilder(
|
||||
column: $table.isWidgetMedia,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<DateTime> get openedAt =>
|
||||
$composableBuilder(column: $table.openedAt, builder: (column) => column);
|
||||
|
||||
|
|
@ -17064,6 +17368,7 @@ class $$MessagesTableTableManager
|
|||
Value<Uint8List?> downloadToken = const Value.absent(),
|
||||
Value<String?> quotesMessageId = const Value.absent(),
|
||||
Value<bool> isDeletedFromSender = const Value.absent(),
|
||||
Value<bool> isWidgetMedia = const Value.absent(),
|
||||
Value<DateTime?> openedAt = const Value.absent(),
|
||||
Value<DateTime?> openedByAll = const Value.absent(),
|
||||
Value<DateTime> createdAt = const Value.absent(),
|
||||
|
|
@ -17084,6 +17389,7 @@ class $$MessagesTableTableManager
|
|||
downloadToken: downloadToken,
|
||||
quotesMessageId: quotesMessageId,
|
||||
isDeletedFromSender: isDeletedFromSender,
|
||||
isWidgetMedia: isWidgetMedia,
|
||||
openedAt: openedAt,
|
||||
openedByAll: openedByAll,
|
||||
createdAt: createdAt,
|
||||
|
|
@ -17106,6 +17412,7 @@ class $$MessagesTableTableManager
|
|||
Value<Uint8List?> downloadToken = const Value.absent(),
|
||||
Value<String?> quotesMessageId = const Value.absent(),
|
||||
Value<bool> isDeletedFromSender = const Value.absent(),
|
||||
Value<bool> isWidgetMedia = const Value.absent(),
|
||||
Value<DateTime?> openedAt = const Value.absent(),
|
||||
Value<DateTime?> openedByAll = const Value.absent(),
|
||||
Value<DateTime> createdAt = const Value.absent(),
|
||||
|
|
@ -17126,6 +17433,7 @@ class $$MessagesTableTableManager
|
|||
downloadToken: downloadToken,
|
||||
quotesMessageId: quotesMessageId,
|
||||
isDeletedFromSender: isDeletedFromSender,
|
||||
isWidgetMedia: isWidgetMedia,
|
||||
openedAt: openedAt,
|
||||
openedByAll: openedByAll,
|
||||
createdAt: createdAt,
|
||||
|
|
|
|||
|
|
@ -278,6 +278,30 @@ abstract class AppLocalizations {
|
|||
/// **'Show archived users'**
|
||||
String get shareImageShowArchived;
|
||||
|
||||
/// No description provided for @shareImageSendToWidget.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Send to home-screen widget'**
|
||||
String get shareImageSendToWidget;
|
||||
|
||||
/// No description provided for @shareImageWidgetExplainerTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Send to a widget'**
|
||||
String get shareImageWidgetExplainerTitle;
|
||||
|
||||
/// No description provided for @shareImageWidgetExplainerBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sends the photo straight to your friends\' home-screen widgets. Only friends who allow them can be selected.'**
|
||||
String get shareImageWidgetExplainerBody;
|
||||
|
||||
/// No description provided for @shareImageWidgetExplainerDismiss.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Got it'**
|
||||
String get shareImageWidgetExplainerDismiss;
|
||||
|
||||
/// No description provided for @searchUsernameInput.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -392,6 +416,12 @@ abstract class AppLocalizations {
|
|||
/// **'Sent'**
|
||||
String get messageSendState_Send;
|
||||
|
||||
/// No description provided for @messageSendState_Delivered.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Received'**
|
||||
String get messageSendState_Delivered;
|
||||
|
||||
/// No description provided for @messageSendState_Sending.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -4591,6 +4621,192 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Upgrade plan'**
|
||||
String get fileLimitReachedUpgrade;
|
||||
|
||||
/// No description provided for @settingsWidgets.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Widgets'**
|
||||
String get settingsWidgets;
|
||||
|
||||
/// No description provided for @widgetsTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Widgets'**
|
||||
String get widgetsTitle;
|
||||
|
||||
/// No description provided for @widgetsIntroTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Photos on your home screen'**
|
||||
String get widgetsIntroTitle;
|
||||
|
||||
/// No description provided for @widgetsIntroBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Friends in the contact groups you choose can send a photo straight to a widget on your home screen. Photos disappear on their own after 24 hours.'**
|
||||
String get widgetsIntroBody;
|
||||
|
||||
/// No description provided for @widgetsSetupIos.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Touch and hold an empty area of the home screen, tap Edit, then Add Widget and pick twonly. Then touch and hold the new widget and tap Edit Widget to choose which contact groups may send to it.'**
|
||||
String get widgetsSetupIos;
|
||||
|
||||
/// No description provided for @widgetsSetupAndroid.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Touch and hold an empty area of the home screen, tap Widgets, then drag the twonly widget into place. You will be asked which contact groups may send to it.'**
|
||||
String get widgetsSetupAndroid;
|
||||
|
||||
/// No description provided for @widgetsNoneTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No widget added yet'**
|
||||
String get widgetsNoneTitle;
|
||||
|
||||
/// No description provided for @widgetsPlacedTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'On your home screen'**
|
||||
String get widgetsPlacedTitle;
|
||||
|
||||
/// No description provided for @widgetsAddAnother.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add another widget'**
|
||||
String get widgetsAddAnother;
|
||||
|
||||
/// No description provided for @widgetsNoGroups.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No contact group selected'**
|
||||
String get widgetsNoGroups;
|
||||
|
||||
/// No description provided for @widgetsNoGroupsHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nobody can send to this widget until you choose at least one contact group.'**
|
||||
String get widgetsNoGroupsHint;
|
||||
|
||||
/// No description provided for @widgetsEditGroup.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit contact group'**
|
||||
String get widgetsEditGroup;
|
||||
|
||||
/// No description provided for @widgetsChangeGroupsIos.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'To change the contact groups, touch and hold the widget on your home screen and tap Edit Widget.'**
|
||||
String get widgetsChangeGroupsIos;
|
||||
|
||||
/// No description provided for @widgetsChangeGroupsAndroid.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'To change the contact groups, remove the widget and add it again.'**
|
||||
String get widgetsChangeGroupsAndroid;
|
||||
|
||||
/// No description provided for @widgetsCurrentImages.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Currently showing'**
|
||||
String get widgetsCurrentImages;
|
||||
|
||||
/// No description provided for @widgetsNoImages.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No photos right now'**
|
||||
String get widgetsNoImages;
|
||||
|
||||
/// No description provided for @widgetsNoImagesHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Photos your friends send to this widget will appear here.'**
|
||||
String get widgetsNoImagesHint;
|
||||
|
||||
/// No description provided for @widgetsFrom.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'From {sender}'**
|
||||
String widgetsFrom(String sender);
|
||||
|
||||
/// No description provided for @widgetsExpiresIn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Shown for {duration} more'**
|
||||
String widgetsExpiresIn(String duration);
|
||||
|
||||
/// No description provided for @widgetsDeleteImage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete photo'**
|
||||
String get widgetsDeleteImage;
|
||||
|
||||
/// No description provided for @widgetsDeleteImageConfirm.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This removes the photo from every widget showing it. It cannot be undone.'**
|
||||
String get widgetsDeleteImageConfirm;
|
||||
|
||||
/// No description provided for @widgetsDurationHours.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{hours} h'**
|
||||
String widgetsDurationHours(int hours);
|
||||
|
||||
/// No description provided for @widgetsDurationMinutes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{minutes} min'**
|
||||
String widgetsDurationMinutes(int minutes);
|
||||
|
||||
/// No description provided for @contactGroupUsedByWidget.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Home screen widget'**
|
||||
String get contactGroupUsedByWidget;
|
||||
|
||||
/// No description provided for @contactGroupUsedByWidgetSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{A widget on your home screen shows photos from this group.} other{{count} widgets on your home screen show photos from this group.}}'**
|
||||
String contactGroupUsedByWidgetSubtitle(int count);
|
||||
|
||||
/// No description provided for @contactGroupDeleteBlockedByWidget.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{This contact group is used by a widget on your home screen. Remove that widget first, then you can delete the group.} other{This contact group is used by {count} widgets on your home screen. Remove those widgets first, then you can delete the group.}}'**
|
||||
String contactGroupDeleteBlockedByWidget(int count);
|
||||
|
||||
/// No description provided for @widgetsQueryFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not read the widgets on your home screen, so this list may be out of date.'**
|
||||
String get widgetsQueryFailed;
|
||||
|
||||
/// No description provided for @widgetsSizeSmall.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Small widget'**
|
||||
String get widgetsSizeSmall;
|
||||
|
||||
/// No description provided for @widgetsSizeMedium.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Medium widget'**
|
||||
String get widgetsSizeMedium;
|
||||
|
||||
/// No description provided for @widgetsSizeLarge.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Large widget'**
|
||||
String get widgetsSizeLarge;
|
||||
|
||||
/// No description provided for @widgetsSizeUnknown.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Widget'**
|
||||
String get widgetsSizeUnknown;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -104,6 +104,19 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get shareImageShowArchived => 'Archivierte Benutzer anzeigen';
|
||||
|
||||
@override
|
||||
String get shareImageSendToWidget => 'An Startbildschirm-Widget senden';
|
||||
|
||||
@override
|
||||
String get shareImageWidgetExplainerTitle => 'An ein Widget senden';
|
||||
|
||||
@override
|
||||
String get shareImageWidgetExplainerBody =>
|
||||
'Sendet das Foto direkt an die Startbildschirm-Widgets deiner Freunde. Nur Freunde, die das erlauben, sind auswählbar.';
|
||||
|
||||
@override
|
||||
String get shareImageWidgetExplainerDismiss => 'Alles klar';
|
||||
|
||||
@override
|
||||
String get searchUsernameInput => 'Benutzername';
|
||||
|
||||
|
|
@ -162,6 +175,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get messageSendState_Send => 'Gesendet';
|
||||
|
||||
@override
|
||||
String get messageSendState_Delivered => 'Empfangen';
|
||||
|
||||
@override
|
||||
String get messageSendState_Sending => 'Wird gesendet';
|
||||
|
||||
|
|
@ -2196,8 +2212,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get passwordlessRecoveryEnableBtn => 'Vertraute Freunde aktivieren';
|
||||
|
||||
@override
|
||||
String get passwordlessRecoveryRecoverBtn =>
|
||||
'Mithilfe von vertrauten Freunden wiederherstellen';
|
||||
String get passwordlessRecoveryRecoverBtn => 'Über Freunde wiederherstellen';
|
||||
|
||||
@override
|
||||
String get passwordlessRecoveryModifyBtn => 'Vertraute Freunde bearbeiten';
|
||||
|
|
@ -2653,4 +2668,134 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get fileLimitReachedUpgrade => 'Tarif wechseln';
|
||||
|
||||
@override
|
||||
String get settingsWidgets => 'Widgets';
|
||||
|
||||
@override
|
||||
String get widgetsTitle => 'Widgets';
|
||||
|
||||
@override
|
||||
String get widgetsIntroTitle => 'Fotos auf deinem Homebildschirm';
|
||||
|
||||
@override
|
||||
String get widgetsIntroBody =>
|
||||
'Freunde aus den von dir gewählten Kontaktgruppen können ein Foto direkt an ein Widget auf deinem Homebildschirm senden. Fotos verschwinden nach 24 Stunden von selbst.';
|
||||
|
||||
@override
|
||||
String get widgetsSetupIos =>
|
||||
'Halte eine freie Stelle auf dem Homebildschirm gedrückt, tippe auf Bearbeiten, dann auf Widget hinzufügen und wähle twonly. Halte anschließend das neue Widget gedrückt und tippe auf Widget bearbeiten, um die Kontaktgruppen auszuwählen.';
|
||||
|
||||
@override
|
||||
String get widgetsSetupAndroid =>
|
||||
'Halte eine freie Stelle auf dem Homebildschirm gedrückt, tippe auf Widgets und ziehe das twonly-Widget an seinen Platz. Du wirst gefragt, welche Kontaktgruppen daran senden dürfen.';
|
||||
|
||||
@override
|
||||
String get widgetsNoneTitle => 'Noch kein Widget hinzugefügt';
|
||||
|
||||
@override
|
||||
String get widgetsPlacedTitle => 'Auf deinem Homebildschirm';
|
||||
|
||||
@override
|
||||
String get widgetsAddAnother => 'Weiteres Widget hinzufügen';
|
||||
|
||||
@override
|
||||
String get widgetsNoGroups => 'Keine Kontaktgruppe ausgewählt';
|
||||
|
||||
@override
|
||||
String get widgetsNoGroupsHint =>
|
||||
'Niemand kann an dieses Widget senden, solange du keine Kontaktgruppe auswählst.';
|
||||
|
||||
@override
|
||||
String get widgetsEditGroup => 'Kontaktgruppe bearbeiten';
|
||||
|
||||
@override
|
||||
String get widgetsChangeGroupsIos =>
|
||||
'Um die Kontaktgruppen zu ändern, halte das Widget auf dem Homebildschirm gedrückt und tippe auf Widget bearbeiten.';
|
||||
|
||||
@override
|
||||
String get widgetsChangeGroupsAndroid =>
|
||||
'Um die Kontaktgruppen zu ändern, entferne das Widget und füge es erneut hinzu.';
|
||||
|
||||
@override
|
||||
String get widgetsCurrentImages => 'Wird aktuell angezeigt';
|
||||
|
||||
@override
|
||||
String get widgetsNoImages => 'Aktuell keine Fotos';
|
||||
|
||||
@override
|
||||
String get widgetsNoImagesHint =>
|
||||
'Fotos, die dir deine Freunde an dieses Widget senden, erscheinen hier.';
|
||||
|
||||
@override
|
||||
String widgetsFrom(String sender) {
|
||||
return 'Von $sender';
|
||||
}
|
||||
|
||||
@override
|
||||
String widgetsExpiresIn(String duration) {
|
||||
return 'Noch $duration sichtbar';
|
||||
}
|
||||
|
||||
@override
|
||||
String get widgetsDeleteImage => 'Foto löschen';
|
||||
|
||||
@override
|
||||
String get widgetsDeleteImageConfirm =>
|
||||
'Damit wird das Foto von allen Widgets entfernt, die es anzeigen. Das kann nicht rückgängig gemacht werden.';
|
||||
|
||||
@override
|
||||
String widgetsDurationHours(int hours) {
|
||||
return '$hours Std.';
|
||||
}
|
||||
|
||||
@override
|
||||
String widgetsDurationMinutes(int minutes) {
|
||||
return '$minutes Min.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get contactGroupUsedByWidget => 'Homebildschirm-Widget';
|
||||
|
||||
@override
|
||||
String contactGroupUsedByWidgetSubtitle(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other:
|
||||
'$count Widgets auf deinem Homebildschirm zeigen Fotos aus dieser Gruppe.',
|
||||
one:
|
||||
'Ein Widget auf deinem Homebildschirm zeigt Fotos aus dieser Gruppe.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String contactGroupDeleteBlockedByWidget(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other:
|
||||
'Diese Kontaktgruppe wird von $count Widgets auf deinem Homebildschirm verwendet. Entferne zuerst diese Widgets, danach kannst du die Gruppe löschen.',
|
||||
one:
|
||||
'Diese Kontaktgruppe wird von einem Widget auf deinem Homebildschirm verwendet. Entferne zuerst dieses Widget, danach kannst du die Gruppe löschen.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get widgetsQueryFailed =>
|
||||
'Die Widgets auf deinem Homebildschirm konnten nicht gelesen werden, diese Liste ist möglicherweise veraltet.';
|
||||
|
||||
@override
|
||||
String get widgetsSizeSmall => 'Kleines Widget';
|
||||
|
||||
@override
|
||||
String get widgetsSizeMedium => 'Mittleres Widget';
|
||||
|
||||
@override
|
||||
String get widgetsSizeLarge => 'Großes Widget';
|
||||
|
||||
@override
|
||||
String get widgetsSizeUnknown => 'Widget';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,6 +103,19 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get shareImageShowArchived => 'Show archived users';
|
||||
|
||||
@override
|
||||
String get shareImageSendToWidget => 'Send to home-screen widget';
|
||||
|
||||
@override
|
||||
String get shareImageWidgetExplainerTitle => 'Send to a widget';
|
||||
|
||||
@override
|
||||
String get shareImageWidgetExplainerBody =>
|
||||
'Sends the photo straight to your friends\' home-screen widgets. Only friends who allow them can be selected.';
|
||||
|
||||
@override
|
||||
String get shareImageWidgetExplainerDismiss => 'Got it';
|
||||
|
||||
@override
|
||||
String get searchUsernameInput => 'Username';
|
||||
|
||||
|
|
@ -160,6 +173,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get messageSendState_Send => 'Sent';
|
||||
|
||||
@override
|
||||
String get messageSendState_Delivered => 'Received';
|
||||
|
||||
@override
|
||||
String get messageSendState_Sending => 'Sending';
|
||||
|
||||
|
|
@ -2628,4 +2644,132 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get fileLimitReachedUpgrade => 'Upgrade plan';
|
||||
|
||||
@override
|
||||
String get settingsWidgets => 'Widgets';
|
||||
|
||||
@override
|
||||
String get widgetsTitle => 'Widgets';
|
||||
|
||||
@override
|
||||
String get widgetsIntroTitle => 'Photos on your home screen';
|
||||
|
||||
@override
|
||||
String get widgetsIntroBody =>
|
||||
'Friends in the contact groups you choose can send a photo straight to a widget on your home screen. Photos disappear on their own after 24 hours.';
|
||||
|
||||
@override
|
||||
String get widgetsSetupIos =>
|
||||
'Touch and hold an empty area of the home screen, tap Edit, then Add Widget and pick twonly. Then touch and hold the new widget and tap Edit Widget to choose which contact groups may send to it.';
|
||||
|
||||
@override
|
||||
String get widgetsSetupAndroid =>
|
||||
'Touch and hold an empty area of the home screen, tap Widgets, then drag the twonly widget into place. You will be asked which contact groups may send to it.';
|
||||
|
||||
@override
|
||||
String get widgetsNoneTitle => 'No widget added yet';
|
||||
|
||||
@override
|
||||
String get widgetsPlacedTitle => 'On your home screen';
|
||||
|
||||
@override
|
||||
String get widgetsAddAnother => 'Add another widget';
|
||||
|
||||
@override
|
||||
String get widgetsNoGroups => 'No contact group selected';
|
||||
|
||||
@override
|
||||
String get widgetsNoGroupsHint =>
|
||||
'Nobody can send to this widget until you choose at least one contact group.';
|
||||
|
||||
@override
|
||||
String get widgetsEditGroup => 'Edit contact group';
|
||||
|
||||
@override
|
||||
String get widgetsChangeGroupsIos =>
|
||||
'To change the contact groups, touch and hold the widget on your home screen and tap Edit Widget.';
|
||||
|
||||
@override
|
||||
String get widgetsChangeGroupsAndroid =>
|
||||
'To change the contact groups, remove the widget and add it again.';
|
||||
|
||||
@override
|
||||
String get widgetsCurrentImages => 'Currently showing';
|
||||
|
||||
@override
|
||||
String get widgetsNoImages => 'No photos right now';
|
||||
|
||||
@override
|
||||
String get widgetsNoImagesHint =>
|
||||
'Photos your friends send to this widget will appear here.';
|
||||
|
||||
@override
|
||||
String widgetsFrom(String sender) {
|
||||
return 'From $sender';
|
||||
}
|
||||
|
||||
@override
|
||||
String widgetsExpiresIn(String duration) {
|
||||
return 'Shown for $duration more';
|
||||
}
|
||||
|
||||
@override
|
||||
String get widgetsDeleteImage => 'Delete photo';
|
||||
|
||||
@override
|
||||
String get widgetsDeleteImageConfirm =>
|
||||
'This removes the photo from every widget showing it. It cannot be undone.';
|
||||
|
||||
@override
|
||||
String widgetsDurationHours(int hours) {
|
||||
return '$hours h';
|
||||
}
|
||||
|
||||
@override
|
||||
String widgetsDurationMinutes(int minutes) {
|
||||
return '$minutes min';
|
||||
}
|
||||
|
||||
@override
|
||||
String get contactGroupUsedByWidget => 'Home screen widget';
|
||||
|
||||
@override
|
||||
String contactGroupUsedByWidgetSubtitle(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count widgets on your home screen show photos from this group.',
|
||||
one: 'A widget on your home screen shows photos from this group.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String contactGroupDeleteBlockedByWidget(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other:
|
||||
'This contact group is used by $count widgets on your home screen. Remove those widgets first, then you can delete the group.',
|
||||
one:
|
||||
'This contact group is used by a widget on your home screen. Remove that widget first, then you can delete the group.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get widgetsQueryFailed =>
|
||||
'Could not read the widgets on your home screen, so this list may be out of date.';
|
||||
|
||||
@override
|
||||
String get widgetsSizeSmall => 'Small widget';
|
||||
|
||||
@override
|
||||
String get widgetsSizeMedium => 'Medium widget';
|
||||
|
||||
@override
|
||||
String get widgetsSizeLarge => 'Large widget';
|
||||
|
||||
@override
|
||||
String get widgetsSizeUnknown => 'Widget';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@
|
|||
"shareImageAllUsers": "Alle Kontakte",
|
||||
"shareImageSearchAllContacts": "Alle Kontakte durchsuchen",
|
||||
"shareImageShowArchived": "Archivierte Benutzer anzeigen",
|
||||
"shareImageSendToWidget": "An Startbildschirm-Widget senden",
|
||||
"shareImageWidgetExplainerTitle": "An ein Widget senden",
|
||||
"shareImageWidgetExplainerBody": "Sendet das Foto direkt an die Startbildschirm-Widgets deiner Freunde. Nur Freunde, die das erlauben, sind auswählbar.",
|
||||
"shareImageWidgetExplainerDismiss": "Alles klar",
|
||||
"startNewChatSearchHint": "Name, Benutzername oder Gruppenname",
|
||||
"searchUsernameInput": "Benutzername",
|
||||
"addFriendTitle": "Freunde hinzufügen",
|
||||
|
|
@ -58,6 +62,7 @@
|
|||
"messageSendState_Received": "Empfangen",
|
||||
"messageSendState_Opened": "Geöffnet",
|
||||
"messageSendState_Send": "Gesendet",
|
||||
"messageSendState_Delivered": "Empfangen",
|
||||
"messageSendState_Sending": "Wird gesendet",
|
||||
"messageSendState_TapToLoad": "Tippe zum Laden",
|
||||
"messageSendState_Loading": "Herunterladen",
|
||||
|
|
@ -796,7 +801,7 @@
|
|||
"passwordlessRecoveryEnterPin": "Bitte gib eine PIN ein.",
|
||||
"passwordlessRecoveryEnterEmail": "Bitte gib eine E-Mail-Adresse ein.",
|
||||
"passwordlessRecoveryEnableBtn": "Vertraute Freunde aktivieren",
|
||||
"passwordlessRecoveryRecoverBtn": "Mithilfe von vertrauten Freunden wiederherstellen",
|
||||
"passwordlessRecoveryRecoverBtn": "Über Freunde wiederherstellen",
|
||||
"passwordlessRecoveryModifyBtn": "Vertraute Freunde bearbeiten",
|
||||
"passwordlessRecoveryStatusEnabled": "Aktiviert • {count} Freunde",
|
||||
"@passwordlessRecoveryStatusEnabled": {
|
||||
|
|
@ -980,17 +985,96 @@
|
|||
"fileLimitReachedDetail": "Diese Datei ist {size} groß, dein Tarif erlaubt aber höchstens {limit} pro Sendung.",
|
||||
"@fileLimitReachedDetail": {
|
||||
"placeholders": {
|
||||
"size": { "type": "String" },
|
||||
"limit": { "type": "String" }
|
||||
"size": {
|
||||
"type": "String"
|
||||
},
|
||||
"limit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fileLimitReachedDetailNoSize": "Dein Tarif erlaubt höchstens {limit} pro Sendung.",
|
||||
"@fileLimitReachedDetailNoSize": {
|
||||
"placeholders": {
|
||||
"limit": { "type": "String" }
|
||||
"limit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fileLimitReachedHint": "Nimm ein kürzeres Video auf und sende es erneut.",
|
||||
"fileLimitReachedHintFree": "Nimm ein kürzeres Video auf oder wechsle den Tarif, um größere Dateien zu senden.",
|
||||
"fileLimitReachedUpgrade": "Tarif wechseln"
|
||||
"fileLimitReachedUpgrade": "Tarif wechseln",
|
||||
"settingsWidgets": "Widgets",
|
||||
"widgetsTitle": "Widgets",
|
||||
"widgetsIntroTitle": "Fotos auf deinem Homebildschirm",
|
||||
"widgetsIntroBody": "Freunde aus den von dir gewählten Kontaktgruppen können ein Foto direkt an ein Widget auf deinem Homebildschirm senden. Fotos verschwinden nach 24 Stunden von selbst.",
|
||||
"widgetsSetupIos": "Halte eine freie Stelle auf dem Homebildschirm gedrückt, tippe auf Bearbeiten, dann auf Widget hinzufügen und wähle twonly. Halte anschließend das neue Widget gedrückt und tippe auf Widget bearbeiten, um die Kontaktgruppen auszuwählen.",
|
||||
"widgetsSetupAndroid": "Halte eine freie Stelle auf dem Homebildschirm gedrückt, tippe auf Widgets und ziehe das twonly-Widget an seinen Platz. Du wirst gefragt, welche Kontaktgruppen daran senden dürfen.",
|
||||
"widgetsNoneTitle": "Noch kein Widget hinzugefügt",
|
||||
"widgetsPlacedTitle": "Auf deinem Homebildschirm",
|
||||
"widgetsAddAnother": "Weiteres Widget hinzufügen",
|
||||
"widgetsNoGroups": "Keine Kontaktgruppe ausgewählt",
|
||||
"widgetsNoGroupsHint": "Niemand kann an dieses Widget senden, solange du keine Kontaktgruppe auswählst.",
|
||||
"widgetsEditGroup": "Kontaktgruppe bearbeiten",
|
||||
"widgetsChangeGroupsIos": "Um die Kontaktgruppen zu ändern, halte das Widget auf dem Homebildschirm gedrückt und tippe auf Widget bearbeiten.",
|
||||
"widgetsChangeGroupsAndroid": "Um die Kontaktgruppen zu ändern, entferne das Widget und füge es erneut hinzu.",
|
||||
"widgetsCurrentImages": "Wird aktuell angezeigt",
|
||||
"widgetsNoImages": "Aktuell keine Fotos",
|
||||
"widgetsNoImagesHint": "Fotos, die dir deine Freunde an dieses Widget senden, erscheinen hier.",
|
||||
"widgetsFrom": "Von {sender}",
|
||||
"@widgetsFrom": {
|
||||
"placeholders": {
|
||||
"sender": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"widgetsExpiresIn": "Noch {duration} sichtbar",
|
||||
"@widgetsExpiresIn": {
|
||||
"placeholders": {
|
||||
"duration": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"widgetsDeleteImage": "Foto löschen",
|
||||
"widgetsDeleteImageConfirm": "Damit wird das Foto von allen Widgets entfernt, die es anzeigen. Das kann nicht rückgängig gemacht werden.",
|
||||
"widgetsDurationHours": "{hours} Std.",
|
||||
"@widgetsDurationHours": {
|
||||
"placeholders": {
|
||||
"hours": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"widgetsDurationMinutes": "{minutes} Min.",
|
||||
"@widgetsDurationMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contactGroupUsedByWidget": "Homebildschirm-Widget",
|
||||
"contactGroupUsedByWidgetSubtitle": "{count, plural, =1{Ein Widget auf deinem Homebildschirm zeigt Fotos aus dieser Gruppe.} other{{count} Widgets auf deinem Homebildschirm zeigen Fotos aus dieser Gruppe.}}",
|
||||
"@contactGroupUsedByWidgetSubtitle": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contactGroupDeleteBlockedByWidget": "{count, plural, =1{Diese Kontaktgruppe wird von einem Widget auf deinem Homebildschirm verwendet. Entferne zuerst dieses Widget, danach kannst du die Gruppe löschen.} other{Diese Kontaktgruppe wird von {count} Widgets auf deinem Homebildschirm verwendet. Entferne zuerst diese Widgets, danach kannst du die Gruppe löschen.}}",
|
||||
"@contactGroupDeleteBlockedByWidget": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"widgetsQueryFailed": "Die Widgets auf deinem Homebildschirm konnten nicht gelesen werden, diese Liste ist möglicherweise veraltet.",
|
||||
"widgetsSizeSmall": "Kleines Widget",
|
||||
"widgetsSizeMedium": "Mittleres Widget",
|
||||
"widgetsSizeLarge": "Großes Widget",
|
||||
"widgetsSizeUnknown": "Widget"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@
|
|||
"startNewChatNewContact": "New Contact",
|
||||
"shareImageAllUsers": "All contacts",
|
||||
"shareImageShowArchived": "Show archived users",
|
||||
"shareImageSendToWidget": "Send to home-screen widget",
|
||||
"shareImageWidgetExplainerTitle": "Send to a widget",
|
||||
"shareImageWidgetExplainerBody": "Sends the photo straight to your friends' home-screen widgets. Only friends who allow them can be selected.",
|
||||
"shareImageWidgetExplainerDismiss": "Got it",
|
||||
"searchUsernameInput": "Username",
|
||||
"addFriendTitle": "Add friends",
|
||||
"searchUserNamePending": "Request pending",
|
||||
|
|
@ -49,6 +53,7 @@
|
|||
"messageSendState_Received": "Received",
|
||||
"messageSendState_Opened": "Opened",
|
||||
"messageSendState_Send": "Sent",
|
||||
"messageSendState_Delivered": "Received",
|
||||
"messageSendState_Sending": "Sending",
|
||||
"messageSendState_TapToLoad": "Tap to load",
|
||||
"messageSendState_Loading": "Downloading",
|
||||
|
|
@ -990,17 +995,96 @@
|
|||
"fileLimitReachedDetail": "This file is {size}, but your plan allows at most {limit} per send.",
|
||||
"@fileLimitReachedDetail": {
|
||||
"placeholders": {
|
||||
"size": { "type": "String" },
|
||||
"limit": { "type": "String" }
|
||||
"size": {
|
||||
"type": "String"
|
||||
},
|
||||
"limit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fileLimitReachedDetailNoSize": "Your plan allows at most {limit} per send.",
|
||||
"@fileLimitReachedDetailNoSize": {
|
||||
"placeholders": {
|
||||
"limit": { "type": "String" }
|
||||
"limit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fileLimitReachedHint": "Record a shorter video and send it again.",
|
||||
"fileLimitReachedHintFree": "Record a shorter video, or upgrade your plan to send larger files.",
|
||||
"fileLimitReachedUpgrade": "Upgrade plan"
|
||||
"fileLimitReachedUpgrade": "Upgrade plan",
|
||||
"settingsWidgets": "Widgets",
|
||||
"widgetsTitle": "Widgets",
|
||||
"widgetsIntroTitle": "Photos on your home screen",
|
||||
"widgetsIntroBody": "Friends in the contact groups you choose can send a photo straight to a widget on your home screen. Photos disappear on their own after 24 hours.",
|
||||
"widgetsSetupIos": "Touch and hold an empty area of the home screen, tap Edit, then Add Widget and pick twonly. Then touch and hold the new widget and tap Edit Widget to choose which contact groups may send to it.",
|
||||
"widgetsSetupAndroid": "Touch and hold an empty area of the home screen, tap Widgets, then drag the twonly widget into place. You will be asked which contact groups may send to it.",
|
||||
"widgetsNoneTitle": "No widget added yet",
|
||||
"widgetsPlacedTitle": "On your home screen",
|
||||
"widgetsAddAnother": "Add another widget",
|
||||
"widgetsNoGroups": "No contact group selected",
|
||||
"widgetsNoGroupsHint": "Nobody can send to this widget until you choose at least one contact group.",
|
||||
"widgetsEditGroup": "Edit contact group",
|
||||
"widgetsChangeGroupsIos": "To change the contact groups, touch and hold the widget on your home screen and tap Edit Widget.",
|
||||
"widgetsChangeGroupsAndroid": "To change the contact groups, remove the widget and add it again.",
|
||||
"widgetsCurrentImages": "Currently showing",
|
||||
"widgetsNoImages": "No photos right now",
|
||||
"widgetsNoImagesHint": "Photos your friends send to this widget will appear here.",
|
||||
"widgetsFrom": "From {sender}",
|
||||
"@widgetsFrom": {
|
||||
"placeholders": {
|
||||
"sender": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"widgetsExpiresIn": "Shown for {duration} more",
|
||||
"@widgetsExpiresIn": {
|
||||
"placeholders": {
|
||||
"duration": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"widgetsDeleteImage": "Delete photo",
|
||||
"widgetsDeleteImageConfirm": "This removes the photo from every widget showing it. It cannot be undone.",
|
||||
"widgetsDurationHours": "{hours} h",
|
||||
"@widgetsDurationHours": {
|
||||
"placeholders": {
|
||||
"hours": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"widgetsDurationMinutes": "{minutes} min",
|
||||
"@widgetsDurationMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contactGroupUsedByWidget": "Home screen widget",
|
||||
"contactGroupUsedByWidgetSubtitle": "{count, plural, =1{A widget on your home screen shows photos from this group.} other{{count} widgets on your home screen show photos from this group.}}",
|
||||
"@contactGroupUsedByWidgetSubtitle": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contactGroupDeleteBlockedByWidget": "{count, plural, =1{This contact group is used by a widget on your home screen. Remove that widget first, then you can delete the group.} other{This contact group is used by {count} widgets on your home screen. Remove those widgets first, then you can delete the group.}}",
|
||||
"@contactGroupDeleteBlockedByWidget": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"widgetsQueryFailed": "Could not read the widgets on your home screen, so this list may be out of date.",
|
||||
"widgetsSizeSmall": "Small widget",
|
||||
"widgetsSizeMedium": "Medium widget",
|
||||
"widgetsSizeLarge": "Large widget",
|
||||
"widgetsSizeUnknown": "Widget"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import 'package:twonly/src/visual/views/settings/profile/profile.view.dart';
|
|||
import 'package:twonly/src/visual/views/settings/settings_main.view.dart';
|
||||
import 'package:twonly/src/visual/views/settings/share_with_friends.view.dart';
|
||||
import 'package:twonly/src/visual/views/settings/subscription/subscription.view.dart';
|
||||
import 'package:twonly/src/visual/views/settings/widgets/widgets.view.dart';
|
||||
|
||||
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
|
|
@ -100,7 +101,11 @@ final routerProvider = GoRouter(
|
|||
path: 'messages/:groupId',
|
||||
builder: (context, state) {
|
||||
final groupId = state.pathParameters['groupId']!;
|
||||
return ChatMessagesView(groupId);
|
||||
final extra = state.extra;
|
||||
return ChatMessagesView(
|
||||
groupId,
|
||||
initialGroup: extra is Group ? extra : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
|
@ -224,6 +229,10 @@ final routerProvider = GoRouter(
|
|||
path: 'notification',
|
||||
builder: (context, state) => const NotificationView(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'widgets',
|
||||
builder: (context, state) => const WidgetsSettingsView(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'storage_data',
|
||||
builder: (context, state) => const DataAndStorageView(),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:connectivity_plus/connectivity_plus.dart';
|
|||
import 'package:twonly/core/bridge/api.dart' as rust_api;
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/services/home_widget.service.dart';
|
||||
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
|
||||
import 'package:twonly/src/services/notifications/fcm.notifications.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
|
|
@ -43,6 +44,11 @@ class ApiService {
|
|||
event.kind == ApiEventKind.newDeviceRegistered) {
|
||||
permanentRejection = event.kind;
|
||||
}
|
||||
if (event.kind == ApiEventKind.widgetMediaReceived) {
|
||||
// Rust has already rewritten the manifest; the home screen still shows
|
||||
// what the widgets drew before it arrived.
|
||||
unawaited(HomeWidgetService.refresh());
|
||||
}
|
||||
}
|
||||
|
||||
// Function is called after the user is authenticated at the server
|
||||
|
|
|
|||
295
lib/src/services/home_widget.service.dart
Normal file
295
lib/src/services/home_widget.service.dart
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
|
||||
import 'package:twonly/core/bridge/api.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
|
||||
/// One widget the user has placed on their home screen.
|
||||
class PlacedWidget {
|
||||
const PlacedWidget({
|
||||
required this.id,
|
||||
required this.platform,
|
||||
required this.contactGroupIds,
|
||||
this.family,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String platform;
|
||||
final List<int> contactGroupIds;
|
||||
|
||||
/// The widget's size, as WidgetKit names it (`systemSmall` and so on). Null
|
||||
/// on Android, which does not report one.
|
||||
final String? family;
|
||||
|
||||
/// A widget with no contact group can never show anything: nobody is allowed
|
||||
/// to share with it, so nothing is ever delivered.
|
||||
bool get isConfigured => contactGroupIds.isNotEmpty;
|
||||
}
|
||||
|
||||
/// An image a widget is currently rotating through.
|
||||
class WidgetImage {
|
||||
const WidgetImage({
|
||||
required this.mediaId,
|
||||
required this.path,
|
||||
required this.sender,
|
||||
required this.contactGroupIds,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
final String mediaId;
|
||||
final String path;
|
||||
final String sender;
|
||||
final List<int> contactGroupIds;
|
||||
final DateTime expiresAt;
|
||||
|
||||
Duration get remaining => expiresAt.difference(DateTime.now());
|
||||
bool get isExpired => remaining.isNegative;
|
||||
File get file => File(path);
|
||||
}
|
||||
|
||||
/// Keeps native widget placement and Rust's derived sharing permissions in
|
||||
/// sync. Native configuration UIs also persist their selection in the shared
|
||||
/// widget configuration file, which Rust imports during every sync.
|
||||
class HomeWidgetService {
|
||||
const HomeWidgetService._();
|
||||
|
||||
static const _runtimeChannel = MethodChannel('eu.twonly/runtime_storage');
|
||||
|
||||
static String get platform => Platform.isIOS ? 'ios' : 'android';
|
||||
|
||||
static Future<void> register(String widgetId) async {
|
||||
await RustApi.registerHomeWidget(widgetId: widgetId, platform: platform);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
static Future<void> unregister(String widgetId) async {
|
||||
await RustApi.unregisterHomeWidget(widgetId: widgetId);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
static Future<void> setGroups(
|
||||
String widgetId,
|
||||
List<int> contactGroupIds,
|
||||
) async {
|
||||
await RustApi.setHomeWidgetGroups(
|
||||
widgetId: widgetId,
|
||||
platform: platform,
|
||||
contactGroupIds: Int64List.fromList(contactGroupIds),
|
||||
);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
static Future<void> syncPermissions() async {
|
||||
// Must run first: Rust derives who may share from the placement file, so a
|
||||
// widget removed from the home screen has to disappear from it before that
|
||||
// import reads it.
|
||||
await reconcile();
|
||||
await RustApi.syncWidgetPermissions();
|
||||
await refresh();
|
||||
}
|
||||
|
||||
/// Rewrites the placement file from the widgets WidgetKit says are placed.
|
||||
///
|
||||
/// A WidgetKit extension is never told that its widget was removed, so its
|
||||
/// own entry would otherwise linger. Only the app can ask, and only on iOS —
|
||||
/// Android's provider already enumerates its widgets whenever one changes.
|
||||
static Future<void> reconcile() async => reconcileReport();
|
||||
|
||||
/// Reconciles, and reports what WidgetKit answered.
|
||||
///
|
||||
/// Returns null when the platform has nothing to reconcile, and rethrows
|
||||
/// nothing: a failed query leaves the placement file alone, because failing
|
||||
/// to ask is not evidence that a widget was removed. The diagnostics screen
|
||||
/// renders the report; ordinary callers use [reconcile].
|
||||
static Future<Map<String, dynamic>?> reconcileReport() async {
|
||||
if (!Platform.isIOS) return null;
|
||||
// Reconciling waits on the widget extension, so a screen that syncs and
|
||||
// then lists would otherwise pay for it twice in a row.
|
||||
if (_cachedReport case final cached?) {
|
||||
if (DateTime.now().difference(cached.at) < _reportCacheFor) {
|
||||
return cached.report;
|
||||
}
|
||||
}
|
||||
try {
|
||||
final report = await _runtimeChannel.invokeMapMethod<String, dynamic>(
|
||||
'reconcileWidgets',
|
||||
);
|
||||
_cachedReport = (at: DateTime.now(), report: report);
|
||||
return report;
|
||||
} catch (error) {
|
||||
Log.error('Could not reconcile the placed widgets: $error');
|
||||
return {'error': '$error'};
|
||||
}
|
||||
}
|
||||
|
||||
static ({DateTime at, Map<String, dynamic>? report})? _cachedReport;
|
||||
static const _reportCacheFor = Duration(seconds: 10);
|
||||
|
||||
/// Drops the cached reconcile so the next read asks the system again.
|
||||
static void invalidate() => _cachedReport = null;
|
||||
|
||||
static Future<void> purgeExpiredMedia() async {
|
||||
await RustApi.purgeWidgetMedia();
|
||||
await refresh();
|
||||
}
|
||||
|
||||
/// Republishes the manifest so the widgets see the current contact groups.
|
||||
///
|
||||
/// A widget's configuration UI lists the groups from that file, so a group
|
||||
/// that was just created is invisible to it until this runs.
|
||||
static Future<void> refreshManifest() async {
|
||||
await RustApi.refreshWidgetManifest();
|
||||
await refresh();
|
||||
}
|
||||
|
||||
/// Removes one image from every widget showing it, and from disk.
|
||||
static Future<void> deleteImage(String mediaId) async {
|
||||
await RustApi.deleteWidgetMedia(mediaId: mediaId);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
/// Asks the placed widgets to redraw from the manifest Rust just wrote.
|
||||
///
|
||||
/// Neither platform notices the rewrite on its own: WidgetKit keeps the
|
||||
/// timeline it built from the older manifest, and an AppWidget only redraws
|
||||
/// when its provider is asked to. Until this runs, an image that has already
|
||||
/// arrived is nowhere on the home screen.
|
||||
static Future<void> refresh() async {
|
||||
try {
|
||||
await _runtimeChannel.invokeMethod<void>('reloadWidgets');
|
||||
} catch (error) {
|
||||
Log.error('Could not reload the home widget: $error');
|
||||
}
|
||||
}
|
||||
|
||||
static Directory get _root =>
|
||||
Directory('${AppEnvironment.supportDir}/widget');
|
||||
|
||||
/// Mirrors `staleWidgetSeconds` in the iOS widget and `STALE_WIDGET_SECONDS`
|
||||
/// in Rust: an iOS entry that has not refreshed within this belongs to a
|
||||
/// widget that is no longer on the home screen.
|
||||
static const _staleWidget = Duration(hours: 48);
|
||||
|
||||
/// The widgets currently on the home screen.
|
||||
///
|
||||
/// On iOS this is WidgetKit's own answer rather than the placement file:
|
||||
/// the file is *derived* from this, so reading it back could only ever repeat
|
||||
/// a stale write. Android has no equivalent query, and its provider rewrites
|
||||
/// the file whenever a widget is added or removed, so there the file is the
|
||||
/// authority.
|
||||
///
|
||||
/// The returned `error` is set when iOS could not be asked; the widgets are
|
||||
/// then whatever the file last recorded, which may name widgets that are
|
||||
/// already gone.
|
||||
static Future<({List<PlacedWidget> widgets, String? error})>
|
||||
placedWidgetsResult() async {
|
||||
if (!Platform.isIOS) {
|
||||
return (widgets: await _widgetsFromFile(), error: null);
|
||||
}
|
||||
final report = await reconcileReport();
|
||||
if (report == null || report['error'] != null) {
|
||||
return (
|
||||
widgets: await _widgetsFromFile(),
|
||||
error: '${report?['error'] ?? 'unknown'}',
|
||||
);
|
||||
}
|
||||
final reported = ((report['widgets'] as List?) ?? const [])
|
||||
.cast<Map<Object?, Object?>>()
|
||||
.where((entry) => entry['mine'] == true && entry['live'] == true);
|
||||
final seen = <String>{};
|
||||
return (
|
||||
widgets: [
|
||||
for (final entry in reported)
|
||||
if (seen.add('${entry['id']}'))
|
||||
PlacedWidget(
|
||||
id: '${entry['id']}',
|
||||
platform: 'ios',
|
||||
family: entry['family'] as String?,
|
||||
contactGroupIds: ((entry['group_ids'] as List?) ?? const [])
|
||||
.map((id) => (id as num).toInt())
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
error: null,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<List<PlacedWidget>> placedWidgets() async =>
|
||||
(await placedWidgetsResult()).widgets;
|
||||
|
||||
static Future<List<PlacedWidget>> _widgetsFromFile() async {
|
||||
final file = File('${_root.path}/native-config.json');
|
||||
if (!file.existsSync()) return const [];
|
||||
try {
|
||||
final decoded = jsonDecode(await file.readAsString());
|
||||
final widgets = ((decoded as Map<String, dynamic>)['widgets'] as List?)
|
||||
?.cast<Map<String, dynamic>>();
|
||||
final oldestLive = DateTime.now().subtract(_staleWidget);
|
||||
return [
|
||||
for (final widget in widgets ?? const <Map<String, dynamic>>[])
|
||||
if (_isLive(widget['last_seen'], oldestLive))
|
||||
PlacedWidget(
|
||||
id: '${widget['id']}',
|
||||
platform: '${widget['platform']}',
|
||||
contactGroupIds: ((widget['group_ids'] as List?) ?? const [])
|
||||
.map((id) => (id as num).toInt())
|
||||
.toList(),
|
||||
),
|
||||
];
|
||||
} catch (error) {
|
||||
Log.error('Could not read the widget configuration: $error');
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// An entry with no timestamp is Android's, which is always authoritative.
|
||||
static bool _isLive(Object? lastSeen, DateTime oldestLive) {
|
||||
if (lastSeen is! num) return true;
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
lastSeen.toInt() * 1000,
|
||||
).isAfter(oldestLive);
|
||||
}
|
||||
|
||||
/// Every unexpired image Rust has published to the widgets, newest first.
|
||||
static Future<List<WidgetImage>> images() async {
|
||||
final file = File('${_root.path}/manifest.json');
|
||||
if (!file.existsSync()) return const [];
|
||||
try {
|
||||
final decoded =
|
||||
jsonDecode(await file.readAsString()) as Map<String, dynamic>;
|
||||
final images = ((decoded['images'] as List?) ?? const [])
|
||||
.cast<Map<String, dynamic>>();
|
||||
final parsed = [
|
||||
for (final image in images)
|
||||
WidgetImage(
|
||||
mediaId: '${image['mediaId'] ?? image['media_id']}',
|
||||
path: '${image['path']}',
|
||||
sender: '${image['sender']}',
|
||||
contactGroupIds: ((image['group_ids'] as List?) ?? const [])
|
||||
.map((id) => (id as num).toInt())
|
||||
.toList(),
|
||||
expiresAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
((image['expires_at'] as num?)?.toInt() ?? 0) * 1000,
|
||||
),
|
||||
),
|
||||
]..sort((a, b) => b.expiresAt.compareTo(a.expiresAt));
|
||||
return parsed.where((image) => !image.isExpired).toList();
|
||||
} catch (error) {
|
||||
Log.error('Could not read the widget manifest: $error');
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// The images a single widget rotates through: only senders whose contact
|
||||
/// groups overlap the ones that widget selected.
|
||||
static Future<List<WidgetImage>> imagesFor(PlacedWidget widget) async {
|
||||
final selected = widget.contactGroupIds.toSet();
|
||||
final all = await images();
|
||||
return all
|
||||
.where((image) => image.contactGroupIds.any(selected.contains))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
|
@ -46,7 +46,10 @@ class GroupContextMenu extends StatelessWidget {
|
|||
ContextMenuItem(
|
||||
title: context.lang.contextMenuOpenChat,
|
||||
onTap: () =>
|
||||
navigator.context.push(Routes.chatsMessages(group.groupId)),
|
||||
navigator.context.push(
|
||||
Routes.chatsMessages(group.groupId),
|
||||
extra: group,
|
||||
),
|
||||
icon: FontAwesomeIcons.comments,
|
||||
),
|
||||
if (!group.archived)
|
||||
|
|
|
|||
112
lib/src/visual/elements/my_card.element.dart
Normal file
112
lib/src/visual/elements/my_card.element.dart
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
|
||||
/// The app's standard tappable card: a tinted circular icon, a title with
|
||||
/// supporting text, and a chevron.
|
||||
///
|
||||
/// Grew out of the backup recovery options, which are the reference for how
|
||||
/// this kind of row should look; every other place that needs the same shape
|
||||
/// uses this rather than rebuilding it, so they stay in step.
|
||||
class MyCard extends StatelessWidget {
|
||||
const MyCard({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
super.key,
|
||||
this.subtitle,
|
||||
this.onTap,
|
||||
this.accentColor,
|
||||
this.trailing,
|
||||
this.titleColor,
|
||||
});
|
||||
|
||||
/// `IconData` or `FaIconData`, matching the rest of the app's icon handling.
|
||||
final dynamic icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Tints the icon and its circle. Defaults to the theme's primary colour;
|
||||
/// pass an error colour to mark the card's subject as needing attention.
|
||||
final Color? accentColor;
|
||||
|
||||
/// Replaces the chevron. A card without `onTap` shows nothing here unless
|
||||
/// this is given.
|
||||
final Widget? trailing;
|
||||
|
||||
/// Overrides the title colour, for cards whose title *is* the warning.
|
||||
final Color? titleColor;
|
||||
|
||||
static const _radius = 16.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = accentColor ?? context.color.primary;
|
||||
final content = Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: icon is IconData
|
||||
? Icon(icon as IconData, color: accent, size: 24)
|
||||
: FaIcon(icon as FaIconData?, color: accent, size: 24),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
if (subtitle case final subtitle?) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: context.color.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing case final trailing?) ...[
|
||||
const SizedBox(width: 3),
|
||||
trailing,
|
||||
] else if (onTap != null) ...[
|
||||
const SizedBox(width: 3),
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: context.color.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: context.color.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(_radius),
|
||||
),
|
||||
child: onTap == null
|
||||
? content
|
||||
: InkWell(
|
||||
borderRadius: BorderRadius.circular(_radius),
|
||||
onTap: onTap,
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,8 @@ Color getMessageColorFromType(
|
|||
color = Colors.orange;
|
||||
} else if (message.type == MessageType.text.name) {
|
||||
color = Colors.blueAccent;
|
||||
} else if (message.isWidgetMedia) {
|
||||
color = const Color.fromARGB(255, 155, 89, 182);
|
||||
} else if (mediaFile != null) {
|
||||
if (mediaFile.requiresAuthentication) {
|
||||
color = context.color.primary;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ class MainCameraPreview extends StatelessWidget {
|
|||
return Container();
|
||||
}
|
||||
return Positioned.fill(
|
||||
child: MediaViewSizingHelper.cameraEditor(
|
||||
child: MediaViewSizingHelper(
|
||||
bottomNavigation: const SizedBox.shrink(),
|
||||
child: Stack(
|
||||
children: [
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ class ShareImageView extends StatefulWidget {
|
|||
required this.mediaStoreFuture,
|
||||
required this.mediaFileService,
|
||||
required this.additionalData,
|
||||
required this.sendToWidget,
|
||||
super.key,
|
||||
});
|
||||
final HashSet<String> selectedGroupIds;
|
||||
|
|
@ -37,6 +38,7 @@ class ShareImageView extends StatefulWidget {
|
|||
final Future<ScreenshotImageHelper?>? mediaStoreFuture;
|
||||
final MediaFileService mediaFileService;
|
||||
final AdditionalMessageData? additionalData;
|
||||
final bool sendToWidget;
|
||||
|
||||
@override
|
||||
State<ShareImageView> createState() => _ShareImageView();
|
||||
|
|
@ -47,7 +49,6 @@ class _ShareImageView extends State<ShareImageView> {
|
|||
List<Group> _otherUsers = [];
|
||||
List<Group> _bestFriends = [];
|
||||
List<Group> _pinnedContacts = [];
|
||||
|
||||
bool sendingImage = false;
|
||||
bool mediaStoreFutureReady = false;
|
||||
ScreenshotImageHelper? _screenshotImage;
|
||||
|
|
@ -60,9 +61,19 @@ class _ShareImageView extends State<ShareImageView> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
|
||||
allGroupSub = twonlyDB.groupsDao.watchGroupsForShareImage().listen((
|
||||
allGroups,
|
||||
) async {
|
||||
final groups = widget.sendToWidget
|
||||
? twonlyDB.groupsDao.watchGroupsAllowedForWidgetShare()
|
||||
: twonlyDB.groupsDao.watchGroupsForShareImage();
|
||||
allGroupSub = groups.listen((allGroups) async {
|
||||
if (!mounted) return;
|
||||
if (widget.sendToWidget) {
|
||||
final allowedGroupIds = allGroups.map((group) => group.groupId).toSet();
|
||||
for (final groupId in widget.selectedGroupIds.toList()) {
|
||||
if (!allowedGroupIds.contains(groupId)) {
|
||||
widget.updateSelectedGroupIds(groupId, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
_allGroups = allGroups;
|
||||
});
|
||||
|
|
@ -188,6 +199,7 @@ class _ShareImageView extends State<ShareImageView> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (!widget.sendToWidget)
|
||||
ContactGroupShortcutRow(
|
||||
selectedGroupIds: widget.selectedGroupIds,
|
||||
updateSelectedGroupIds: updateSelectedGroupIds,
|
||||
|
|
@ -199,6 +211,7 @@ class _ShareImageView extends State<ShareImageView> {
|
|||
updateSelectedGroupIds: updateSelectedGroupIds,
|
||||
title: context.lang.shareImagePinnedContacts,
|
||||
showSelectAll:
|
||||
!widget.sendToWidget &&
|
||||
!widget.mediaFileService.mediaFile.requiresAuthentication,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
|
@ -208,6 +221,7 @@ class _ShareImageView extends State<ShareImageView> {
|
|||
updateSelectedGroupIds: updateSelectedGroupIds,
|
||||
title: context.lang.shareImageBestFriends,
|
||||
showSelectAll:
|
||||
!widget.sendToWidget &&
|
||||
!widget.mediaFileService.mediaFile.requiresAuthentication,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
|
@ -316,6 +330,7 @@ class _ShareImageView extends State<ShareImageView> {
|
|||
groupIds: widget.selectedGroupIds.toList(),
|
||||
additionalMessageData: widget.additionalData
|
||||
?.writeToBuffer(),
|
||||
widgetOnly: widget.sendToWidget,
|
||||
),
|
||||
'sendMediaToGroups',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/constants/keyvalue.keys.dart';
|
||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
|
||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||
import 'package:twonly/src/utils/keyvalue.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart';
|
||||
import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
|
||||
|
|
@ -23,6 +26,7 @@ import 'package:twonly/src/visual/views/camera/share_image_editor_components/edi
|
|||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_top_toolbar.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/image_item.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/video_trimmer.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/widget_share_explainer.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
/// Lets the user edit a just taken (or shared) photo/video/gif and send it.
|
||||
|
|
@ -82,6 +86,25 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
ImageItem currentImage = ImageItem();
|
||||
ScreenshotController screenshotController = ScreenshotController();
|
||||
Timer? _imageLoadingTimer;
|
||||
late final StreamSubscription<List<Group>> _widgetGroupsSubscription;
|
||||
final GlobalKey _editorStackKey = GlobalKey();
|
||||
final GlobalKey _widgetActionKey = GlobalKey();
|
||||
Offset? _widgetActionCenter;
|
||||
bool _widgetActionMeasurementScheduled = false;
|
||||
|
||||
bool _widgetRecipientAvailable = false;
|
||||
bool _sendToWidget = false;
|
||||
bool _updatingWidgetMode = false;
|
||||
bool _widgetExplainerPreferenceLoaded = false;
|
||||
bool _widgetExplainerDismissed = false;
|
||||
bool _previousMediaSettingsCaptured = false;
|
||||
int? _displayLimitBeforeWidget;
|
||||
bool _requiresAuthBeforeWidget = false;
|
||||
|
||||
bool get _showWidgetExplainer =>
|
||||
_widgetRecipientAvailable &&
|
||||
_widgetExplainerPreferenceLoaded &&
|
||||
!_widgetExplainerDismissed;
|
||||
|
||||
MediaFileService get mediaService => widget.mediaFileService;
|
||||
MediaFile get media => widget.mediaFileService.mediaFile;
|
||||
|
|
@ -113,6 +136,11 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
selectedGroupIds.add(widget.sendToGroup!.groupId);
|
||||
}
|
||||
|
||||
_widgetGroupsSubscription = twonlyDB.groupsDao
|
||||
.watchGroupsAllowedForWidgetShare()
|
||||
.listen(_updateWidgetRecipientAvailability);
|
||||
unawaited(_loadWidgetExplainerPreference());
|
||||
|
||||
if (media.type == MediaType.image || media.type == MediaType.gif) {
|
||||
_loadInitialImage();
|
||||
}
|
||||
|
|
@ -133,9 +161,69 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
),
|
||||
);
|
||||
_imageLoadingTimer?.cancel();
|
||||
unawaited(_widgetGroupsSubscription.cancel());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadWidgetExplainerPreference() async {
|
||||
final preference = await KeyValueStore.get(
|
||||
KeyValueKeys.shareImageWidgetExplainer,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_widgetExplainerDismissed = preference?['dismissed'] == true;
|
||||
_widgetExplainerPreferenceLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _dismissWidgetExplainer() async {
|
||||
setState(() => _widgetExplainerDismissed = true);
|
||||
await KeyValueStore.put(
|
||||
KeyValueKeys.shareImageWidgetExplainer,
|
||||
const {'dismissed': true},
|
||||
);
|
||||
}
|
||||
|
||||
void _updateWidgetRecipientAvailability(List<Group> widgetGroups) {
|
||||
final fixedGroupId = widget.sendToGroup?.groupId;
|
||||
final recipientAvailable =
|
||||
media.type == MediaType.image &&
|
||||
(fixedGroupId == null
|
||||
? widgetGroups.isNotEmpty
|
||||
: widgetGroups.any((group) => group.groupId == fixedGroupId));
|
||||
|
||||
if (mounted && recipientAvailable != _widgetRecipientAvailable) {
|
||||
setState(() => _widgetRecipientAvailable = recipientAvailable);
|
||||
}
|
||||
if (!recipientAvailable && _sendToWidget && !_updatingWidgetMode) {
|
||||
unawaited(_setSendToWidget(false));
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleWidgetActionMeasurement() {
|
||||
if (_widgetActionMeasurementScheduled) return;
|
||||
_widgetActionMeasurementScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_widgetActionMeasurementScheduled = false;
|
||||
if (!mounted || !_showWidgetExplainer) return;
|
||||
|
||||
final actionBox =
|
||||
_widgetActionKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final stackBox =
|
||||
_editorStackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (actionBox == null || stackBox == null || !actionBox.hasSize) return;
|
||||
|
||||
final topLeft = actionBox.localToGlobal(
|
||||
Offset.zero,
|
||||
ancestor: stackBox,
|
||||
);
|
||||
final center = topLeft + actionBox.size.center(Offset.zero);
|
||||
if (_widgetActionCenter != center) {
|
||||
setState(() => _widgetActionCenter = center);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// loading the media
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -286,6 +374,51 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _setSendToWidget(bool enabled) async {
|
||||
if (_updatingWidgetMode || (enabled && !_widgetRecipientAvailable)) return;
|
||||
|
||||
if (widget.sendToGroup == null) {
|
||||
selectedGroupIds.clear();
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
_displayLimitBeforeWidget = media.displayLimitInMilliseconds;
|
||||
_requiresAuthBeforeWidget = media.requiresAuthentication;
|
||||
_previousMediaSettingsCaptured = true;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_sendToWidget = enabled;
|
||||
_updatingWidgetMode = true;
|
||||
});
|
||||
|
||||
try {
|
||||
if (enabled) {
|
||||
if (media.displayLimitInMilliseconds != null) {
|
||||
await mediaService.setDisplayLimit(null);
|
||||
}
|
||||
if (media.requiresAuthentication) {
|
||||
await mediaService.setRequiresAuth(false);
|
||||
}
|
||||
} else if (_previousMediaSettingsCaptured) {
|
||||
if (media.displayLimitInMilliseconds != _displayLimitBeforeWidget) {
|
||||
await mediaService.setDisplayLimit(_displayLimitBeforeWidget);
|
||||
}
|
||||
if (media.requiresAuthentication != _requiresAuthBeforeWidget) {
|
||||
await mediaService.setRequiresAuth(_requiresAuthBeforeWidget);
|
||||
}
|
||||
_previousMediaSettingsCaptured = false;
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _updatingWidgetMode = false);
|
||||
if (_sendToWidget && !_widgetRecipientAvailable) {
|
||||
unawaited(_setSendToWidget(false));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editDisplayTime() async {
|
||||
await showDisplayTimePicker(
|
||||
context,
|
||||
|
|
@ -352,6 +485,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
mediaStoreFuture: mediaStoreFuture,
|
||||
mediaFileService: mediaService,
|
||||
additionalData: getAdditionalData(),
|
||||
sendToWidget: _sendToWidget,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -381,6 +515,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
mediaId: mediaService.mediaFile.mediaId,
|
||||
groupIds: [widget.sendToGroup!.groupId],
|
||||
additionalMessageData: getAdditionalData()?.writeToBuffer(),
|
||||
widgetOnly: _sendToWidget,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
|
|
@ -409,6 +544,13 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
if (_showWidgetExplainer) _scheduleWidgetActionMeasurement();
|
||||
|
||||
final double widgetExplainerWidth = math.min(
|
||||
340,
|
||||
MediaQuery.sizeOf(context).width - 82,
|
||||
);
|
||||
final widgetActionCenter = _widgetActionCenter;
|
||||
|
||||
return PopScope<bool?>(
|
||||
canPop: false,
|
||||
|
|
@ -422,6 +564,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
: Colors.white.withAlpha(0),
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: Stack(
|
||||
key: _editorStackKey,
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
GestureDetector(
|
||||
|
|
@ -438,7 +581,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
mediaService: mediaService,
|
||||
sendToGroup: widget.sendToGroup,
|
||||
isLoadingImage: loadingImage,
|
||||
isSending: sendingOrLoadingImage,
|
||||
isSending: sendingOrLoadingImage || _updatingWidgetMode,
|
||||
storeImageAsOriginal: storeImageAsOriginal,
|
||||
onAddMoreRecipients: pushShareImageView,
|
||||
onSend: () async {
|
||||
|
|
@ -484,6 +627,21 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
),
|
||||
),
|
||||
),
|
||||
if (_showWidgetExplainer && widgetActionCenter != null)
|
||||
Positioned(
|
||||
left: math.max(
|
||||
12,
|
||||
widgetActionCenter.dx + 24 - widgetExplainerWidth,
|
||||
),
|
||||
top: widgetActionCenter.dy,
|
||||
width: widgetExplainerWidth,
|
||||
child: FractionalTranslation(
|
||||
translation: const Offset(0, -0.5),
|
||||
child: WidgetShareExplainer(
|
||||
onDismiss: _dismissWidgetExplainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 6,
|
||||
top: 100,
|
||||
|
|
@ -498,6 +656,13 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
onEditDisplayTime: _editDisplayTime,
|
||||
onToggleAudio: _toggleAudio,
|
||||
onToggleRequiresAuth: _toggleRequiresAuth,
|
||||
sendToWidget: _sendToWidget,
|
||||
showWidgetOption: _widgetRecipientAvailable,
|
||||
highlightWidgetOption: _showWidgetExplainer,
|
||||
isUpdatingWidgetMode: _updatingWidgetMode,
|
||||
widgetActionKey: _widgetActionKey,
|
||||
onToggleSendToWidget: () =>
|
||||
_setSendToWidget(!_sendToWidget),
|
||||
canTrim: _canTrim,
|
||||
trimmerVisible: _trimmerVisible,
|
||||
onToggleTrimmer: () =>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ class EditorSideToolbar extends StatelessWidget {
|
|||
required this.canTrim,
|
||||
required this.trimmerVisible,
|
||||
required this.onToggleTrimmer,
|
||||
required this.sendToWidget,
|
||||
required this.showWidgetOption,
|
||||
required this.highlightWidgetOption,
|
||||
required this.isUpdatingWidgetMode,
|
||||
required this.widgetActionKey,
|
||||
required this.onToggleSendToWidget,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -40,6 +46,12 @@ class EditorSideToolbar extends StatelessWidget {
|
|||
final bool canTrim;
|
||||
final bool trimmerVisible;
|
||||
final VoidCallback onToggleTrimmer;
|
||||
final bool sendToWidget;
|
||||
final bool showWidgetOption;
|
||||
final bool highlightWidgetOption;
|
||||
final bool isUpdatingWidgetMode;
|
||||
final GlobalKey widgetActionKey;
|
||||
final VoidCallback onToggleSendToWidget;
|
||||
|
||||
MediaFile get media => mediaService.mediaFile;
|
||||
|
||||
|
|
@ -103,6 +115,22 @@ class EditorSideToolbar extends StatelessWidget {
|
|||
onChanged();
|
||||
},
|
||||
),
|
||||
if (showWidgetOption) ...[
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
key: widgetActionKey,
|
||||
child: ActionButton(
|
||||
sendToWidget ? Icons.widgets_rounded : Icons.widgets_outlined,
|
||||
tooltipText: context.lang.shareImageSendToWidget,
|
||||
color: sendToWidget || highlightWidgetOption
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.white,
|
||||
disable: isUpdatingWidgetMode,
|
||||
onPressed: onToggleSendToWidget,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (!sendToWidget) ...[
|
||||
const SizedBox(height: 8),
|
||||
NotificationBadgeComp(
|
||||
count: _displayTimeLabel,
|
||||
|
|
@ -112,6 +140,7 @@ class EditorSideToolbar extends StatelessWidget {
|
|||
onPressed: onEditDisplayTime,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (canTrim) ...[
|
||||
const SizedBox(height: 8),
|
||||
ActionButton(
|
||||
|
|
@ -148,6 +177,7 @@ class EditorSideToolbar extends StatelessWidget {
|
|||
},
|
||||
),
|
||||
],
|
||||
if (!sendToWidget) ...[
|
||||
const SizedBox(height: 8),
|
||||
ActionButton(
|
||||
FontAwesomeIcons.shieldHeart,
|
||||
|
|
@ -158,6 +188,7 @@ class EditorSideToolbar extends StatelessWidget {
|
|||
onPressed: onToggleRequiresAuth,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/elements/my_button.element.dart';
|
||||
|
||||
/// A one-time coach mark for the widget-send option in the editor toolbar.
|
||||
class WidgetShareExplainer extends StatelessWidget {
|
||||
const WidgetShareExplainer({required this.onDismiss, super.key});
|
||||
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Material(
|
||||
color: colors.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 10, 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.lang.shareImageWidgetExplainerTitle,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.lang.shareImageWidgetExplainerBody,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: MyButton(
|
||||
variant: MyButtonVariant.text,
|
||||
onPressed: onDismiss,
|
||||
child: Text(
|
||||
context.lang.shareImageWidgetExplainerDismiss,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Transform.translate(
|
||||
offset: const Offset(-1.5, 0),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: colors.primary, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -237,7 +237,7 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
}
|
||||
|
||||
final msgs = _previewMessages
|
||||
.where((x) => x.type == MessageType.media.name)
|
||||
.where((x) => x.type == MessageType.media.name && !x.isWidgetMedia)
|
||||
.toList();
|
||||
if (msgs.isNotEmpty &&
|
||||
msgs.first.type == MessageType.media.name &&
|
||||
|
|
@ -291,7 +291,7 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
|
||||
if (_hasNonOpenedMediaFile) {
|
||||
final msgs = _previewMessages
|
||||
.where((x) => x.type == MessageType.media.name)
|
||||
.where((x) => x.type == MessageType.media.name && !x.isWidgetMedia)
|
||||
.toList();
|
||||
final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(
|
||||
msgs.first.mediaId!,
|
||||
|
|
@ -313,7 +313,10 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
await context.push(Routes.chatsMessages(widget.group.groupId));
|
||||
await context.push(
|
||||
Routes.chatsMessages(widget.group.groupId),
|
||||
extra: widget.group,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -434,6 +437,7 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
if (_hasNonOpenedMediaFile) {
|
||||
context.push(
|
||||
Routes.chatsMessages(widget.group.groupId),
|
||||
extra: widget.group,
|
||||
);
|
||||
} else {
|
||||
context.push(
|
||||
|
|
|
|||
|
|
@ -81,10 +81,14 @@ class _ChatSubscriptions {
|
|||
}
|
||||
|
||||
class ChatMessagesView extends StatefulWidget {
|
||||
const ChatMessagesView(this.groupId, {super.key});
|
||||
const ChatMessagesView(this.groupId, {this.initialGroup, super.key});
|
||||
|
||||
final String groupId;
|
||||
|
||||
/// Handed over by the caller when it already holds the row, so the first
|
||||
/// frame can draw the real chat instead of waiting for the group stream.
|
||||
final Group? initialGroup;
|
||||
|
||||
@override
|
||||
State<ChatMessagesView> createState() => _ChatMessagesViewState();
|
||||
}
|
||||
|
|
@ -119,6 +123,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_group = widget.initialGroup;
|
||||
textFieldFocus = FocusNode();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
itemPositionsListener.itemPositions.addListener(_loadOlderWhenNeeded);
|
||||
|
|
@ -576,7 +581,12 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_group == null) return Container();
|
||||
if (_group == null) {
|
||||
// Drawing nothing leaves the pushed route black until the group row
|
||||
// arrives, which is plainly visible whenever the database is busy. The
|
||||
// empty app bar keeps the back button reachable during that wait.
|
||||
return Scaffold(appBar: AppBar(), body: const SizedBox.shrink());
|
||||
}
|
||||
final group = _group!;
|
||||
return GestureDetector(
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
|
|
|
|||
|
|
@ -78,7 +78,9 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
|
|||
}
|
||||
|
||||
Future<void> initAsync() async {
|
||||
if (widget.message.senderId == null || widget.message.mediaStored) {
|
||||
if (widget.message.senderId == null ||
|
||||
widget.message.mediaStored ||
|
||||
widget.message.isWidgetMedia) {
|
||||
return;
|
||||
}
|
||||
if (widget.mediaService.mediaFile.requiresAuthentication ||
|
||||
|
|
@ -95,7 +97,9 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
|
|||
}
|
||||
|
||||
Future<void> onDoubleTap() async {
|
||||
if (widget.message.openedAt == null || widget.message.mediaStored) {
|
||||
if (widget.message.isWidgetMedia ||
|
||||
widget.message.openedAt == null ||
|
||||
widget.message.mediaStored) {
|
||||
return;
|
||||
}
|
||||
if (widget.mediaService.canBeOpenedAgain &&
|
||||
|
|
@ -120,6 +124,7 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
|
|||
}
|
||||
|
||||
Future<void> onTap() async {
|
||||
if (widget.message.isWidgetMedia) return;
|
||||
if ((widget.mediaService.mediaFile.downloadState == DownloadState.ready) &&
|
||||
widget.message.openedAt == null) {
|
||||
if (!mounted) return;
|
||||
|
|
|
|||
|
|
@ -137,7 +137,8 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.message.mediaStored ||
|
||||
if (widget.message.isWidgetMedia ||
|
||||
!widget.message.mediaStored ||
|
||||
!widget.mediaService.imagePreviewAvailable) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(
|
||||
|
|
|
|||
|
|
@ -165,7 +165,8 @@ class _MessageInputState extends State<MessageInput> {
|
|||
// The periodic announcement is what keeps the indicator alive, but at its
|
||||
// cadence the first keystroke would take seconds to reach the other side.
|
||||
// That one is announced directly and the timer carries it from there.
|
||||
final wasIdle = _lastTextChangeTime == null ||
|
||||
final wasIdle =
|
||||
_lastTextChangeTime == null ||
|
||||
now.difference(_lastTextChangeTime!) > typingIndicatorInterval;
|
||||
_lastTextChangeTime = now;
|
||||
if (wasIdle &&
|
||||
|
|
@ -257,6 +258,7 @@ class _MessageInputState extends State<MessageInput> {
|
|||
await RustApi.sendMediaToGroups(
|
||||
mediaId: mediaFileService.mediaFile.mediaId,
|
||||
groupIds: [widget.group.groupId],
|
||||
widgetOnly: false,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import 'package:twonly/src/database/twonly.db.dart';
|
|||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/components/animate_icon.comp.dart';
|
||||
import 'package:twonly/src/visual/themes/colors.dart';
|
||||
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/friendly_message_time.comp.dart';
|
||||
import 'package:twonly/src/visual/views/chats/chat_messages_components/file_limit_reached.dialog.dart';
|
||||
|
||||
enum MessageSendState {
|
||||
|
|
@ -95,6 +96,12 @@ class _MessageSendStateIconState extends State<MessageSendStateIcon> {
|
|||
var hasLoader = false;
|
||||
GestureTapCallback? onTap;
|
||||
|
||||
/// Only the chat view knows which of the sent messages the receivers have
|
||||
/// acknowledged; elsewhere the set stays empty and 'sent' is the last state.
|
||||
final ackedMessageIds =
|
||||
ChatMessageActionScope.maybeOf(context)?.ackedMessageIds ??
|
||||
const <String>{};
|
||||
|
||||
for (final message in widget.messages) {
|
||||
if (icons.length == 2) break;
|
||||
if (kindsAlreadyShown.contains(message.type)) continue;
|
||||
|
|
@ -125,6 +132,13 @@ class _MessageSendStateIconState extends State<MessageSendStateIcon> {
|
|||
}
|
||||
}
|
||||
text = context.lang.messageSendState_Received;
|
||||
if (message.isWidgetMedia &&
|
||||
mediaFile != null &&
|
||||
mediaFile.downloadState == DownloadState.downloading) {
|
||||
text = context.lang.messageSendState_Loading;
|
||||
icon = getLoaderIcon(color);
|
||||
hasLoader = true;
|
||||
}
|
||||
if (widget.canBeReopened) {
|
||||
textWidget = Text(
|
||||
context.lang.doubleClickToReopen,
|
||||
|
|
@ -153,7 +167,9 @@ class _MessageSendStateIconState extends State<MessageSendStateIcon> {
|
|||
size: 12,
|
||||
color: color,
|
||||
);
|
||||
text = context.lang.messageSendState_Send;
|
||||
text = ackedMessageIds.contains(message.messageId)
|
||||
? context.lang.messageSendState_Delivered
|
||||
: context.lang.messageSendState_Send;
|
||||
case MessageSendState.sending:
|
||||
icon = getLoaderIcon(color);
|
||||
text = context.lang.messageSendState_Sending;
|
||||
|
|
@ -193,6 +209,12 @@ class _MessageSendStateIconState extends State<MessageSendStateIcon> {
|
|||
hasLoader = true;
|
||||
}
|
||||
|
||||
// Only the icon marks a widget media apart; the state text stays the
|
||||
// same as for any other media so the two read alike.
|
||||
if (message.isWidgetMedia && !hasLoader) {
|
||||
icon = Icon(Icons.widgets_rounded, size: 12, color: color);
|
||||
}
|
||||
|
||||
if (message.mediaStored && message.openedAt != null) {
|
||||
icon = FaIcon(FontAwesomeIcons.floppyDisk, size: 12, color: color);
|
||||
text = context.lang.messageStoredInGallery;
|
||||
|
|
|
|||
|
|
@ -84,7 +84,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
super.initState();
|
||||
_currentMediaSender = widget.group.groupName;
|
||||
|
||||
if (widget.initialMessage != null) {
|
||||
if (widget.initialMessage != null &&
|
||||
!widget.initialMessage!.isWidgetMedia) {
|
||||
allMediaFiles = [widget.initialMessage!];
|
||||
}
|
||||
|
||||
|
|
@ -137,6 +138,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
.listen((messages) async {
|
||||
await _messageUpdateLock.protect(() async {
|
||||
for (final msg in messages) {
|
||||
if (msg.isWidgetMedia) {
|
||||
continue;
|
||||
}
|
||||
if (_alreadyOpenedMediaIds.contains(msg.mediaId)) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -210,7 +214,10 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
if (group != null &&
|
||||
group.draftMessage != null &&
|
||||
group.draftMessage != '') {
|
||||
context.replace(Routes.chatsMessages(group.groupId));
|
||||
context.replace(
|
||||
Routes.chatsMessages(group.groupId),
|
||||
extra: group,
|
||||
);
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -204,7 +204,10 @@ class _ContactViewState extends State<ContactView> {
|
|||
contact.userId,
|
||||
);
|
||||
if (group != null && context.mounted) {
|
||||
await context.push(Routes.chatsMessages(group.groupId));
|
||||
await context.push(
|
||||
Routes.chatsMessages(group.groupId),
|
||||
extra: group,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
|
@ -316,13 +319,22 @@ class _ContactViewState extends State<ContactView> {
|
|||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(
|
||||
child: Text(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Encryption: ${switch (contact.signalVersion) {
|
||||
SignalVersion.v1 => 'Signal Protocol (v1)',
|
||||
SignalVersion.v2 => 'PQXDH (v2)',
|
||||
}}',
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Can send widgets: ${contact.widgetSharingAllowed ? 'Yes' : 'No'}',
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -102,7 +102,10 @@ class _MutualGroupsExpansionTileCompState
|
|||
),
|
||||
),
|
||||
onTap: () {
|
||||
context.push(Routes.chatsMessages(group.groupId));
|
||||
context.push(
|
||||
Routes.chatsMessages(group.groupId),
|
||||
extra: group,
|
||||
);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
|||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/services/home_widget.service.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||
import 'package:twonly/src/visual/components/contact_groups.comp.dart';
|
||||
|
|
@ -78,6 +79,11 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
|||
final Set<String> _selectedGroupIds = {};
|
||||
final List<StreamSubscription<dynamic>> _subscriptions = [];
|
||||
|
||||
/// Widgets on the home screen configured with this contact group. Its
|
||||
/// members are what makes those widgets able to receive anything, so the
|
||||
/// group cannot be deleted while any of them is still placed.
|
||||
int _widgetCount = 0;
|
||||
|
||||
bool get _isEditing => widget.contactGroup != null;
|
||||
|
||||
@override
|
||||
|
|
@ -91,6 +97,7 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
|||
contactGroup?.showAsShortcut ?? widget.initialShowAsShortcut;
|
||||
_showAsLabel = contactGroup?.showAsLabel ?? !widget.initialShowAsShortcut;
|
||||
_emoji = contactGroup?.emoji;
|
||||
if (contactGroup != null) unawaited(_loadWidgetUsage(contactGroup.id));
|
||||
|
||||
_subscriptions.add(
|
||||
twonlyDB.contactsDao.watchAllAcceptedContacts().listen((contacts) {
|
||||
|
|
@ -154,9 +161,13 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
|||
}
|
||||
|
||||
List<_MemberEntry> get _members {
|
||||
final entries = <_MemberEntry>[
|
||||
final entries =
|
||||
<_MemberEntry>[
|
||||
for (final contact in _contacts)
|
||||
_MemberEntry(name: getContactDisplayName(contact), contact: contact),
|
||||
_MemberEntry(
|
||||
name: getContactDisplayName(contact),
|
||||
contact: contact,
|
||||
),
|
||||
for (final group in _groups)
|
||||
_MemberEntry(name: group.groupName, group: group),
|
||||
]..sort(
|
||||
|
|
@ -232,15 +243,49 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
|||
userIds: _selectedUserIds,
|
||||
groupIds: _selectedGroupIds,
|
||||
);
|
||||
// A widget's configuration UI lists the groups from the manifest, so a
|
||||
// group that was just created or renamed is invisible to it until the
|
||||
// manifest is republished.
|
||||
unawaited(HomeWidgetService.refreshManifest());
|
||||
if (mounted) Navigator.pop(context, id);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadWidgetUsage(int contactGroupId) async {
|
||||
final widgets = await HomeWidgetService.placedWidgets();
|
||||
final count = widgets
|
||||
.where((placed) => placed.contactGroupIds.contains(contactGroupId))
|
||||
.length;
|
||||
if (!mounted) return;
|
||||
setState(() => _widgetCount = count);
|
||||
}
|
||||
|
||||
Future<void> _delete() async {
|
||||
final contactGroup = widget.contactGroup;
|
||||
if (contactGroup == null) return;
|
||||
if (_widgetCount > 0) {
|
||||
// Deleting would silently strip the widget of everyone allowed to send to
|
||||
// it, leaving a placed widget that can never fill up again. Neither
|
||||
// platform lets the app take the widget down, so the user has to.
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.lang.deleteContactGroup),
|
||||
content: Text(
|
||||
context.lang.contactGroupDeleteBlockedByWidget(_widgetCount),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.lang.ok),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
|
|
@ -260,6 +305,7 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
|||
);
|
||||
if (confirmed != true) return;
|
||||
await twonlyDB.contactGroupsDao.deleteContactGroup(contactGroup.id);
|
||||
unawaited(HomeWidgetService.refreshManifest());
|
||||
if (mounted) Navigator.pop(context);
|
||||
}
|
||||
|
||||
|
|
@ -343,7 +389,9 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
|||
width: painter.width.clamp(16, 240) + 8,
|
||||
child: TextField(
|
||||
controller: _nameController,
|
||||
autofocus: true,
|
||||
// Only for a brand-new group: opening an existing one to change its
|
||||
// colour or members should not throw up the keyboard.
|
||||
autofocus: widget.contactGroup == null,
|
||||
maxLength: 24,
|
||||
textAlign: TextAlign.center,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
|
|
@ -406,7 +454,12 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
|||
],
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
body: GestureDetector(
|
||||
// The name and filter fields sit in a long scrolling page, so tapping
|
||||
// anywhere that is not a control should put the keyboard away.
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 100),
|
||||
children: [
|
||||
_nameEditor(),
|
||||
|
|
@ -485,6 +538,21 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
|||
),
|
||||
onTap: _selectEmoji,
|
||||
),
|
||||
if (_widgetCount > 0)
|
||||
// Stated rather than offered as a switch: which groups a widget
|
||||
// draws from is chosen on the home screen, not here.
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(context.lang.contactGroupUsedByWidget),
|
||||
subtitle: Text(
|
||||
context.lang.contactGroupUsedByWidgetSubtitle(_widgetCount),
|
||||
),
|
||||
trailing: FaIcon(
|
||||
FontAwesomeIcons.image,
|
||||
size: 18,
|
||||
color: context.color.primary,
|
||||
),
|
||||
),
|
||||
const Divider(height: 40),
|
||||
Text(
|
||||
context.lang.contactGroupMembers,
|
||||
|
|
@ -501,6 +569,7 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
|||
for (final entry in members) _memberTile(entry),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ class GroupMemberContextMenu extends StatelessWidget {
|
|||
if (!navigator.mounted) return;
|
||||
await navigator.context.push(
|
||||
Routes.chatsMessages(directChat.groupId),
|
||||
extra: directChat,
|
||||
);
|
||||
},
|
||||
icon: FontAwesomeIcons.message,
|
||||
|
|
|
|||
|
|
@ -188,6 +188,7 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
|
|||
},
|
||||
child: Text(
|
||||
context.lang.passwordlessRecoveryRecoverBtn,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/elements/my_card.element.dart';
|
||||
|
||||
/// A recovery option, tinted red until it is actually set up.
|
||||
class RecoveryCard extends StatelessWidget {
|
||||
const RecoveryCard({
|
||||
required this.icon,
|
||||
|
|
@ -19,69 +20,12 @@ class RecoveryCard extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectiveColor = isEnabled ? context.color.primary : Colors.red;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: context.color.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
return MyCard(
|
||||
icon: icon,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: effectiveColor.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: icon is IconData
|
||||
? Icon(
|
||||
icon as IconData,
|
||||
color: effectiveColor,
|
||||
size: 24,
|
||||
)
|
||||
: FaIcon(
|
||||
icon as FaIconData?,
|
||||
color: effectiveColor,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: context.color.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: context.color.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
accentColor: isEnabled ? context.color.primary : Colors.red,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import 'package:twonly/src/utils/storage.dart';
|
|||
import 'package:twonly/src/visual/components/alert.dialog.dart';
|
||||
import 'package:twonly/src/visual/components/snackbar.dart';
|
||||
import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
|
||||
import 'package:twonly/src/visual/views/settings/developer/home_widget_developer.view.dart';
|
||||
import 'package:twonly/src/visual/views/settings/developer/user_discovery_developer.view.dart';
|
||||
|
||||
class DeveloperSettingsView extends StatefulWidget {
|
||||
|
|
@ -220,6 +221,7 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
|
|||
stored: true,
|
||||
requiresAuthentication: false,
|
||||
isDraftMedia: false,
|
||||
isWidgetMedia: false,
|
||||
isFavorite: false,
|
||||
hasCropAnalyzed: false,
|
||||
hasThumbnail: false,
|
||||
|
|
@ -413,6 +415,11 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
|
|||
onTap: () =>
|
||||
context.navPush(const UserDiscoveryDeveloperView()),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Home Widget State'),
|
||||
subtitle: const Text('What the widget reads'),
|
||||
onTap: () => context.navPush(const HomeWidgetDeveloperView()),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Share local database'),
|
||||
onTap: () async {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,260 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/services/home_widget.service.dart';
|
||||
import 'package:twonly/src/visual/components/snackbar.dart';
|
||||
|
||||
/// Shows the files the home widget reads, from the app side of the App Group.
|
||||
///
|
||||
/// The widget extension is a separate process that cannot be inspected from
|
||||
/// here, and it renders a blank square for every failure. This screen answers
|
||||
/// the half of the question the app can answer on its own: whether Rust wrote a
|
||||
/// manifest at all, what is in it, and whether the images it points at exist.
|
||||
/// Whether the extension can *reach* those same files is what the widget's own
|
||||
/// `os_log` output ("eu.twonly.widget") reports.
|
||||
class HomeWidgetDeveloperView extends StatefulWidget {
|
||||
const HomeWidgetDeveloperView({super.key});
|
||||
|
||||
@override
|
||||
State<HomeWidgetDeveloperView> createState() =>
|
||||
_HomeWidgetDeveloperViewState();
|
||||
}
|
||||
|
||||
class _HomeWidgetDeveloperViewState extends State<HomeWidgetDeveloperView> {
|
||||
String _report = 'Loading…';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
Directory get _widgetDirectory =>
|
||||
Directory('${AppEnvironment.supportDir}/widget');
|
||||
|
||||
Future<void> _load() async {
|
||||
final report = await _buildReport();
|
||||
if (!mounted) return;
|
||||
setState(() => _report = report);
|
||||
}
|
||||
|
||||
Future<String> _buildReport() async {
|
||||
final lines = <String>[
|
||||
'platform: ${Platform.operatingSystem}',
|
||||
'support dir: ${AppEnvironment.supportDir}',
|
||||
'',
|
||||
];
|
||||
|
||||
final root = _widgetDirectory;
|
||||
lines
|
||||
..addAll(await _reconcileSection())
|
||||
..add('');
|
||||
if (!root.existsSync()) {
|
||||
lines
|
||||
..add('${root.path} does not exist.')
|
||||
..add('Rust has never written widget state on this install.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
lines
|
||||
..addAll(await _manifestSection(File('${root.path}/manifest.json')))
|
||||
..add('')
|
||||
..addAll(_nativeConfigSection(File('${root.path}/native-config.json')))
|
||||
..add('')
|
||||
..addAll(_imagesSection(Directory('${root.path}/images')));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/// What WidgetKit itself says is on the home screen. This is the only
|
||||
/// authoritative answer: the placement file below is merely derived from it,
|
||||
/// so when the two disagree the fault is between these two sections.
|
||||
Future<List<String>> _reconcileSection() async {
|
||||
HomeWidgetService.invalidate();
|
||||
final report = await HomeWidgetService.reconcileReport();
|
||||
if (report == null) {
|
||||
return ['-- WidgetKit --', 'Not applicable on this platform.'];
|
||||
}
|
||||
if (report['error'] case final error?) {
|
||||
return [
|
||||
'-- WidgetKit --',
|
||||
'QUERY FAILED: $error',
|
||||
'The placement file was left untouched.',
|
||||
];
|
||||
}
|
||||
final widgets = (report['widgets'] as List?) ?? const [];
|
||||
final summary =
|
||||
'${widgets.length} widget(s) installed, '
|
||||
'${report['matched']} confirmed placed';
|
||||
final lines = <String>[
|
||||
'-- WidgetKit --',
|
||||
'supported: ${report['supported']}',
|
||||
summary,
|
||||
];
|
||||
for (final widget in widgets.cast<Map<Object?, Object?>>()) {
|
||||
lines.add(
|
||||
' kind=${widget['kind']} family=${widget['family']} '
|
||||
'mine=${widget['mine']} live=${widget['live'] ?? '-'} '
|
||||
'groups=${widget['group_ids'] ?? '-'}'
|
||||
'${widget['configuration'] == null ? '' : ' UNREADABLE'}',
|
||||
);
|
||||
}
|
||||
if (widgets.isEmpty) {
|
||||
lines.add(' (none — the placement file should now be empty)');
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
Future<List<String>> _manifestSection(File manifest) async {
|
||||
if (!manifest.existsSync()) {
|
||||
return [
|
||||
'-- manifest.json --',
|
||||
'MISSING. The widget shows "Open twonly once" in this state.',
|
||||
];
|
||||
}
|
||||
|
||||
final raw = await manifest.readAsString();
|
||||
final lines = <String>[
|
||||
'-- manifest.json --',
|
||||
'${raw.length} bytes, modified ${manifest.statSync().modified.toLocal()}',
|
||||
];
|
||||
|
||||
final Map<String, dynamic> decoded;
|
||||
try {
|
||||
decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (error) {
|
||||
return lines
|
||||
..add('UNPARSEABLE: $error')
|
||||
..add(raw);
|
||||
}
|
||||
|
||||
final groups = (decoded['groups'] as List?) ?? [];
|
||||
final images = (decoded['images'] as List?) ?? [];
|
||||
lines.add('${groups.length} contact groups, ${images.length} images');
|
||||
|
||||
// The group IDs are the whole matching rule: the widget shows an image only
|
||||
// when its sender's groups intersect the groups the widget was configured
|
||||
// with, so a mismatch here is the difference between a picture and a blank.
|
||||
for (final group in groups.cast<Map<String, dynamic>>()) {
|
||||
lines.add(' group ${group['id']}: ${group['name']}');
|
||||
}
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
for (final image in images.cast<Map<String, dynamic>>()) {
|
||||
final expiresAt = (image['expires_at'] as num?)?.toInt() ?? 0;
|
||||
final exists = File('${image['path']}').existsSync();
|
||||
lines.add(
|
||||
' ${image['media_id']} from ${image['sender']} '
|
||||
'groups=${image['group_ids']} '
|
||||
'${expiresAt > now ? 'valid' : 'EXPIRED'} '
|
||||
'${exists ? '' : 'FILE MISSING'}',
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
static const _missingNativeConfig =
|
||||
'MISSING. On iOS the widget writes this the first time WidgetKit builds '
|
||||
'its timeline; until then Rust grants nobody permission to share with '
|
||||
'the widget.';
|
||||
|
||||
List<String> _nativeConfigSection(File config) {
|
||||
if (!config.existsSync()) {
|
||||
return ['-- native-config.json --', _missingNativeConfig];
|
||||
}
|
||||
return [
|
||||
'-- native-config.json --',
|
||||
'modified ${config.statSync().modified.toLocal()}',
|
||||
config.readAsStringSync(),
|
||||
];
|
||||
}
|
||||
|
||||
List<String> _imagesSection(Directory images) {
|
||||
if (!images.existsSync()) {
|
||||
return ['-- images/ --', 'MISSING'];
|
||||
}
|
||||
final files = images.listSync().whereType<File>().toList();
|
||||
return [
|
||||
'-- images/ --',
|
||||
if (files.isEmpty) '(empty)',
|
||||
for (final file in files)
|
||||
' ${file.uri.pathSegments.last} ${file.lengthSync()} bytes',
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Home Widget'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
tooltip: 'Copy report',
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: _report));
|
||||
if (!context.mounted) return;
|
||||
showSnackbar(
|
||||
context,
|
||||
'Report copied.',
|
||||
level: SnackbarLevel.info,
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Reload',
|
||||
onPressed: _load,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () async {
|
||||
await HomeWidgetService.syncPermissions();
|
||||
await _load();
|
||||
},
|
||||
child: const Text('Rewrite manifest'),
|
||||
),
|
||||
),
|
||||
const Expanded(
|
||||
child: _ReloadWidgetButton(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SelectableText(
|
||||
_report,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReloadWidgetButton extends StatelessWidget {
|
||||
const _ReloadWidgetButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const FilledButton.tonal(
|
||||
onPressed: HomeWidgetService.refresh,
|
||||
child: Text('Reload widget'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -110,6 +110,11 @@ class SettingsMainView extends StatelessWidget {
|
|||
text: context.lang.settingsNotification,
|
||||
onTap: () => context.push(Routes.settingsNotification),
|
||||
),
|
||||
BetterListTile(
|
||||
icon: Icons.widgets_rounded,
|
||||
text: context.lang.settingsWidgets,
|
||||
onTap: () => context.push(Routes.settingsWidgets),
|
||||
),
|
||||
BetterListTile(
|
||||
icon: FontAwesomeIcons.chartPie,
|
||||
iconSize: 15,
|
||||
|
|
|
|||
185
lib/src/visual/views/settings/widgets/widget_detail.view.dart
Normal file
185
lib/src/visual/views/settings/widgets/widget_detail.view.dart
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/services/home_widget.service.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/components/alert.dialog.dart';
|
||||
import 'package:twonly/src/visual/views/contact/contact_group_settings.view.dart';
|
||||
import 'package:twonly/src/visual/views/settings/widgets/widgets.view.dart';
|
||||
|
||||
/// One placed widget: who may send to it, and what it is showing right now.
|
||||
class WidgetDetailView extends StatefulWidget {
|
||||
const WidgetDetailView({required this.widget, super.key});
|
||||
|
||||
final PlacedWidget widget;
|
||||
|
||||
@override
|
||||
State<WidgetDetailView> createState() => _WidgetDetailViewState();
|
||||
}
|
||||
|
||||
class _WidgetDetailViewState extends State<WidgetDetailView> {
|
||||
List<WidgetImage>? _images;
|
||||
List<ContactGroup> _groups = const [];
|
||||
Timer? _ticker;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_load());
|
||||
// Every entry shows how much of its 24 hours is left, so the screen has to
|
||||
// keep counting down while it is open.
|
||||
_ticker = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final images = await HomeWidgetService.imagesFor(widget.widget);
|
||||
final all = await twonlyDB.contactGroupsDao.watchAllContactGroups().first;
|
||||
final selected = widget.widget.contactGroupIds.toSet();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_images = images;
|
||||
_groups = all.where((group) => selected.contains(group.id)).toList();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _delete(WidgetImage image) async {
|
||||
final confirmed = await showAlertDialog(
|
||||
context,
|
||||
context.lang.widgetsDeleteImage,
|
||||
context.lang.widgetsDeleteImageConfirm,
|
||||
customOk: context.lang.widgetsDeleteImage,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
await HomeWidgetService.deleteImage(image.mediaId);
|
||||
await _load();
|
||||
}
|
||||
|
||||
/// Coarse on purpose: the exact second an image disappears is noise, and the
|
||||
/// user only needs to know whether it is here for a while or nearly gone.
|
||||
String _remaining(WidgetImage image) {
|
||||
final remaining = image.remaining;
|
||||
if (remaining.inHours >= 1) {
|
||||
return context.lang.widgetsDurationHours(remaining.inHours);
|
||||
}
|
||||
return context.lang.widgetsDurationMinutes(
|
||||
remaining.inMinutes.clamp(1, 59),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final images = _images;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(context.lang.widgetsTitle)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 32),
|
||||
children: [
|
||||
if (_groups.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Text(
|
||||
context.lang.widgetsNoGroupsHint,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
)
|
||||
else
|
||||
for (final group in _groups)
|
||||
ListTile(
|
||||
leading: group.emoji == null
|
||||
? const Icon(Icons.group_outlined)
|
||||
: Text(
|
||||
group.emoji!,
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
title: Text(group.name),
|
||||
subtitle: Text(context.lang.widgetsEditGroup),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () async {
|
||||
await context.navPush(
|
||||
ContactGroupSettingsView(contactGroup: group),
|
||||
);
|
||||
await _load();
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 0),
|
||||
child: Text(
|
||||
changeGroupsHint(context),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
_sectionTitle(context.lang.widgetsCurrentImages),
|
||||
if (images == null)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (images.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Text(
|
||||
context.lang.widgetsNoImagesHint,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
)
|
||||
else
|
||||
for (final image in images) _imageTile(image),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionTitle(String text) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _imageTile(WidgetImage image) {
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(
|
||||
image.file,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
// The file is deleted the moment the user confirms, and Flutter keeps
|
||||
// decoded frames in a cache keyed by path, so a stale entry would
|
||||
// outlive the file it came from.
|
||||
cacheWidth: 168,
|
||||
errorBuilder: (_, _, _) => const SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: Icon(Icons.broken_image_outlined),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(context.lang.widgetsFrom(image.sender)),
|
||||
subtitle: Text(context.lang.widgetsExpiresIn(_remaining(image))),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: context.lang.widgetsDeleteImage,
|
||||
onPressed: () => _delete(image),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
|
||||
/// Explains how to put a twonly widget on the home screen.
|
||||
///
|
||||
/// A widget can only be placed by the user, from the home screen itself —
|
||||
/// neither iOS nor Android exposes an API for an app to add or configure one on
|
||||
/// the user's behalf — so instructions are the most the app can offer.
|
||||
class WidgetSetupGuide extends StatelessWidget {
|
||||
const WidgetSetupGuide({required this.showIntro, super.key});
|
||||
|
||||
/// Whether to lead with what the feature is. Shown when the user has no
|
||||
/// widget yet; skipped when this sits under a list they can already see.
|
||||
final bool showIntro;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showIntro) ...[
|
||||
Center(
|
||||
child: Icon(
|
||||
Icons.widgets_outlined,
|
||||
size: 56,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.lang.widgetsIntroTitle,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.lang.widgetsIntroBody,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
Text(
|
||||
showIntro
|
||||
? context.lang.widgetsNoneTitle
|
||||
: context.lang.widgetsAddAnother,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
Platform.isIOS
|
||||
? context.lang.widgetsSetupIos
|
||||
: context.lang.widgetsSetupAndroid,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.textTheme.bodySmall?.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
161
lib/src/visual/views/settings/widgets/widgets.view.dart
Normal file
161
lib/src/visual/views/settings/widgets/widgets.view.dart
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/services/home_widget.service.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/elements/my_card.element.dart';
|
||||
import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart';
|
||||
import 'package:twonly/src/visual/views/settings/widgets/widget_detail.view.dart';
|
||||
import 'package:twonly/src/visual/views/settings/widgets/widget_setup_guide.comp.dart';
|
||||
|
||||
/// Lists the widgets on the home screen and who may send to each of them.
|
||||
///
|
||||
/// Neither platform lets an app place or reconfigure a widget on the user's
|
||||
/// behalf, so this screen explains the placement and then reflects the result.
|
||||
/// What it *can* offer directly is the part that lives in the app: which
|
||||
/// contacts are in each group.
|
||||
class WidgetsSettingsView extends StatefulWidget {
|
||||
const WidgetsSettingsView({super.key});
|
||||
|
||||
@override
|
||||
State<WidgetsSettingsView> createState() => _WidgetsSettingsViewState();
|
||||
}
|
||||
|
||||
class _WidgetsSettingsViewState extends State<WidgetsSettingsView> {
|
||||
List<PlacedWidget>? _widgets;
|
||||
String? _queryError;
|
||||
Map<int, ContactGroup> _groups = const {};
|
||||
Map<String, int> _imageCounts = const {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
// The manifest and the placement file are rewritten by Rust and by the
|
||||
// native widgets, so this screen always re-reads rather than caching.
|
||||
HomeWidgetService.invalidate();
|
||||
await HomeWidgetService.syncPermissions();
|
||||
final result = await HomeWidgetService.placedWidgetsResult();
|
||||
final widgets = result.widgets;
|
||||
final groups = await twonlyDB.contactGroupsDao
|
||||
.watchAllContactGroups()
|
||||
.first;
|
||||
final counts = <String, int>{};
|
||||
for (final widget in widgets) {
|
||||
counts[widget.id] = (await HomeWidgetService.imagesFor(widget)).length;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_widgets = widgets;
|
||||
_queryError = result.error;
|
||||
_groups = {for (final group in groups) group.id: group};
|
||||
_imageCounts = counts;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final widgets = _widgets;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(context.lang.widgetsTitle)),
|
||||
body: widgets == null
|
||||
? const Center(child: ThreeRotatingDots(size: 40))
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: [
|
||||
// Without this the list would quietly present a stale file as
|
||||
// the state of the home screen.
|
||||
if (_queryError != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 8),
|
||||
child: Text(
|
||||
context.lang.widgetsQueryFailed,
|
||||
style: TextStyle(color: context.color.error),
|
||||
),
|
||||
),
|
||||
if (widgets.isEmpty)
|
||||
const WidgetSetupGuide(showIntro: true)
|
||||
else ...[
|
||||
for (final widget in widgets) _tile(widget),
|
||||
const Divider(height: 32),
|
||||
const WidgetSetupGuide(showIntro: false),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Several widgets can share one contact group, so the size is what tells
|
||||
/// two cards apart.
|
||||
String _size(PlacedWidget widget) => switch (widget.family) {
|
||||
'systemSmall' => context.lang.widgetsSizeSmall,
|
||||
'systemMedium' => context.lang.widgetsSizeMedium,
|
||||
'systemLarge' => context.lang.widgetsSizeLarge,
|
||||
_ => context.lang.widgetsSizeUnknown,
|
||||
};
|
||||
|
||||
Widget _tile(PlacedWidget widget) {
|
||||
final selected = [
|
||||
for (final id in widget.contactGroupIds) ?_groups[id],
|
||||
];
|
||||
final count = _imageCounts[widget.id] ?? 0;
|
||||
final unconfigured = selected.isEmpty;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: MyCard(
|
||||
icon: FontAwesomeIcons.image,
|
||||
accentColor: unconfigured ? context.color.error : null,
|
||||
titleColor: unconfigured ? context.color.error : null,
|
||||
title: unconfigured
|
||||
? context.lang.widgetsNoGroups
|
||||
: selected
|
||||
.map(
|
||||
(g) => '${g.emoji == null ? '' : '${g.emoji} '}${g.name}',
|
||||
)
|
||||
.join(', '),
|
||||
subtitle: unconfigured
|
||||
? context.lang.widgetsNoGroupsHint
|
||||
: '${_size(widget)} · '
|
||||
'${count == 0 ? context.lang.widgetsNoImages : context.lang.widgetsCurrentImages}',
|
||||
trailing: count == 0
|
||||
? null
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Not a warning, just a count: the badge's default error red
|
||||
// reads as something being wrong.
|
||||
Badge(
|
||||
backgroundColor: context.color.primary,
|
||||
textColor: context.color.onPrimary,
|
||||
label: Text('$count'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: context.color.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
await context.navPush(WidgetDetailView(widget: widget));
|
||||
await _load();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform wording for how the contact groups of a placed widget are changed.
|
||||
String changeGroupsHint(BuildContext context) => Platform.isIOS
|
||||
? context.lang.widgetsChangeGroupsIos
|
||||
: context.lang.widgetsChangeGroupsAndroid;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n INSERT INTO messages(\n group_id,\n message_id,\n sender_id,\n type,\n media_id,\n additional_message_data,\n quotes_message_id,\n is_widget_media,\n opened_at,\n opened_by_all,\n created_at\n ) VALUES (?, ?, ?, 'media', ?, ?, ?, ?, ?, ?, ?)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "013966ba4d977a6d396259f9f6706e00ef02c0b9923d0dac6b5719223482822e"
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n INSERT INTO messages(\n group_id,\n message_id,\n sender_id,\n type,\n media_id,\n additional_message_data,\n quotes_message_id,\n created_at\n ) VALUES (?, ?, ?, 'media', ?, ?, ?, ?)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 7
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "26099393d47834ce775cee6533f42d4cab0069c385c3efe8be764c6eacd8e112"
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT widget_sharing_granted FROM contacts WHERE user_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "widget_sharing_granted",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "contacts",
|
||||
"name": "widget_sharing_granted"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "38975397d77e89bc70bdbead5a919774559c6c41add3171b8b47244dcaa98018"
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n UPDATE contacts SET\n username = COALESCE(?, username),\n display_name = CASE WHEN ? THEN ? ELSE display_name END,\n avatar_svg_compressed = CASE WHEN ? THEN ? ELSE avatar_svg_compressed END,\n sender_profile_counter = COALESCE(?, sender_profile_counter),\n signal_version = COALESCE(?, signal_version),\n accepted = COALESCE(?, accepted),\n requested = COALESCE(?, requested),\n requested_by_user = COALESCE(?, requested_by_user),\n deleted_by_user = COALESCE(?, deleted_by_user),\n blocked = COALESCE(?, blocked)\n WHERE user_id = ?\n ",
|
||||
"query": "\n UPDATE contacts SET\n username = COALESCE(?, username),\n display_name = CASE WHEN ? THEN ? ELSE display_name END,\n avatar_svg_compressed = CASE WHEN ? THEN ? ELSE avatar_svg_compressed END,\n sender_profile_counter = COALESCE(?, sender_profile_counter),\n signal_version = COALESCE(?, signal_version),\n accepted = COALESCE(?, accepted),\n requested = COALESCE(?, requested),\n requested_by_user = COALESCE(?, requested_by_user),\n deleted_by_user = COALESCE(?, deleted_by_user),\n blocked = COALESCE(?, blocked),\n widget_sharing_allowed = COALESCE(?, widget_sharing_allowed)\n WHERE user_id = ?\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 13
|
||||
"Right": 14
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "90b3bd6a65232aaf0f49ad4277e5a79948b901302a3eec44b49c74e4f0185991"
|
||||
"hash": "43114af54b919556644128c208ad3b4a6a2c01f1d16b011267fce8a4363fa031"
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT media_id FROM media_files WHERE download_state = 'pending'",
|
||||
"query": "SELECT media_id FROM media_files\n WHERE download_state IN ('pending', 'downloading')\n ORDER BY is_widget_media DESC, created_at DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
|
|
@ -22,5 +22,5 @@
|
|||
false
|
||||
]
|
||||
},
|
||||
"hash": "b84d4a6bba4e8eb084bbb6b6ffdff05df10bc93c4ff9bfba248f376439e268a1"
|
||||
"hash": "44d1e907c77acd1d5cb5762c778494b984ec433c5012edcadbd1928f70409111"
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE contacts SET requested = 1, deleted_by_user = 1 WHERE user_id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "463452781891c7f94456ca4869f7e89b1fc0586c286e985a5853af7bfc38cb31"
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE media_files SET download_state = 'reuploadRequested' WHERE media_id = ?",
|
||||
"query": "UPDATE media_files SET download_state = 'reuploadRequested'\n WHERE media_id = ? AND download_state IS NOT 'ready'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
|
|
@ -8,5 +8,5 @@
|
|||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2cc4a73378cf37ff6e3dcbd761fec56686a8f9820a3bb2c489e94fb036de0786"
|
||||
"hash": "5bca89eb7f5df3a6190d8b343bf4d880b10ee6472c469c2c4a7781a91dec0c68"
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n INSERT INTO media_files(\n media_id,\n type,\n download_state,\n requires_authentication,\n display_limit_in_milliseconds,\n download_token,\n encryption_key,\n encryption_mac,\n encryption_nonce,\n created_at\n ) VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?)\n ",
|
||||
"query": "\n INSERT INTO media_files(\n media_id,\n type,\n download_state,\n requires_authentication,\n display_limit_in_milliseconds,\n download_token,\n encryption_key,\n encryption_mac,\n encryption_nonce,\n is_widget_media,\n created_at\n ) VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 9
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d50a6ee6ff69408981408585348040d91c2e430c936b91bd02a3f7f562edf658"
|
||||
"hash": "783995c916cafa69d2aed5bdcbb9f153e1d599a485e63efe48be297a696bb8cf"
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE media_files SET download_state = 'pending'\n WHERE media_id = ? AND download_state = 'downloading'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "785424639a0aa856ab9e037a7949d705f5bd2ecc7b6ab2f88c51df90197577c6"
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE media_files SET download_state = 'pending' WHERE download_state = 'downloading'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9e596b383a0af665ed0645792af43636345cf9372c0d4005df9d179f1004a619"
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE media_files SET type = ?, download_state = 'pending',\n requires_authentication = ?, display_limit_in_milliseconds = ?,\n download_token = ?, encryption_key = ?, encryption_mac = ?,\n encryption_nonce = ?, created_at = ? WHERE media_id = ?",
|
||||
"query": "UPDATE media_files SET type = ?, download_state = 'pending',\n requires_authentication = ?, display_limit_in_milliseconds = ?,\n download_token = ?, encryption_key = ?, encryption_mac = ?,\n encryption_nonce = ?, is_widget_media = ?, created_at = ? WHERE media_id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 9
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2b86ca80f38f47f115c8c8e6ce79343574e6ca435946c303dd8f35e68bfc57ef"
|
||||
"hash": "a34ec4535e12998589d315b60e1ce1a0378f6c85fc006f8362df8d11381933d1"
|
||||
}
|
||||
|
|
@ -299,6 +299,28 @@
|
|||
"name": "requested_by_user"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "widget_sharing_allowed",
|
||||
"ordinal": 27,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "contacts",
|
||||
"name": "widget_sharing_allowed"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "widget_sharing_granted",
|
||||
"ordinal": 28,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "contacts",
|
||||
"name": "widget_sharing_granted"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
|
@ -331,6 +353,8 @@
|
|||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE contacts SET widget_sharing_allowed = ? WHERE user_id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c86a99f1f94fc29cfb5e270f1c8c529bb8b04517c14c6c4742c16b43aaa10ea1"
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT message_id, sender_id AS \"sender_id!: i64\" FROM messages\n WHERE media_id = ? AND opened_at IS NULL AND sender_id IS NOT NULL",
|
||||
"query": "SELECT message_id, sender_id AS \"sender_id!: i64\" FROM messages\n WHERE media_id = ? AND sender_id IS NOT NULL\n AND (opened_at IS NULL OR is_widget_media = 1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
|
|
@ -34,5 +34,5 @@
|
|||
true
|
||||
]
|
||||
},
|
||||
"hash": "e569dd744fb4a5e49cfbe14c61005d14f8c0f82a8640269ec29d5e9edabb106b"
|
||||
"hash": "f96e23fc46fe9d566e332d17edece9fd3f3d617907841e30bfeaba38eff12a52"
|
||||
}
|
||||
|
|
@ -34,7 +34,62 @@ use prost::Message as _;
|
|||
use proto::message::Type;
|
||||
use server_to_client::v0::Kind;
|
||||
use sqlx::{Sqlite, Transaction};
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, LazyLock, Mutex, PoisonError, Weak};
|
||||
|
||||
/// Serialises the handling of one receipt ID against itself.
|
||||
///
|
||||
/// The server resends a mailbox page whose acknowledgement timed out, so a
|
||||
/// message can arrive again while the first copy is still being handled. Both
|
||||
/// copies would open their own transaction on the single app-database
|
||||
/// connection, and the second could only ever find the claim the first is
|
||||
/// about to write. Waiting here keeps the duplicate off that connection until
|
||||
/// the first copy has committed, after which it takes the ordinary
|
||||
/// already-claimed path.
|
||||
static IN_FLIGHT_RECEIPTS: LazyLock<Mutex<HashMap<String, Weak<tokio::sync::Mutex<()>>>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// The registry entry for one in-flight receipt. It keeps the only strong
|
||||
/// reference held by a handler that is not waiting, so dropping it once no
|
||||
/// other copy is queued takes the entry out of the registry again.
|
||||
struct InFlightReceipt {
|
||||
receipt_id: String,
|
||||
lock: Arc<tokio::sync::Mutex<()>>,
|
||||
}
|
||||
|
||||
impl InFlightReceipt {
|
||||
fn claim(receipt_id: &str) -> Self {
|
||||
let mut in_flight = IN_FLIGHT_RECEIPTS
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
let lock = in_flight
|
||||
.get(receipt_id)
|
||||
.and_then(Weak::upgrade)
|
||||
.unwrap_or_else(|| {
|
||||
let lock = Arc::new(tokio::sync::Mutex::new(()));
|
||||
in_flight.insert(receipt_id.to_owned(), Arc::downgrade(&lock));
|
||||
lock
|
||||
});
|
||||
Self {
|
||||
receipt_id: receipt_id.to_owned(),
|
||||
lock,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InFlightReceipt {
|
||||
fn drop(&mut self) {
|
||||
let mut in_flight = IN_FLIGHT_RECEIPTS
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
// Every other copy of this receipt holds a strong reference of its
|
||||
// own, and both taking one and removing the entry happen under this
|
||||
// lock, so being the last holder means nothing is queued behind us.
|
||||
if Arc::strong_count(&self.lock) == 1 {
|
||||
in_flight.remove(&self.receipt_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn handle_server_message(
|
||||
ctx: &Arc<Context>,
|
||||
|
|
@ -256,6 +311,13 @@ pub(crate) async fn handle_decoded_server_message(
|
|||
));
|
||||
}
|
||||
|
||||
// A redelivery of a message still being handled waits here instead of
|
||||
// racing the copy in flight for the single app-database connection. The
|
||||
// registry entry outlives the lock guard, so the receipt stays registered
|
||||
// for as long as anything is queued on it.
|
||||
let in_flight = InFlightReceipt::claim(&message.receipt_id);
|
||||
let _in_flight = in_flight.lock.lock().await;
|
||||
|
||||
let message_type = Type::try_from(message.r#type)?;
|
||||
let is_encrypted_message = matches!(
|
||||
message_type,
|
||||
|
|
@ -486,6 +548,10 @@ async fn handle_encrypted_inner(
|
|||
Contact::update_ask_for_friend_promotions(t, from_user_id).await?;
|
||||
}
|
||||
|
||||
if let Some(allowed) = content.widget_sharing_allowed {
|
||||
Contact::update_widget_sharing_allowed(t, from_user_id, allowed).await?;
|
||||
}
|
||||
|
||||
let type_kind = content_type_kind(&content);
|
||||
|
||||
tracing::Span::current().record("kind", type_kind);
|
||||
|
|
@ -653,3 +719,29 @@ async fn handle_encrypted_inner(
|
|||
"client2client content in receipt {receipt_id} is not implemented in Rust"
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn copies_of_one_receipt_share_a_lock_until_the_last_one_goes() {
|
||||
let receipt_id = "in-flight-registry-test";
|
||||
|
||||
let first = InFlightReceipt::claim(receipt_id);
|
||||
let second = InFlightReceipt::claim(receipt_id);
|
||||
assert!(Arc::ptr_eq(&first.lock, &second.lock));
|
||||
|
||||
// One copy leaving must not unregister a receipt another still holds.
|
||||
drop(second);
|
||||
let third = InFlightReceipt::claim(receipt_id);
|
||||
assert!(Arc::ptr_eq(&third.lock, &first.lock));
|
||||
|
||||
drop(third);
|
||||
drop(first);
|
||||
assert!(!IN_FLIGHT_RECEIPTS
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.contains_key(receipt_id));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,25 @@ pub(crate) async fn handle_media(
|
|||
media: encrypted_content::Media,
|
||||
) -> Result<()> {
|
||||
let media_type = MediaType::try_from(media.r#type)?;
|
||||
let widget_only = media.widget_only == Some(true);
|
||||
|
||||
if widget_only {
|
||||
let granted = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT widget_sharing_granted FROM contacts WHERE user_id = ?",
|
||||
)
|
||||
.bind(from_user_id)
|
||||
.fetch_optional(&mut **t)
|
||||
.await?
|
||||
.unwrap_or(0)
|
||||
!= 0;
|
||||
if !granted
|
||||
|| media_type != MediaType::Image
|
||||
|| media.display_limit_in_milliseconds.is_some()
|
||||
{
|
||||
tracing::warn!(from_user_id, "dropping unauthorized widget media");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
if media_type == MediaType::Reupload {
|
||||
let media_id = sqlx::query_scalar!(
|
||||
|
|
@ -140,7 +159,7 @@ pub(crate) async fn handle_media(
|
|||
r#"UPDATE media_files SET type = ?, download_state = 'pending',
|
||||
requires_authentication = ?, display_limit_in_milliseconds = ?,
|
||||
download_token = ?, encryption_key = ?, encryption_mac = ?,
|
||||
encryption_nonce = ?, created_at = ? WHERE media_id = ?"#,
|
||||
encryption_nonce = ?, is_widget_media = ?, created_at = ? WHERE media_id = ?"#,
|
||||
media_type,
|
||||
media.requires_authentication,
|
||||
media.display_limit_in_milliseconds,
|
||||
|
|
@ -148,6 +167,7 @@ pub(crate) async fn handle_media(
|
|||
media.encryption_key,
|
||||
media.encryption_mac,
|
||||
media.encryption_nonce,
|
||||
widget_only,
|
||||
timestamp,
|
||||
media_id,
|
||||
)
|
||||
|
|
@ -172,8 +192,9 @@ pub(crate) async fn handle_media(
|
|||
encryption_key,
|
||||
encryption_mac,
|
||||
encryption_nonce,
|
||||
is_widget_media,
|
||||
created_at
|
||||
) VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
media_id,
|
||||
media_type,
|
||||
|
|
@ -183,10 +204,16 @@ pub(crate) async fn handle_media(
|
|||
media.encryption_key,
|
||||
media.encryption_mac,
|
||||
media.encryption_nonce,
|
||||
widget_only,
|
||||
timestamp,
|
||||
)
|
||||
.execute(&mut **t)
|
||||
.await?;
|
||||
let (opened_at, opened_by_all) = if widget_only {
|
||||
(Some(timestamp), Some(timestamp))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO messages(
|
||||
|
|
@ -197,8 +224,11 @@ pub(crate) async fn handle_media(
|
|||
media_id,
|
||||
additional_message_data,
|
||||
quotes_message_id,
|
||||
is_widget_media,
|
||||
opened_at,
|
||||
opened_by_all,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, 'media', ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, 'media', ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
group_id,
|
||||
media.sender_message_id,
|
||||
|
|
@ -206,13 +236,18 @@ pub(crate) async fn handle_media(
|
|||
media_id,
|
||||
media.additional_message_data,
|
||||
media.quote_message_id,
|
||||
widget_only,
|
||||
opened_at,
|
||||
opened_by_all,
|
||||
timestamp,
|
||||
)
|
||||
.execute(&mut **t)
|
||||
.await?;
|
||||
|
||||
if !widget_only {
|
||||
Group::increase_last_message_exchange(t, group_id, timestamp).await?;
|
||||
Group::record_media_exchange(t, group_id, true, timestamp).await?;
|
||||
}
|
||||
|
||||
spawn_media_download(ctx, media_id);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,14 +15,83 @@ use crate::utils::new_uuid_v4;
|
|||
use prost::Message as ProstMessage;
|
||||
use proto::encrypted_content::error_messages::Type;
|
||||
use sqlx::{Sqlite, Transaction};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, LazyLock, Mutex, PoisonError};
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
use std::{collections::HashMap, sync::LazyLock};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
static ALREADY_QUEUED_RECEIPTS: LazyLock<std::sync::Mutex<HashMap<String, std::time::Instant>>> =
|
||||
LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
|
||||
static ALREADY_QUEUED_RECEIPTS: LazyLock<Mutex<HashMap<String, std::time::Instant>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Coalesces overlapping flushes of the receipt queue.
|
||||
///
|
||||
/// Every committed inbound message asks for a flush, and a flush scans the
|
||||
/// whole queue, so one mailbox page would otherwise start as many identical
|
||||
/// scans as it carries messages — all of them competing for the single
|
||||
/// app-database connection. A request that arrives while a flush runs only
|
||||
/// marks it dirty, so the running flush queries once more before it returns.
|
||||
static QUEUED_RECEIPT_FLUSH: LazyLock<Mutex<FlushClaim>> =
|
||||
LazyLock::new(|| Mutex::new(FlushClaim::default()));
|
||||
|
||||
#[derive(Default)]
|
||||
struct FlushClaim {
|
||||
running: bool,
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
/// Held for as long as a flush owns the claim. Dropping it without releasing
|
||||
/// it — an error or a panic on the way out — frees the claim for the next
|
||||
/// caller instead of blocking every later flush.
|
||||
struct FlushGuard {
|
||||
released: bool,
|
||||
}
|
||||
|
||||
impl FlushGuard {
|
||||
/// Registers a flush for this caller. Returns `None` when one is already
|
||||
/// running, in which case it is marked dirty and will query once more.
|
||||
fn claim() -> Option<Self> {
|
||||
let mut claim = QUEUED_RECEIPT_FLUSH
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
if claim.running {
|
||||
claim.dirty = true;
|
||||
return None;
|
||||
}
|
||||
claim.running = true;
|
||||
claim.dirty = false;
|
||||
Some(Self { released: false })
|
||||
}
|
||||
|
||||
/// Ends the flush when nothing asked for another one while it ran,
|
||||
/// otherwise clears the dirty flag and keeps the claim for one more pass.
|
||||
/// Both happen under one lock so a request cannot be dropped between the
|
||||
/// last query and the release.
|
||||
fn release_or_take_dirty(&mut self) -> bool {
|
||||
let mut claim = QUEUED_RECEIPT_FLUSH
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
if claim.dirty {
|
||||
claim.dirty = false;
|
||||
return true;
|
||||
}
|
||||
claim.running = false;
|
||||
self.released = true;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FlushGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.released {
|
||||
return;
|
||||
}
|
||||
QUEUED_RECEIPT_FLUSH
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn queue_encrypted_content(
|
||||
t: &mut Transaction<'_, Sqlite>,
|
||||
|
|
@ -574,6 +643,20 @@ pub(crate) async fn release_deferred_receipts(
|
|||
}
|
||||
|
||||
pub async fn retransmit_queued_receipts(ctx: &Arc<Context>) -> Result<()> {
|
||||
let Some(mut guard) = FlushGuard::claim() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
loop {
|
||||
flush_queued_receipts(ctx).await?;
|
||||
|
||||
if !guard.release_or_take_dirty() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn flush_queued_receipts(ctx: &Arc<Context>) -> Result<()> {
|
||||
let database = ctx.app_db.read().await.clone();
|
||||
let receipt_ids = sqlx::query_scalar!(
|
||||
r#"
|
||||
|
|
@ -699,3 +782,25 @@ pub async fn handle_plaintext_content(
|
|||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_second_flush_request_marks_the_running_one_dirty() {
|
||||
let mut guard = FlushGuard::claim().expect("nothing else holds the claim");
|
||||
assert!(FlushGuard::claim().is_none());
|
||||
|
||||
// The running flush sees the dirty flag and keeps its claim.
|
||||
assert!(guard.release_or_take_dirty());
|
||||
// Nothing arrived during the second pass, so the claim is released.
|
||||
assert!(!guard.release_or_take_dirty());
|
||||
|
||||
// Dropping a guard that never released frees the claim too, so an
|
||||
// error on the way out cannot block every later flush.
|
||||
let guard = FlushGuard::claim().expect("the claim was released");
|
||||
drop(guard);
|
||||
assert!(FlushGuard::claim().is_some());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,18 @@ pub(crate) async fn decorate_content(
|
|||
};
|
||||
|
||||
content.sender_profile_counter = Some(config.avatar_counter);
|
||||
if config.ask_for_friend_promotions {
|
||||
let database = ctx.app_db.read().await.clone();
|
||||
content.widget_sharing_allowed = Some(
|
||||
sqlx::query_scalar!(
|
||||
"SELECT widget_sharing_granted FROM contacts WHERE user_id = ?",
|
||||
contact_id,
|
||||
)
|
||||
.fetch_optional(&database.pool)
|
||||
.await?
|
||||
.unwrap_or(0)
|
||||
!= 0,
|
||||
);
|
||||
if config.ask_for_friend_promotions {
|
||||
let accepted = sqlx::query_scalar!("SELECT COUNT(*) FROM contacts WHERE accepted = 1")
|
||||
.fetch_one(&database.pool)
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ message EncryptedContent {
|
|||
optional bytes sender_user_discovery_version = 21;
|
||||
optional bool ask_for_friend_promotions = 25;
|
||||
reserved 28;
|
||||
optional bool widget_sharing_allowed = 29;
|
||||
|
||||
optional MessageUpdate message_update = 5;
|
||||
optional Media media = 6;
|
||||
|
|
@ -119,6 +120,7 @@ message EncryptedContent {
|
|||
optional bytes encryption_nonce = 10;
|
||||
|
||||
optional bytes additional_message_data = 11;
|
||||
optional bool widget_only = 12;
|
||||
}
|
||||
|
||||
message MediaUpdate {
|
||||
|
|
|
|||
|
|
@ -78,12 +78,14 @@ pub(crate) fn schedule_post_authentication(ctx: &Arc<Context>, in_background: bo
|
|||
if let Err(error) = messages::retransmit_queued_receipts(&ctx).await {
|
||||
tracing::warn!("failed to retransmit queued receipts: {error}");
|
||||
}
|
||||
|
||||
// A preparation that a terminated process left half-finished is only
|
||||
// resumed by a sweep like this one; a mid-session reconnect is just as
|
||||
// good a moment for it as a cold start, and far more frequent.
|
||||
if let Err(error) = MediaUploadService::new(&ctx).finish_started_uploads().await {
|
||||
tracing::warn!("failed to finish started media uploads: {error}");
|
||||
}
|
||||
|
||||
if let Err(error) = MediaFileService::new(&ctx).download_pending().await {
|
||||
tracing::warn!("failed to download pending media: {error}");
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue