implement widget text template

This commit is contained in:
2026-05-06 20:27:11 +03:30
parent 81bb6fc156
commit c613ca972f
2 changed files with 315 additions and 10 deletions

View File

@@ -43,6 +43,8 @@ class FlowWidgetProvider : AppWidgetProvider() {
val appContext = context.applicationContext
val manager = AppWidgetManager.getInstance(appContext)
val ids = widgetIds(appContext, manager, intent)
val widgetConfig = loadWidgetConfig(appContext)
val widgetVariables = loadEnvironmentVariables(appContext).toEnvironmentMap()
ids.forEach { widgetId ->
manager.updateAppWidget(
@@ -51,6 +53,12 @@ class FlowWidgetProvider : AppWidgetProvider() {
context = appContext,
widgetId = widgetId,
state = WidgetState(
title = renderWidgetTemplate(
template = widgetConfig.titleTemplate,
values = widgetVariables,
fallback = WidgetDisplayConfig().titleTemplate,
maxLength = 48
),
subtitle = "Running saved flow",
status = "Running",
meta = "Starting",
@@ -99,7 +107,7 @@ class FlowWidgetProvider : AppWidgetProvider() {
private const val ENVIRONMENT_KEY = "environment_variables"
private const val FLOW_KEY = "request_flow"
private const val WIDGET_STATE_KEY = "widget_state"
private const val COLOR_PRIMARY = 0xFF006B5B.toInt()
private const val WIDGET_CONFIG_KEY = "widget_config"
private const val COLOR_AMBER = 0xFFC47A12.toInt()
private const val COLOR_GREEN = 0xFF147A56.toInt()
private const val COLOR_RED = 0xFFC7383D.toInt()
@@ -118,6 +126,14 @@ private data class WidgetState(
val statusColor: Int = 0xFF006B5B.toInt()
)
private data class WidgetDisplayConfig(
val titleTemplate: String = "PostHUB Flow",
val subtitleTemplate: String = "{{flow_steps}} step flow",
val statusTemplate: String = "{{status_code}}",
val metaTemplate: String = "{{duration_ms}} ms {{bytes}}",
val bodyTemplate: String = "{{summary}}"
)
private data class WidgetRequestField(
val id: Int,
val key: String,
@@ -236,12 +252,27 @@ private fun runSavedFlow(
onProgress: (WidgetState) -> Unit
): WidgetState {
val steps = loadFlowSteps(context).ifEmpty { defaultWidgetFlow() }
val widgetConfig = loadWidgetConfig(context)
var environmentRows = loadEnvironmentVariables(context)
var lastResult: WidgetRequestResult? = null
steps.forEachIndexed { index, step ->
val progressValues = environmentRows.toEnvironmentMap() + mapOf(
"flow_steps" to steps.size.toString(),
"step_index" to (index + 1).toString(),
"step_name" to step.request.name,
"status" to "Running",
"status_code" to "Running",
"summary" to step.request.url
)
onProgress(
WidgetState(
title = renderWidgetTemplate(
template = widgetConfig.titleTemplate,
values = progressValues,
fallback = WidgetDisplayConfig().titleTemplate,
maxLength = 48
),
subtitle = "Step ${index + 1} of ${steps.size}",
status = "Running",
meta = step.request.name,
@@ -257,11 +288,27 @@ private fun runSavedFlow(
lastResult = result
if (result.error != null) {
val errorValues = environmentRows.toEnvironmentMap() + mapOf(
"flow_steps" to steps.size.toString(),
"step_index" to (index + 1).toString(),
"step_name" to step.request.name,
"status" to "Error",
"status_code" to "Error",
"status_text" to result.statusText,
"duration_ms" to result.durationMs.toString(),
"bytes" to formatBytes(result.byteCount),
"byte_count" to result.byteCount.toString(),
"final_url" to result.finalUrl,
"summary" to result.error,
"body" to result.error,
"raw_body" to result.rawBody
)
return WidgetState(
subtitle = "Failed at ${step.request.name}",
status = "Error",
meta = result.statusText,
body = result.error,
title = renderWidgetTemplate(widgetConfig.titleTemplate, errorValues, WidgetDisplayConfig().titleTemplate, 48),
subtitle = renderWidgetTemplate(widgetConfig.subtitleTemplate, errorValues, "Failed at ${step.request.name}", 80),
status = renderWidgetTemplate(widgetConfig.statusTemplate, errorValues, "Error", 18),
meta = renderWidgetTemplate(widgetConfig.metaTemplate, errorValues, result.statusText, 80),
body = renderWidgetTemplate(widgetConfig.bodyTemplate, errorValues, result.error, 420),
statusColor = 0xFFC7383D.toInt()
)
}
@@ -274,6 +321,17 @@ private fun runSavedFlow(
}
val result = lastResult ?: return WidgetState(
title = renderWidgetTemplate(
template = widgetConfig.titleTemplate,
values = mapOf(
"flow_steps" to "0",
"status" to "Empty",
"status_code" to "Empty",
"summary" to "Create or add requests to a flow, then refresh this widget."
),
fallback = WidgetDisplayConfig().titleTemplate,
maxLength = 48
),
subtitle = "No flow steps",
status = "Empty",
meta = "Open PostHUB",
@@ -281,11 +339,13 @@ private fun runSavedFlow(
statusColor = 0xFF66736D.toInt()
)
val finalValues = widgetTemplateValues(result, environmentRows, steps.size)
return WidgetState(
subtitle = "${steps.size} step flow",
status = result.statusCode?.toString() ?: "Done",
meta = "${result.durationMs} ms ${formatBytes(result.byteCount)}",
body = summarizeWidgetResponse(result),
title = renderWidgetTemplate(widgetConfig.titleTemplate, finalValues, WidgetDisplayConfig().titleTemplate, 48),
subtitle = renderWidgetTemplate(widgetConfig.subtitleTemplate, finalValues, "${steps.size} step flow", 80),
status = renderWidgetTemplate(widgetConfig.statusTemplate, finalValues, result.statusCode?.toString() ?: "Done", 18),
meta = renderWidgetTemplate(widgetConfig.metaTemplate, finalValues, "${result.durationMs} ms ${formatBytes(result.byteCount)}", 80),
body = renderWidgetTemplate(widgetConfig.bodyTemplate, finalValues, summarizeWidgetResponse(result), 420),
statusColor = statusColor(result.statusCode)
)
}
@@ -422,7 +482,7 @@ private fun loadLastWidgetState(context: Context): WidgetState {
.getSharedPreferences("posthub_state", Context.MODE_PRIVATE)
.getString("widget_state", null)
if (raw.isNullOrBlank()) return WidgetState()
if (raw.isNullOrBlank()) return defaultRenderedWidgetState(context)
return runCatching {
val json = JSONObject(raw)
@@ -437,6 +497,91 @@ private fun loadLastWidgetState(context: Context): WidgetState {
}.getOrDefault(WidgetState())
}
private fun defaultRenderedWidgetState(context: Context): WidgetState {
val config = loadWidgetConfig(context)
val values = loadEnvironmentVariables(context).toEnvironmentMap() + mapOf(
"flow_steps" to "0",
"status" to "Ready",
"status_code" to "Ready",
"status_text" to "Ready",
"duration_ms" to "",
"bytes" to "",
"byte_count" to "",
"final_url" to "",
"summary" to "Run your saved flow from the home screen.",
"body" to "Run your saved flow from the home screen.",
"raw_body" to ""
)
return WidgetState(
title = renderWidgetTemplate(config.titleTemplate, values, WidgetDisplayConfig().titleTemplate, 48),
subtitle = renderWidgetTemplate(config.subtitleTemplate, values, "Home runner", 80),
status = renderWidgetTemplate(config.statusTemplate, values, "Ready", 18),
meta = renderWidgetTemplate(config.metaTemplate, values, "Tap refresh", 80),
body = renderWidgetTemplate(config.bodyTemplate, values, "Run your saved flow from the home screen.", 420),
statusColor = 0xFF006B5B.toInt()
)
}
private fun loadWidgetConfig(context: Context): WidgetDisplayConfig {
val raw = context
.getSharedPreferences("posthub_state", Context.MODE_PRIVATE)
.getString("widget_config", null)
if (raw.isNullOrBlank()) return WidgetDisplayConfig()
return runCatching {
val json = JSONObject(raw)
WidgetDisplayConfig(
titleTemplate = json.optString("titleTemplate", WidgetDisplayConfig().titleTemplate),
subtitleTemplate = json.optString("subtitleTemplate", WidgetDisplayConfig().subtitleTemplate),
statusTemplate = json.optString("statusTemplate", WidgetDisplayConfig().statusTemplate),
metaTemplate = json.optString("metaTemplate", WidgetDisplayConfig().metaTemplate),
bodyTemplate = json.optString("bodyTemplate", WidgetDisplayConfig().bodyTemplate)
)
}.getOrDefault(WidgetDisplayConfig())
}
private fun widgetTemplateValues(
result: WidgetRequestResult,
environmentRows: List<WidgetRequestField>,
flowSteps: Int
): Map<String, String> {
val statusValue = result.statusCode?.toString() ?: "Done"
return environmentRows.toEnvironmentMap() + mapOf(
"flow_steps" to flowSteps.toString(),
"status" to statusValue,
"status_code" to statusValue,
"status_text" to result.statusText,
"duration_ms" to result.durationMs.toString(),
"bytes" to formatBytes(result.byteCount),
"byte_count" to result.byteCount.toString(),
"final_url" to result.finalUrl,
"summary" to summarizeWidgetResponse(result),
"body" to result.body,
"raw_body" to result.rawBody
)
}
private fun renderWidgetTemplate(
template: String,
values: Map<String, String>,
fallback: String,
maxLength: Int
): String {
val source = template.ifBlank { fallback }
val rendered = source.applyVariables(values).trim().ifBlank {
fallback.applyVariables(values).trim()
}
return rendered.limitWidgetText(maxLength)
}
private fun String.limitWidgetText(maxLength: Int): String {
if (length <= maxLength) return this
if (maxLength <= 3) return take(maxLength)
return take(maxLength - 3).trimEnd() + "..."
}
private fun saveLastWidgetState(
context: Context,
state: WidgetState

View File

@@ -105,6 +105,7 @@ private val VariablePattern = Regex("""\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}""")
private const val PrefName = "posthub_state"
private const val EnvironmentKey = "environment_variables"
private const val FlowKey = "request_flow"
private const val WidgetConfigKey = "widget_config"
private object AppColors {
private val darkMode = mutableStateOf(false)
@@ -230,6 +231,14 @@ private data class FlowRunEntry(
val savedVariables: Int = 0
)
private data class WidgetConfig(
val titleTemplate: String = "PostHUB Flow",
val subtitleTemplate: String = "{{flow_steps}} step flow",
val statusTemplate: String = "{{status_code}}",
val metaTemplate: String = "{{duration_ms}} ms {{bytes}}",
val bodyTemplate: String = "{{summary}}"
)
private fun appSections() = listOf(
AppSection("request", "Request", "Composer", AppColors.Primary),
AppSection("clients", "Clients", "MCI TCI", AppColors.Coral),
@@ -285,6 +294,7 @@ private fun ApiClientApp() {
loadFlowSteps(appContext).ifEmpty { defaultFlowSteps(builtInRequests) }
)
}
var widgetConfig by remember { mutableStateOf(loadWidgetConfig(appContext)) }
var flowRun by remember { mutableStateOf<List<FlowRunEntry>>(emptyList()) }
var response by remember { mutableStateOf<RequestResult?>(null) }
var history by remember { mutableStateOf<List<HistoryItem>>(emptyList()) }
@@ -302,6 +312,11 @@ private fun ApiClientApp() {
saveFlowSteps(appContext, next)
}
fun updateWidgetConfig(next: WidgetConfig) {
widgetConfig = next
saveWidgetConfig(appContext, next)
}
fun currentRequest(): SavedRequest {
return SavedRequest(
name = requestName(method, url),
@@ -540,8 +555,10 @@ private fun ApiClientApp() {
steps = flowSteps,
runEntries = flowRun,
isRunning = isFlowRunning,
widgetConfig = widgetConfig,
onAddCurrent = ::addCurrentRequestToFlow,
onRun = ::runFlow,
onWidgetConfigChange = ::updateWidgetConfig,
onLoad = { loadSavedRequest(it.request) },
onRemove = { step ->
updateFlowSteps(flowSteps.filterNot { it.id == step.id })
@@ -1164,8 +1181,10 @@ private fun FlowPanel(
steps: List<FlowStep>,
runEntries: List<FlowRunEntry>,
isRunning: Boolean,
widgetConfig: WidgetConfig,
onAddCurrent: () -> Unit,
onRun: () -> Unit,
onWidgetConfigChange: (WidgetConfig) -> Unit,
onLoad: (FlowStep) -> Unit,
onRemove: (FlowStep) -> Unit
) {
@@ -1234,10 +1253,114 @@ private fun FlowPanel(
}
}
}
WidgetTextEditor(
config = widgetConfig,
onConfigChange = onWidgetConfigChange
)
}
}
}
@Composable
private fun WidgetTextEditor(
config: WidgetConfig,
onConfigChange: (WidgetConfig) -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = AppColors.PanelSoft,
border = BorderStroke(1.dp, AppColors.Border)
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
SectionTitle(title = "Widget text", meta = "Templates")
TextButton(onClick = { onConfigChange(WidgetConfig()) }) {
Text("Reset", fontWeight = FontWeight.Bold)
}
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
StatusPill("{{summary}}", AppColors.Primary)
StatusPill("{{status_code}}", AppColors.Green)
StatusPill("{{duration_ms}}", AppColors.Amber)
StatusPill("{{public_ip}}", AppColors.Coral)
}
WidgetTemplateField(
label = "Title",
value = config.titleTemplate,
placeholder = "PostHUB Flow",
onValueChange = { onConfigChange(config.copy(titleTemplate = it)) }
)
WidgetTemplateField(
label = "Subtitle",
value = config.subtitleTemplate,
placeholder = "{{flow_steps}} step flow",
onValueChange = { onConfigChange(config.copy(subtitleTemplate = it)) }
)
WidgetTemplateField(
label = "Status",
value = config.statusTemplate,
placeholder = "{{status_code}}",
onValueChange = { onConfigChange(config.copy(statusTemplate = it)) }
)
WidgetTemplateField(
label = "Meta",
value = config.metaTemplate,
placeholder = "{{duration_ms}} ms {{bytes}}",
onValueChange = { onConfigChange(config.copy(metaTemplate = it)) }
)
WidgetTemplateField(
label = "Body",
value = config.bodyTemplate,
placeholder = "{{summary}}",
minLines = 2,
onValueChange = { onConfigChange(config.copy(bodyTemplate = it)) }
)
}
}
}
@Composable
private fun WidgetTemplateField(
label: String,
value: String,
placeholder: String,
minLines: Int = 1,
onValueChange: (String) -> Unit
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
minLines = minLines,
maxLines = if (minLines > 1) 3 else 1,
singleLine = minLines == 1,
shape = RoundedCornerShape(8.dp),
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
label = { Text(label) },
placeholder = {
Text(
text = placeholder,
color = AppColors.Muted,
maxLines = 1
)
}
)
}
@Composable
private fun FlowStepRow(
index: Int,
@@ -2762,6 +2885,43 @@ private fun saveFlowSteps(
.apply()
}
private fun loadWidgetConfig(context: Context): WidgetConfig {
val raw = context
.getSharedPreferences(PrefName, Context.MODE_PRIVATE)
.getString(WidgetConfigKey, null)
if (raw.isNullOrBlank()) return WidgetConfig()
return runCatching {
val json = JSONObject(raw)
WidgetConfig(
titleTemplate = json.optString("titleTemplate", WidgetConfig().titleTemplate),
subtitleTemplate = json.optString("subtitleTemplate", WidgetConfig().subtitleTemplate),
statusTemplate = json.optString("statusTemplate", WidgetConfig().statusTemplate),
metaTemplate = json.optString("metaTemplate", WidgetConfig().metaTemplate),
bodyTemplate = json.optString("bodyTemplate", WidgetConfig().bodyTemplate)
)
}.getOrDefault(WidgetConfig())
}
private fun saveWidgetConfig(
context: Context,
config: WidgetConfig
) {
val json = JSONObject()
.put("titleTemplate", config.titleTemplate)
.put("subtitleTemplate", config.subtitleTemplate)
.put("statusTemplate", config.statusTemplate)
.put("metaTemplate", config.metaTemplate)
.put("bodyTemplate", config.bodyTemplate)
context
.getSharedPreferences(PrefName, Context.MODE_PRIVATE)
.edit()
.putString(WidgetConfigKey, json.toString())
.apply()
}
private fun requestFieldsToJson(rows: List<RequestField>): JSONArray {
val array = JSONArray()
rows.forEach { row ->