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 appContext = context.applicationContext
val manager = AppWidgetManager.getInstance(appContext) val manager = AppWidgetManager.getInstance(appContext)
val ids = widgetIds(appContext, manager, intent) val ids = widgetIds(appContext, manager, intent)
val widgetConfig = loadWidgetConfig(appContext)
val widgetVariables = loadEnvironmentVariables(appContext).toEnvironmentMap()
ids.forEach { widgetId -> ids.forEach { widgetId ->
manager.updateAppWidget( manager.updateAppWidget(
@@ -51,6 +53,12 @@ class FlowWidgetProvider : AppWidgetProvider() {
context = appContext, context = appContext,
widgetId = widgetId, widgetId = widgetId,
state = WidgetState( state = WidgetState(
title = renderWidgetTemplate(
template = widgetConfig.titleTemplate,
values = widgetVariables,
fallback = WidgetDisplayConfig().titleTemplate,
maxLength = 48
),
subtitle = "Running saved flow", subtitle = "Running saved flow",
status = "Running", status = "Running",
meta = "Starting", meta = "Starting",
@@ -99,7 +107,7 @@ class FlowWidgetProvider : AppWidgetProvider() {
private const val ENVIRONMENT_KEY = "environment_variables" private const val ENVIRONMENT_KEY = "environment_variables"
private const val FLOW_KEY = "request_flow" private const val FLOW_KEY = "request_flow"
private const val WIDGET_STATE_KEY = "widget_state" 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_AMBER = 0xFFC47A12.toInt()
private const val COLOR_GREEN = 0xFF147A56.toInt() private const val COLOR_GREEN = 0xFF147A56.toInt()
private const val COLOR_RED = 0xFFC7383D.toInt() private const val COLOR_RED = 0xFFC7383D.toInt()
@@ -118,6 +126,14 @@ private data class WidgetState(
val statusColor: Int = 0xFF006B5B.toInt() 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( private data class WidgetRequestField(
val id: Int, val id: Int,
val key: String, val key: String,
@@ -236,12 +252,27 @@ private fun runSavedFlow(
onProgress: (WidgetState) -> Unit onProgress: (WidgetState) -> Unit
): WidgetState { ): WidgetState {
val steps = loadFlowSteps(context).ifEmpty { defaultWidgetFlow() } val steps = loadFlowSteps(context).ifEmpty { defaultWidgetFlow() }
val widgetConfig = loadWidgetConfig(context)
var environmentRows = loadEnvironmentVariables(context) var environmentRows = loadEnvironmentVariables(context)
var lastResult: WidgetRequestResult? = null var lastResult: WidgetRequestResult? = null
steps.forEachIndexed { index, step -> 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( onProgress(
WidgetState( WidgetState(
title = renderWidgetTemplate(
template = widgetConfig.titleTemplate,
values = progressValues,
fallback = WidgetDisplayConfig().titleTemplate,
maxLength = 48
),
subtitle = "Step ${index + 1} of ${steps.size}", subtitle = "Step ${index + 1} of ${steps.size}",
status = "Running", status = "Running",
meta = step.request.name, meta = step.request.name,
@@ -257,11 +288,27 @@ private fun runSavedFlow(
lastResult = result lastResult = result
if (result.error != null) { 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( return WidgetState(
subtitle = "Failed at ${step.request.name}", title = renderWidgetTemplate(widgetConfig.titleTemplate, errorValues, WidgetDisplayConfig().titleTemplate, 48),
status = "Error", subtitle = renderWidgetTemplate(widgetConfig.subtitleTemplate, errorValues, "Failed at ${step.request.name}", 80),
meta = result.statusText, status = renderWidgetTemplate(widgetConfig.statusTemplate, errorValues, "Error", 18),
body = result.error, meta = renderWidgetTemplate(widgetConfig.metaTemplate, errorValues, result.statusText, 80),
body = renderWidgetTemplate(widgetConfig.bodyTemplate, errorValues, result.error, 420),
statusColor = 0xFFC7383D.toInt() statusColor = 0xFFC7383D.toInt()
) )
} }
@@ -274,6 +321,17 @@ private fun runSavedFlow(
} }
val result = lastResult ?: return WidgetState( 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", subtitle = "No flow steps",
status = "Empty", status = "Empty",
meta = "Open PostHUB", meta = "Open PostHUB",
@@ -281,11 +339,13 @@ private fun runSavedFlow(
statusColor = 0xFF66736D.toInt() statusColor = 0xFF66736D.toInt()
) )
val finalValues = widgetTemplateValues(result, environmentRows, steps.size)
return WidgetState( return WidgetState(
subtitle = "${steps.size} step flow", title = renderWidgetTemplate(widgetConfig.titleTemplate, finalValues, WidgetDisplayConfig().titleTemplate, 48),
status = result.statusCode?.toString() ?: "Done", subtitle = renderWidgetTemplate(widgetConfig.subtitleTemplate, finalValues, "${steps.size} step flow", 80),
meta = "${result.durationMs} ms ${formatBytes(result.byteCount)}", status = renderWidgetTemplate(widgetConfig.statusTemplate, finalValues, result.statusCode?.toString() ?: "Done", 18),
body = summarizeWidgetResponse(result), meta = renderWidgetTemplate(widgetConfig.metaTemplate, finalValues, "${result.durationMs} ms ${formatBytes(result.byteCount)}", 80),
body = renderWidgetTemplate(widgetConfig.bodyTemplate, finalValues, summarizeWidgetResponse(result), 420),
statusColor = statusColor(result.statusCode) statusColor = statusColor(result.statusCode)
) )
} }
@@ -422,7 +482,7 @@ private fun loadLastWidgetState(context: Context): WidgetState {
.getSharedPreferences("posthub_state", Context.MODE_PRIVATE) .getSharedPreferences("posthub_state", Context.MODE_PRIVATE)
.getString("widget_state", null) .getString("widget_state", null)
if (raw.isNullOrBlank()) return WidgetState() if (raw.isNullOrBlank()) return defaultRenderedWidgetState(context)
return runCatching { return runCatching {
val json = JSONObject(raw) val json = JSONObject(raw)
@@ -437,6 +497,91 @@ private fun loadLastWidgetState(context: Context): WidgetState {
}.getOrDefault(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( private fun saveLastWidgetState(
context: Context, context: Context,
state: WidgetState 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 PrefName = "posthub_state"
private const val EnvironmentKey = "environment_variables" private const val EnvironmentKey = "environment_variables"
private const val FlowKey = "request_flow" private const val FlowKey = "request_flow"
private const val WidgetConfigKey = "widget_config"
private object AppColors { private object AppColors {
private val darkMode = mutableStateOf(false) private val darkMode = mutableStateOf(false)
@@ -230,6 +231,14 @@ private data class FlowRunEntry(
val savedVariables: Int = 0 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( private fun appSections() = listOf(
AppSection("request", "Request", "Composer", AppColors.Primary), AppSection("request", "Request", "Composer", AppColors.Primary),
AppSection("clients", "Clients", "MCI TCI", AppColors.Coral), AppSection("clients", "Clients", "MCI TCI", AppColors.Coral),
@@ -285,6 +294,7 @@ private fun ApiClientApp() {
loadFlowSteps(appContext).ifEmpty { defaultFlowSteps(builtInRequests) } loadFlowSteps(appContext).ifEmpty { defaultFlowSteps(builtInRequests) }
) )
} }
var widgetConfig by remember { mutableStateOf(loadWidgetConfig(appContext)) }
var flowRun by remember { mutableStateOf<List<FlowRunEntry>>(emptyList()) } var flowRun by remember { mutableStateOf<List<FlowRunEntry>>(emptyList()) }
var response by remember { mutableStateOf<RequestResult?>(null) } var response by remember { mutableStateOf<RequestResult?>(null) }
var history by remember { mutableStateOf<List<HistoryItem>>(emptyList()) } var history by remember { mutableStateOf<List<HistoryItem>>(emptyList()) }
@@ -302,6 +312,11 @@ private fun ApiClientApp() {
saveFlowSteps(appContext, next) saveFlowSteps(appContext, next)
} }
fun updateWidgetConfig(next: WidgetConfig) {
widgetConfig = next
saveWidgetConfig(appContext, next)
}
fun currentRequest(): SavedRequest { fun currentRequest(): SavedRequest {
return SavedRequest( return SavedRequest(
name = requestName(method, url), name = requestName(method, url),
@@ -540,8 +555,10 @@ private fun ApiClientApp() {
steps = flowSteps, steps = flowSteps,
runEntries = flowRun, runEntries = flowRun,
isRunning = isFlowRunning, isRunning = isFlowRunning,
widgetConfig = widgetConfig,
onAddCurrent = ::addCurrentRequestToFlow, onAddCurrent = ::addCurrentRequestToFlow,
onRun = ::runFlow, onRun = ::runFlow,
onWidgetConfigChange = ::updateWidgetConfig,
onLoad = { loadSavedRequest(it.request) }, onLoad = { loadSavedRequest(it.request) },
onRemove = { step -> onRemove = { step ->
updateFlowSteps(flowSteps.filterNot { it.id == step.id }) updateFlowSteps(flowSteps.filterNot { it.id == step.id })
@@ -1164,8 +1181,10 @@ private fun FlowPanel(
steps: List<FlowStep>, steps: List<FlowStep>,
runEntries: List<FlowRunEntry>, runEntries: List<FlowRunEntry>,
isRunning: Boolean, isRunning: Boolean,
widgetConfig: WidgetConfig,
onAddCurrent: () -> Unit, onAddCurrent: () -> Unit,
onRun: () -> Unit, onRun: () -> Unit,
onWidgetConfigChange: (WidgetConfig) -> Unit,
onLoad: (FlowStep) -> Unit, onLoad: (FlowStep) -> Unit,
onRemove: (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 @Composable
private fun FlowStepRow( private fun FlowStepRow(
index: Int, index: Int,
@@ -2762,6 +2885,43 @@ private fun saveFlowSteps(
.apply() .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 { private fun requestFieldsToJson(rows: List<RequestField>): JSONArray {
val array = JSONArray() val array = JSONArray()
rows.forEach { row -> rows.forEach { row ->