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:
DenozordecandClaude Opus 5.5 committed 2026-10-12 10:18:47 +07:00
1 parent db6c806d58
commit 53623ebb4a
19 files changed
+435 -67

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())
@@ -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
}
} }
@@ -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)
{ {
Text( Look.ON -> Triple(ACCENT, R.drawable.ic_widget_logo_accent, DOT_ON)
text = status, Look.CONNECTING -> Triple(ACCENT, R.drawable.ic_widget_logo_accent, DOT_OFF)
style = TextStyle( Look.OFF -> Triple(GREY, R.drawable.ic_widget_logo_grey, DOT_OFF)
fontWeight = FontWeight.Bold, Look.PROBLEM -> Triple(WARN_STRONG, R.drawable.ic_widget_logo_warning, WARN_STRONG)
fontSize = 16.sp, }
color = GlanceTheme.colors.onSurface, Box(GlanceModifier.fillMaxSize().appWidgetBackground().clickable(open), contentAlignment = Alignment.Center)
), {
maxLines = 1, Box(GlanceModifier.size(64.dp))
) {
Text( Box(GlanceModifier.size(64.dp).cornerRadius(32.dp).background(bg), contentAlignment = Alignment.Center)
text = snapshot.profileName ?: context.getString(R.string.status_choose_profile), {
style = TextStyle(fontSize = 12.sp, color = GlanceTheme.colors.onSurfaceVariant), Image(ImageProvider(logo), contentDescription = null, modifier = GlanceModifier.size(34.dp))
maxLines = 1, }
) Box(GlanceModifier.size(64.dp).padding(2.dp), contentAlignment = Alignment.BottomEnd)
{
/* the 3dp ring in the widget background around the dot */
Box(GlanceModifier.size(16.dp).cornerRadius(8.dp).background(BG).padding(3.dp))
{
Box(GlanceModifier.size(10.dp).cornerRadius(5.dp).background(dot)) {}
}
}
} }
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" />