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";
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -121,5 +126,10 @@ public class MainActivity extends AppCompatActivity
|
||||
intent.removeExtra(EXTRA_OPEN_SERVERS);
|
||||
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()
|
||||
}
|
||||
}
|
||||
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 intent = Intent(context, VpnProfileDetailActivity::class.java)
|
||||
intent.putExtra(VpnProfileDataSource.KEY_UUID, profile.getUUID().toString())
|
||||
|
||||
+14
@@ -49,4 +49,18 @@ object ShxNavigation
|
||||
{
|
||||
_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.Intent
|
||||
import android.os.SystemClock
|
||||
import android.widget.RemoteViews
|
||||
import androidx.annotation.DrawableRes
|
||||
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.sp
|
||||
import androidx.glance.Button
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.Image
|
||||
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.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
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.cornerRadius
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.glance.background
|
||||
import androidx.glance.color.ColorProvider
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -44,16 +64,22 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.strongswan.android.R
|
||||
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.ui.MainActivity
|
||||
import org.strongswan.android.ui.VpnProfileControlActivity
|
||||
import org.strongswan.android.utils.DefaultVpnProfile
|
||||
|
||||
/** What the widget shows; persisted by [ShxWidgetState] so it survives process death. */
|
||||
internal data class WidgetSnapshot(
|
||||
val state: String,
|
||||
val error: Boolean,
|
||||
/* ErrorState name, null without an error */
|
||||
val error: String?,
|
||||
val profileName: 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
|
||||
{
|
||||
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_UUID = "widget_profile_uuid"
|
||||
private const val KEY_SINCE = "widget_connected_since"
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
@@ -79,9 +106,13 @@ object ShxWidgetState
|
||||
)
|
||||
{
|
||||
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)
|
||||
.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)
|
||||
{
|
||||
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
|
||||
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()
|
||||
{
|
||||
override val sizeMode = SizeMode.Single
|
||||
override val sizeMode = SizeMode.Responsive(setOf(ICON, SMALL, LARGE))
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId)
|
||||
{
|
||||
val snapshot = ShxWidgetState.snapshot(context)
|
||||
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
|
||||
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(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.appWidgetBackground()
|
||||
.background(GlanceTheme.colors.widgetBackground)
|
||||
.cornerRadius(24.dp)
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.Vertical.CenterVertically,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier.height(44.dp).cornerRadius(14.dp).background(TILE).clickable(onClick).padding(horizontal = 4.dp),
|
||||
)
|
||||
{
|
||||
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(
|
||||
text = status,
|
||||
style = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
color = GlanceTheme.colors.onSurface,
|
||||
),
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
text = snapshot.profileName ?: context.getString(R.string.status_choose_profile),
|
||||
style = TextStyle(fontSize = 12.sp, color = GlanceTheme.colors.onSurfaceVariant),
|
||||
maxLines = 1,
|
||||
)
|
||||
Box(GlanceModifier.size(64.dp).cornerRadius(32.dp).background(bg), contentAlignment = Alignment.Center)
|
||||
{
|
||||
Image(ImageProvider(logo), contentDescription = null, modifier = GlanceModifier.size(34.dp))
|
||||
}
|
||||
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="warning_text">#FBBF24</color>
|
||||
<color name="success_text">#28A671</color>
|
||||
<color name="widget_text">#FFE5E1E9</color>
|
||||
<color name="widget_text_sub">#FFA9A3BF</color>
|
||||
</resources>
|
||||
@@ -1013,4 +1013,12 @@
|
||||
<string name="notif_pause">Пауза 15 м</string>
|
||||
<string name="notif_change">Сменить</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>
|
||||
@@ -15,4 +15,6 @@
|
||||
<color name="error_text">#DC2626</color>
|
||||
<color name="warning_text">#B45309</color>
|
||||
<color name="success_text">#148A45</color>
|
||||
<color name="widget_text">#FF1C1B20</color>
|
||||
<color name="widget_text_sub">#FF5E5A7D</color>
|
||||
</resources>
|
||||
@@ -998,4 +998,12 @@
|
||||
<string name="notif_pause">Pause 15 min</string>
|
||||
<string name="notif_change">Change</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>
|
||||
@@ -1,11 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/widget_description"
|
||||
android:minWidth="110dp"
|
||||
android:minWidth="40dp"
|
||||
android:minHeight="40dp"
|
||||
android:minResizeWidth="40dp"
|
||||
android:minResizeHeight="40dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="1"
|
||||
android:maxResizeWidth="530dp"
|
||||
android:maxResizeHeight="260dp"
|
||||
android:updatePeriodMillis="0"
|
||||
android:resizeMode="horizontal"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:initialLayout="@layout/shx_widget_loading" />
|
||||
Reference in new issue
Block a user