android: home screen widget in three sizes as in the Final 2026 mockup
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
db6c806d58
commit
53623ebb4a
19 files changed
+436
-68
No files matched your search
@@ -52,6 +52,11 @@ public class MainActivity extends AppCompatActivity
|
|||||||
*/
|
*/
|
||||||
public static final String EXTRA_OPEN_SERVERS = "org.strongswan.android.extra.OPEN_SERVERS";
|
public static final String EXTRA_OPEN_SERVERS = "org.strongswan.android.extra.OPEN_SERVERS";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set as an intent extra to connect the fastest server (the «Fastest» button of the widget)
|
||||||
|
*/
|
||||||
|
public static final String EXTRA_CONNECT_FASTEST = "org.strongswan.android.extra.CONNECT_FASTEST";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use "bring your own device" (BYOD) features
|
* Use "bring your own device" (BYOD) features
|
||||||
*/
|
*/
|
||||||
@@ -121,5 +126,10 @@ public class MainActivity extends AppCompatActivity
|
|||||||
intent.removeExtra(EXTRA_OPEN_SERVERS);
|
intent.removeExtra(EXTRA_OPEN_SERVERS);
|
||||||
ShxNavigation.INSTANCE.request(ShxTab.PROFILES);
|
ShxNavigation.INSTANCE.request(ShxTab.PROFILES);
|
||||||
}
|
}
|
||||||
|
if (intent != null && intent.getBooleanExtra(EXTRA_CONNECT_FASTEST, false))
|
||||||
|
{
|
||||||
|
intent.removeExtra(EXTRA_CONNECT_FASTEST);
|
||||||
|
ShxNavigation.INSTANCE.requestFastest();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,6 +121,16 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
|
|||||||
ShxNavigation.consumeAddServer()
|
ShxNavigation.consumeAddServer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val fastestRequested by ShxNavigation.fastest.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(fastestRequested, state.profiles.isNotEmpty())
|
||||||
|
{
|
||||||
|
if (fastestRequested && state.profiles.isNotEmpty())
|
||||||
|
{
|
||||||
|
viewModel.setTab(ShxTab.HOME)
|
||||||
|
viewModel.connectFastest(context)
|
||||||
|
ShxNavigation.consumeFastest()
|
||||||
|
}
|
||||||
|
}
|
||||||
val editProfile: (VpnProfile) -> Unit = { profile ->
|
val editProfile: (VpnProfile) -> Unit = { profile ->
|
||||||
val intent = Intent(context, VpnProfileDetailActivity::class.java)
|
val intent = Intent(context, VpnProfileDetailActivity::class.java)
|
||||||
intent.putExtra(VpnProfileDataSource.KEY_UUID, profile.getUUID().toString())
|
intent.putExtra(VpnProfileDataSource.KEY_UUID, profile.getUUID().toString())
|
||||||
|
|||||||
+14
@@ -49,4 +49,18 @@ object ShxNavigation
|
|||||||
{
|
{
|
||||||
_addServer.value = false
|
_addServer.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* «connect the fastest server» request (widget) */
|
||||||
|
private val _fastest = MutableStateFlow(false)
|
||||||
|
val fastest: StateFlow<Boolean> = _fastest
|
||||||
|
|
||||||
|
fun requestFastest()
|
||||||
|
{
|
||||||
|
_fastest.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeFastest()
|
||||||
|
{
|
||||||
|
_fastest.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+305
-66
@@ -11,31 +11,51 @@ package org.strongswan.android.ui.widget
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.widget.RemoteViews
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.glance.Button
|
import androidx.glance.ColorFilter
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
import androidx.glance.GlanceModifier
|
import androidx.glance.GlanceModifier
|
||||||
import androidx.glance.GlanceTheme
|
import androidx.glance.Image
|
||||||
import androidx.glance.appwidget.action.actionStartActivity
|
import androidx.glance.ImageProvider
|
||||||
|
import androidx.glance.LocalSize
|
||||||
|
import androidx.glance.action.Action
|
||||||
|
import androidx.glance.action.clickable
|
||||||
|
import androidx.glance.appwidget.AndroidRemoteViews
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
import androidx.glance.appwidget.SizeMode
|
import androidx.glance.appwidget.SizeMode
|
||||||
|
import androidx.glance.appwidget.action.actionSendBroadcast
|
||||||
|
import androidx.glance.appwidget.action.actionStartActivity
|
||||||
import androidx.glance.appwidget.appWidgetBackground
|
import androidx.glance.appwidget.appWidgetBackground
|
||||||
import androidx.glance.appwidget.cornerRadius
|
import androidx.glance.appwidget.cornerRadius
|
||||||
import androidx.glance.appwidget.provideContent
|
import androidx.glance.appwidget.provideContent
|
||||||
import androidx.glance.appwidget.updateAll
|
import androidx.glance.appwidget.updateAll
|
||||||
import androidx.glance.background
|
import androidx.glance.background
|
||||||
|
import androidx.glance.color.ColorProvider
|
||||||
import androidx.glance.layout.Alignment
|
import androidx.glance.layout.Alignment
|
||||||
|
import androidx.glance.layout.Box
|
||||||
import androidx.glance.layout.Column
|
import androidx.glance.layout.Column
|
||||||
import androidx.glance.layout.Row
|
import androidx.glance.layout.Row
|
||||||
|
import androidx.glance.layout.Spacer
|
||||||
import androidx.glance.layout.fillMaxSize
|
import androidx.glance.layout.fillMaxSize
|
||||||
|
import androidx.glance.layout.fillMaxWidth
|
||||||
|
import androidx.glance.layout.height
|
||||||
import androidx.glance.layout.padding
|
import androidx.glance.layout.padding
|
||||||
|
import androidx.glance.layout.size
|
||||||
|
import androidx.glance.layout.width
|
||||||
import androidx.glance.text.FontWeight
|
import androidx.glance.text.FontWeight
|
||||||
import androidx.glance.text.Text
|
import androidx.glance.text.Text
|
||||||
import androidx.glance.text.TextStyle
|
import androidx.glance.text.TextStyle
|
||||||
|
import androidx.glance.unit.ColorProvider
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -44,16 +64,22 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
import org.strongswan.android.data.VpnProfileSource
|
import org.strongswan.android.data.VpnProfileSource
|
||||||
|
import org.strongswan.android.logic.PauseManager
|
||||||
|
import org.strongswan.android.logic.PauseReceiver
|
||||||
import org.strongswan.android.logic.VpnStateService
|
import org.strongswan.android.logic.VpnStateService
|
||||||
|
import org.strongswan.android.ui.MainActivity
|
||||||
import org.strongswan.android.ui.VpnProfileControlActivity
|
import org.strongswan.android.ui.VpnProfileControlActivity
|
||||||
import org.strongswan.android.utils.DefaultVpnProfile
|
import org.strongswan.android.utils.DefaultVpnProfile
|
||||||
|
|
||||||
/** What the widget shows; persisted by [ShxWidgetState] so it survives process death. */
|
/** What the widget shows; persisted by [ShxWidgetState] so it survives process death. */
|
||||||
internal data class WidgetSnapshot(
|
internal data class WidgetSnapshot(
|
||||||
val state: String,
|
val state: String,
|
||||||
val error: Boolean,
|
/* ErrorState name, null without an error */
|
||||||
|
val error: String?,
|
||||||
val profileName: String?,
|
val profileName: String?,
|
||||||
val profileUuid: String?,
|
val profileUuid: String?,
|
||||||
|
/* wall-clock start of the running connection, 0 if none */
|
||||||
|
val connectedSince: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,9 +89,10 @@ internal data class WidgetSnapshot(
|
|||||||
object ShxWidgetState
|
object ShxWidgetState
|
||||||
{
|
{
|
||||||
private const val KEY_STATE = "widget_state"
|
private const val KEY_STATE = "widget_state"
|
||||||
private const val KEY_ERROR = "widget_error"
|
private const val KEY_ERROR = "widget_error_state"
|
||||||
private const val KEY_NAME = "widget_profile_name"
|
private const val KEY_NAME = "widget_profile_name"
|
||||||
private const val KEY_UUID = "widget_profile_uuid"
|
private const val KEY_UUID = "widget_profile_uuid"
|
||||||
|
private const val KEY_SINCE = "widget_connected_since"
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
@@ -79,9 +106,13 @@ object ShxWidgetState
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
val app = context.applicationContext
|
val app = context.applicationContext
|
||||||
val editor = PreferenceManager.getDefaultSharedPreferences(app).edit()
|
val prefs = PreferenceManager.getDefaultSharedPreferences(app)
|
||||||
|
val connected = state == VpnStateService.State.CONNECTED && error == VpnStateService.ErrorState.NO_ERROR
|
||||||
|
val since = prefs.getLong(KEY_SINCE, 0L)
|
||||||
|
val editor = prefs.edit()
|
||||||
.putString(KEY_STATE, state.name)
|
.putString(KEY_STATE, state.name)
|
||||||
.putBoolean(KEY_ERROR, error != VpnStateService.ErrorState.NO_ERROR)
|
.putString(KEY_ERROR, if (error == VpnStateService.ErrorState.NO_ERROR) null else error.name)
|
||||||
|
.putLong(KEY_SINCE, if (!connected) 0L else if (since == 0L) System.currentTimeMillis() else since)
|
||||||
if (profileUuid != null)
|
if (profileUuid != null)
|
||||||
{
|
{
|
||||||
editor.putString(KEY_NAME, profileName).putString(KEY_UUID, profileUuid)
|
editor.putString(KEY_NAME, profileName).putString(KEY_UUID, profileUuid)
|
||||||
@@ -123,87 +154,295 @@ object ShxWidgetState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val state = prefs.getString(KEY_STATE, null) ?: VpnStateService.State.DISABLED.name
|
val state = prefs.getString(KEY_STATE, null) ?: VpnStateService.State.DISABLED.name
|
||||||
WidgetSnapshot(state, prefs.getBoolean(KEY_ERROR, false), name, uuid)
|
WidgetSnapshot(state, prefs.getString(KEY_ERROR, null), name, uuid, prefs.getLong(KEY_SINCE, 0L))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 2x1 widget: protection status, current profile and a connect / disconnect button. */
|
/* the sizes of F_Widgets: 1×1 status icon, 2×1 status and button, 4×2 detailed */
|
||||||
|
private val ICON = DpSize(56.dp, 56.dp)
|
||||||
|
private val SMALL = DpSize(150.dp, 64.dp)
|
||||||
|
private val LARGE = DpSize(250.dp, 150.dp)
|
||||||
|
|
||||||
|
/** Home screen widget as on F_Widgets, in three sizes. */
|
||||||
class ShxWidget : GlanceAppWidget()
|
class ShxWidget : GlanceAppWidget()
|
||||||
{
|
{
|
||||||
override val sizeMode = SizeMode.Single
|
override val sizeMode = SizeMode.Responsive(setOf(ICON, SMALL, LARGE))
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId)
|
override suspend fun provideGlance(context: Context, id: GlanceId)
|
||||||
{
|
{
|
||||||
val snapshot = ShxWidgetState.snapshot(context)
|
val snapshot = ShxWidgetState.snapshot(context)
|
||||||
provideContent {
|
provideContent {
|
||||||
GlanceTheme {
|
WidgetContent(context, snapshot)
|
||||||
WidgetContent(context, snapshot)
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the mockup palette, light and dark */
|
||||||
|
private fun cp(day: Long, night: Long): ColorProvider = ColorProvider(Color(day), Color(night))
|
||||||
|
private val BG = cp(0xFFFCF8FF, 0xFF1F1D24)
|
||||||
|
private val TEXT = cp(0xFF1C1B20, 0xFFE5E1E9)
|
||||||
|
private val SUB = cp(0xFF5E5A7D, 0xFFA9A3BF)
|
||||||
|
private val ACCENT = cp(0xFF6D4AFF, 0xFF6D4AFF)
|
||||||
|
private val WHITE = cp(0xFFFFFFFF, 0xFFFFFFFF)
|
||||||
|
private val TILE = cp(0xFFF0ECF4, 0xFF2B2930)
|
||||||
|
private val ON_TILE = cp(0xFF2A2640, 0xFFE0DAF5)
|
||||||
|
private val TILE_ICON = cp(0xFF49454F, 0xFFCAC4D0)
|
||||||
|
private val ACCENT_CONTAINER = cp(0xFFE7DFFF, 0xFF3B2C88)
|
||||||
|
private val ON_ACCENT_CONTAINER = cp(0xFF6D4AFF, 0xFFB69DFF)
|
||||||
|
private val WARN_BG = cp(0xFFFFF4E0, 0xFF3A2A12)
|
||||||
|
private val WARN_TITLE = cp(0xFF6B3A10, 0xFFFFB869)
|
||||||
|
private val WARN_SUB = cp(0xFF7A3A06, 0xFFFFB869)
|
||||||
|
private val WARN_ICON_BG = cp(0xFFFFE2B8, 0xFF4A3416)
|
||||||
|
private val DOT_ON = cp(0xFF148A45, 0xFF7CE3B0)
|
||||||
|
private val DOT_OFF = cp(0xFFA39EAD, 0xFF79767F)
|
||||||
|
private val GREY = cp(0xFF79767F, 0xFF79767F)
|
||||||
|
private val WARN_STRONG = cp(0xFFB45309, 0xFFB45309)
|
||||||
|
|
||||||
|
private enum class Look { ON, OFF, CONNECTING, PROBLEM }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WidgetContent(context: Context, s: WidgetSnapshot)
|
||||||
|
{
|
||||||
|
val look = when
|
||||||
|
{
|
||||||
|
s.error != null -> Look.PROBLEM
|
||||||
|
s.state == VpnStateService.State.CONNECTED.name -> Look.ON
|
||||||
|
s.state == VpnStateService.State.CONNECTING.name || s.state == VpnStateService.State.DISCONNECTING.name -> Look.CONNECTING
|
||||||
|
else -> Look.OFF
|
||||||
|
}
|
||||||
|
/* the round button: on / off, the same entry point as the quick settings tile */
|
||||||
|
val toggle = Intent(context, VpnProfileControlActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
if (look == Look.ON || look == Look.CONNECTING)
|
||||||
|
{
|
||||||
|
toggle.action = VpnProfileControlActivity.DISCONNECT
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
toggle.action = VpnProfileControlActivity.START_PROFILE
|
||||||
|
toggle.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, s.profileUuid)
|
||||||
|
}
|
||||||
|
/* the text: open the app */
|
||||||
|
val open = Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
|
val size = LocalSize.current
|
||||||
|
when
|
||||||
|
{
|
||||||
|
size.width < SMALL.width -> IconWidget(look, actionStartActivity(open))
|
||||||
|
size.width >= LARGE.width && size.height >= LARGE.height -> LargeWidget(context, s, look, actionStartActivity(toggle), actionStartActivity(open))
|
||||||
|
else -> SmallWidget(context, s, look, actionStartActivity(toggle), actionStartActivity(open))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun title(context: Context, look: Look, error: String?): String = when (look)
|
||||||
|
{
|
||||||
|
Look.ON -> context.getString(R.string.status_protected)
|
||||||
|
Look.OFF -> context.getString(R.string.widget_off)
|
||||||
|
Look.CONNECTING -> context.getString(R.string.widget_connecting)
|
||||||
|
Look.PROBLEM -> context.getString(
|
||||||
|
when (error)
|
||||||
|
{
|
||||||
|
VpnStateService.ErrorState.UNREACHABLE.name, VpnStateService.ErrorState.LOOKUP_FAILED.name -> R.string.widget_no_answer
|
||||||
|
VpnStateService.ErrorState.AUTH_FAILED.name, VpnStateService.ErrorState.PEER_AUTH_FAILED.name,
|
||||||
|
VpnStateService.ErrorState.PASSWORD_MISSING.name -> R.string.widget_auth_error
|
||||||
|
VpnStateService.ErrorState.CERTIFICATE_UNAVAILABLE.name -> R.string.widget_cert_error
|
||||||
|
else -> R.string.widget_problem
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the 2-letter server code of the 2×1 widget («NL · 01:24») */
|
||||||
|
private fun code(name: String?): String = name.orEmpty().filter { it.isLetterOrDigit() }.take(2).uppercase().ifEmpty { "VPN" }
|
||||||
|
|
||||||
|
/* the connected time as a live Chronometer (Glance itself cannot tick) */
|
||||||
|
private fun chrono(context: Context, since: Long, layout: Int): RemoteViews
|
||||||
|
{
|
||||||
|
val views = RemoteViews(context.packageName, layout)
|
||||||
|
val base = SystemClock.elapsedRealtime() - (System.currentTimeMillis() - since).coerceAtLeast(0L)
|
||||||
|
views.setChronometer(R.id.widget_chrono, base, null, true)
|
||||||
|
return views
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ButtonLook(val bg: ColorProvider, val fg: ColorProvider, val radius: Dp, @DrawableRes val icon: Int)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToggleButton(look: Look, onClick: Action)
|
||||||
|
{
|
||||||
|
val b = when (look)
|
||||||
|
{
|
||||||
|
Look.ON -> ButtonLook(ACCENT, WHITE, 22.dp, R.drawable.ic_widget_power)
|
||||||
|
Look.OFF -> ButtonLook(TILE, TILE_ICON, 22.dp, R.drawable.ic_widget_power)
|
||||||
|
Look.CONNECTING -> ButtonLook(ACCENT_CONTAINER, ON_ACCENT_CONTAINER, 22.dp, R.drawable.ic_widget_power)
|
||||||
|
Look.PROBLEM -> ButtonLook(WARN_ICON_BG, WARN_SUB, 16.dp, R.drawable.ic_widget_alert)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = GlanceModifier.size(44.dp).cornerRadius(b.radius).background(b.bg).clickable(onClick),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Image(ImageProvider(b.icon), contentDescription = null, colorFilter = ColorFilter.tint(b.fg), modifier = GlanceModifier.size(22.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Card(look: Look, content: @Composable () -> Unit)
|
||||||
|
{
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.fillMaxSize().appWidgetBackground().cornerRadius(28.dp)
|
||||||
|
.background(if (look == Look.PROBLEM) WARN_BG else BG).padding(14.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Title(text: String, problem: Boolean)
|
||||||
|
{
|
||||||
|
Text(text, maxLines = 1, style = TextStyle(fontSize = 15.sp, fontWeight = FontWeight.Bold, color = if (problem) WARN_TITLE else TEXT))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Sub(text: String, problem: Boolean)
|
||||||
|
{
|
||||||
|
Text(text, maxLines = 1, style = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Medium, color = if (problem) WARN_SUB else SUB))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 2×1: title, subtitle and the round on / off button. */
|
||||||
|
@Composable
|
||||||
|
private fun SmallWidget(context: Context, s: WidgetSnapshot, look: Look, toggle: Action, open: Action)
|
||||||
|
{
|
||||||
|
val problem = look == Look.PROBLEM
|
||||||
|
Card(look)
|
||||||
|
{
|
||||||
|
Row(GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically)
|
||||||
|
{
|
||||||
|
Column(GlanceModifier.defaultWeight().clickable(open))
|
||||||
|
{
|
||||||
|
Title(title(context, look, s.error), problem)
|
||||||
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
|
when
|
||||||
|
{
|
||||||
|
look == Look.ON && s.connectedSince > 0 -> Row(verticalAlignment = Alignment.CenterVertically)
|
||||||
|
{
|
||||||
|
Sub("${code(s.profileName)} · ", false)
|
||||||
|
AndroidRemoteViews(chrono(context, s.connectedSince, R.layout.shx_widget_chrono_sub))
|
||||||
|
}
|
||||||
|
look == Look.OFF && s.profileName != null -> Sub(context.getString(R.string.widget_tap_to, s.profileName), false)
|
||||||
|
else -> Sub(s.profileName ?: context.getString(R.string.status_choose_profile), problem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(GlanceModifier.width(8.dp))
|
||||||
|
ToggleButton(look, toggle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 4×2: logo, title with the live time, server and button; Pause / Fastest / Change below. */
|
||||||
|
@Composable
|
||||||
|
private fun LargeWidget(context: Context, s: WidgetSnapshot, look: Look, toggle: Action, open: Action)
|
||||||
|
{
|
||||||
|
val problem = look == Look.PROBLEM
|
||||||
|
Card(look)
|
||||||
|
{
|
||||||
|
Column(GlanceModifier.fillMaxSize())
|
||||||
|
{
|
||||||
|
Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically)
|
||||||
|
{
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = GlanceModifier.size(32.dp).cornerRadius(10.dp).background(ACCENT).clickable(open),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Image(ImageProvider(R.drawable.ic_widget_logo_accent), contentDescription = null, modifier = GlanceModifier.size(22.dp))
|
||||||
|
}
|
||||||
|
Spacer(GlanceModifier.width(10.dp))
|
||||||
|
Column(GlanceModifier.defaultWeight().clickable(open))
|
||||||
|
{
|
||||||
|
val t = title(context, look, s.error)
|
||||||
|
if (look == Look.ON && s.connectedSince > 0)
|
||||||
|
{
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically)
|
||||||
|
{
|
||||||
|
Title("$t · ", false)
|
||||||
|
AndroidRemoteViews(chrono(context, s.connectedSince, R.layout.shx_widget_chrono))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Title(t, problem)
|
||||||
|
}
|
||||||
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
|
Sub(s.profileName ?: context.getString(R.string.status_choose_profile), problem)
|
||||||
|
}
|
||||||
|
Spacer(GlanceModifier.width(8.dp))
|
||||||
|
ToggleButton(look, toggle)
|
||||||
|
}
|
||||||
|
Spacer(GlanceModifier.defaultWeight())
|
||||||
|
Row(GlanceModifier.fillMaxWidth())
|
||||||
|
{
|
||||||
|
val pause = Intent(context, PauseReceiver::class.java).setAction(PauseManager.ACTION_PAUSE)
|
||||||
|
val fastest = Intent(context, MainActivity::class.java)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
|
.putExtra(MainActivity.EXTRA_CONNECT_FASTEST, true)
|
||||||
|
val change = Intent(context, MainActivity::class.java)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
|
.putExtra(MainActivity.EXTRA_OPEN_SERVERS, true)
|
||||||
|
/* pausing only makes sense while connected; otherwise the app opens */
|
||||||
|
Chip(
|
||||||
|
R.drawable.ic_widget_pause, context.getString(R.string.widget_pause),
|
||||||
|
if (look == Look.ON) actionSendBroadcast(pause) else open, GlanceModifier.defaultWeight(),
|
||||||
|
)
|
||||||
|
Spacer(GlanceModifier.width(8.dp))
|
||||||
|
Chip(R.drawable.ic_widget_bolt, context.getString(R.string.home_action_fastest), actionStartActivity(fastest), GlanceModifier.defaultWeight())
|
||||||
|
Spacer(GlanceModifier.width(8.dp))
|
||||||
|
Chip(R.drawable.ic_widget_swap, context.getString(R.string.notif_change), actionStartActivity(change), GlanceModifier.defaultWeight())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WidgetContent(context: Context, snapshot: WidgetSnapshot)
|
private fun Chip(@DrawableRes icon: Int, label: String, onClick: Action, modifier: GlanceModifier)
|
||||||
{
|
{
|
||||||
val connected = snapshot.state == VpnStateService.State.CONNECTED.name && !snapshot.error
|
|
||||||
val connecting = snapshot.state == VpnStateService.State.CONNECTING.name && !snapshot.error
|
|
||||||
/* not "Unprotected" with a Connect button while the tunnel is going down */
|
|
||||||
val disconnecting = snapshot.state == VpnStateService.State.DISCONNECTING.name && !snapshot.error
|
|
||||||
val status = context.getString(
|
|
||||||
when
|
|
||||||
{
|
|
||||||
connected -> R.string.status_protected
|
|
||||||
connecting -> R.string.status_connecting
|
|
||||||
disconnecting -> R.string.state_disconnecting
|
|
||||||
else -> R.string.status_unprotected
|
|
||||||
}
|
|
||||||
)
|
|
||||||
val buttonLabel = context.getString(if (connected || connecting || disconnecting) R.string.disconnect else R.string.connect)
|
|
||||||
/* same entry point as the quick settings tile */
|
|
||||||
val intent = Intent(context, VpnProfileControlActivity::class.java)
|
|
||||||
if (connected || connecting || disconnecting)
|
|
||||||
{
|
|
||||||
intent.action = VpnProfileControlActivity.DISCONNECT
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
intent.action = VpnProfileControlActivity.START_PROFILE
|
|
||||||
intent.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, snapshot.profileUuid)
|
|
||||||
}
|
|
||||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = GlanceModifier
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
.fillMaxSize()
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
.appWidgetBackground()
|
modifier = modifier.height(44.dp).cornerRadius(14.dp).background(TILE).clickable(onClick).padding(horizontal = 4.dp),
|
||||||
.background(GlanceTheme.colors.widgetBackground)
|
|
||||||
.cornerRadius(24.dp)
|
|
||||||
.padding(12.dp),
|
|
||||||
verticalAlignment = Alignment.Vertical.CenterVertically,
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
Column(modifier = GlanceModifier.defaultWeight())
|
Image(ImageProvider(icon), contentDescription = null, colorFilter = ColorFilter.tint(ON_TILE), modifier = GlanceModifier.size(16.dp))
|
||||||
|
Spacer(GlanceModifier.width(6.dp))
|
||||||
|
Text(label, maxLines = 1, style = TextStyle(fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ON_TILE))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1×1: the round logo with the status dot. */
|
||||||
|
@Composable
|
||||||
|
private fun IconWidget(look: Look, open: Action)
|
||||||
|
{
|
||||||
|
val (bg, logo, dot) = when (look)
|
||||||
|
{
|
||||||
|
Look.ON -> Triple(ACCENT, R.drawable.ic_widget_logo_accent, DOT_ON)
|
||||||
|
Look.CONNECTING -> Triple(ACCENT, R.drawable.ic_widget_logo_accent, DOT_OFF)
|
||||||
|
Look.OFF -> Triple(GREY, R.drawable.ic_widget_logo_grey, DOT_OFF)
|
||||||
|
Look.PROBLEM -> Triple(WARN_STRONG, R.drawable.ic_widget_logo_warning, WARN_STRONG)
|
||||||
|
}
|
||||||
|
Box(GlanceModifier.fillMaxSize().appWidgetBackground().clickable(open), contentAlignment = Alignment.Center)
|
||||||
|
{
|
||||||
|
Box(GlanceModifier.size(64.dp))
|
||||||
{
|
{
|
||||||
Text(
|
Box(GlanceModifier.size(64.dp).cornerRadius(32.dp).background(bg), contentAlignment = Alignment.Center)
|
||||||
text = status,
|
{
|
||||||
style = TextStyle(
|
Image(ImageProvider(logo), contentDescription = null, modifier = GlanceModifier.size(34.dp))
|
||||||
fontWeight = FontWeight.Bold,
|
}
|
||||||
fontSize = 16.sp,
|
Box(GlanceModifier.size(64.dp).padding(2.dp), contentAlignment = Alignment.BottomEnd)
|
||||||
color = GlanceTheme.colors.onSurface,
|
{
|
||||||
),
|
/* the 3dp ring in the widget background around the dot */
|
||||||
maxLines = 1,
|
Box(GlanceModifier.size(16.dp).cornerRadius(8.dp).background(BG).padding(3.dp))
|
||||||
)
|
{
|
||||||
Text(
|
Box(GlanceModifier.size(10.dp).cornerRadius(5.dp).background(dot)) {}
|
||||||
text = snapshot.profileName ?: context.getString(R.string.status_choose_profile),
|
}
|
||||||
style = TextStyle(fontSize = 12.sp, color = GlanceTheme.colors.onSurfaceVariant),
|
}
|
||||||
maxLines = 1,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Button(
|
|
||||||
text = buttonLabel,
|
|
||||||
onClick = actionStartActivity(intent),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M12 8v5M12 16.5v.5M10.3 3.9L2.4 18a2 2 0 0 0 1.7 3h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M13 2L4 14h7l-1 8 9-12h-7z" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M12 2.6l7.6 2.8v6c0 5-3.3 8.4-7.6 10-4.3-1.6-7.6-5-7.6-10v-6z" android:fillColor="#FFFFFFFF"/>
|
||||||
|
<path android:pathData="M14.6 8.8c-.6-.95-1.6-1.45-2.7-1.45-1.5 0-2.6.85-2.6 2.05 0 2.8 5.5 1.6 5.5 4.5 0 1.3-1.2 2.25-2.8 2.25-1.2 0-2.2-.55-2.8-1.5" android:strokeColor="#FF6D4AFF" android:strokeWidth="2.4" android:strokeLineCap="round" android:fillColor="#00000000"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M12 2.6l7.6 2.8v6c0 5-3.3 8.4-7.6 10-4.3-1.6-7.6-5-7.6-10v-6z" android:fillColor="#FFFFFFFF"/>
|
||||||
|
<path android:pathData="M14.6 8.8c-.6-.95-1.6-1.45-2.7-1.45-1.5 0-2.6.85-2.6 2.05 0 2.8 5.5 1.6 5.5 4.5 0 1.3-1.2 2.25-2.8 2.25-1.2 0-2.2-.55-2.8-1.5" android:strokeColor="#FF79767F" android:strokeWidth="2.4" android:strokeLineCap="round" android:fillColor="#00000000"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M12 2.6l7.6 2.8v6c0 5-3.3 8.4-7.6 10-4.3-1.6-7.6-5-7.6-10v-6z" android:fillColor="#FFFFFFFF"/>
|
||||||
|
<path android:pathData="M14.6 8.8c-.6-.95-1.6-1.45-2.7-1.45-1.5 0-2.6.85-2.6 2.05 0 2.8 5.5 1.6 5.5 4.5 0 1.3-1.2 2.25-2.8 2.25-1.2 0-2.2-.55-2.8-1.5" android:strokeColor="#FFB45309" android:strokeWidth="2.4" android:strokeLineCap="round" android:fillColor="#00000000"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M9 5v14M15 5v14" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M12 3v9M6.3 6.3a8 8 0 1 0 11.4 0" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M7 7h13l-3-3M17 17H4l3 3" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- the live connected time of the home screen widget («Защищено · 01:24») -->
|
||||||
|
<Chronometer xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/widget_chrono"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textColor="@color/widget_text"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- the connected time on the subtitle of the 2×1 widget («NL · 01:24») -->
|
||||||
|
<Chronometer xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/widget_chrono"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textColor="@color/widget_text_sub"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
@@ -13,4 +13,6 @@
|
|||||||
<color name="error_text">#F87171</color>
|
<color name="error_text">#F87171</color>
|
||||||
<color name="warning_text">#FBBF24</color>
|
<color name="warning_text">#FBBF24</color>
|
||||||
<color name="success_text">#28A671</color>
|
<color name="success_text">#28A671</color>
|
||||||
|
<color name="widget_text">#FFE5E1E9</color>
|
||||||
|
<color name="widget_text_sub">#FFA9A3BF</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1013,4 +1013,12 @@
|
|||||||
<string name="notif_pause">Пауза 15 м</string>
|
<string name="notif_pause">Пауза 15 м</string>
|
||||||
<string name="notif_change">Сменить</string>
|
<string name="notif_change">Сменить</string>
|
||||||
<string name="notif_ru_direct">RU-сайты идут напрямую</string>
|
<string name="notif_ru_direct">RU-сайты идут напрямую</string>
|
||||||
|
<string name="widget_off">Выключен</string>
|
||||||
|
<string name="widget_connecting">Подключаемся</string>
|
||||||
|
<string name="widget_no_answer">Нет ответа</string>
|
||||||
|
<string name="widget_auth_error">Ошибка входа</string>
|
||||||
|
<string name="widget_cert_error">Сертификат</string>
|
||||||
|
<string name="widget_problem">Проблема</string>
|
||||||
|
<string name="widget_tap_to">тап — %1$s</string>
|
||||||
|
<string name="widget_pause">Пауза</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -15,4 +15,6 @@
|
|||||||
<color name="error_text">#DC2626</color>
|
<color name="error_text">#DC2626</color>
|
||||||
<color name="warning_text">#B45309</color>
|
<color name="warning_text">#B45309</color>
|
||||||
<color name="success_text">#148A45</color>
|
<color name="success_text">#148A45</color>
|
||||||
|
<color name="widget_text">#FF1C1B20</color>
|
||||||
|
<color name="widget_text_sub">#FF5E5A7D</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -998,4 +998,12 @@
|
|||||||
<string name="notif_pause">Pause 15 min</string>
|
<string name="notif_pause">Pause 15 min</string>
|
||||||
<string name="notif_change">Change</string>
|
<string name="notif_change">Change</string>
|
||||||
<string name="notif_ru_direct">RU sites go direct</string>
|
<string name="notif_ru_direct">RU sites go direct</string>
|
||||||
|
<string name="widget_off">Off</string>
|
||||||
|
<string name="widget_connecting">Connecting</string>
|
||||||
|
<string name="widget_no_answer">No answer</string>
|
||||||
|
<string name="widget_auth_error">Sign-in failed</string>
|
||||||
|
<string name="widget_cert_error">Certificate</string>
|
||||||
|
<string name="widget_problem">Problem</string>
|
||||||
|
<string name="widget_tap_to">tap — %1$s</string>
|
||||||
|
<string name="widget_pause">Pause</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:description="@string/widget_description"
|
android:description="@string/widget_description"
|
||||||
android:minWidth="110dp"
|
android:minWidth="40dp"
|
||||||
android:minHeight="40dp"
|
android:minHeight="40dp"
|
||||||
|
android:minResizeWidth="40dp"
|
||||||
|
android:minResizeHeight="40dp"
|
||||||
android:targetCellWidth="2"
|
android:targetCellWidth="2"
|
||||||
android:targetCellHeight="1"
|
android:targetCellHeight="1"
|
||||||
|
android:maxResizeWidth="530dp"
|
||||||
|
android:maxResizeHeight="260dp"
|
||||||
android:updatePeriodMillis="0"
|
android:updatePeriodMillis="0"
|
||||||
android:resizeMode="horizontal"
|
android:resizeMode="horizontal|vertical"
|
||||||
android:widgetCategory="home_screen"
|
android:widgetCategory="home_screen"
|
||||||
android:initialLayout="@layout/shx_widget_loading" />
|
android:initialLayout="@layout/shx_widget_loading" />
|
||||||
Reference in new issue
Block a user