feat: add per-list column alignment persistence and UI controls in ListDetailScreen

This commit is contained in:
2025-11-11 22:32:01 +01:00
parent 1624da76b2
commit b1e740c396
2 changed files with 259 additions and 103 deletions
@@ -223,6 +223,45 @@ class PreferencesManager(context: Context) {
prefs.edit().putString(key, json.toString()).apply()
}
// Persist per-list column alignments (fieldId -> alignment: "start" | "center" | "end")
// Stored as a JSON object string under key: COLUMN_ALIGN_PREFIX + listId
fun getColumnAlignments(listId: String): Map<String, String> {
val key = COLUMN_ALIGN_PREFIX + listId
val raw = prefs.getString(key, null) ?: return emptyMap()
return try {
val json = JSONObject(raw)
val map = mutableMapOf<String, String>()
val it = json.keys()
while (it.hasNext()) {
val fieldId = it.next()
val align = json.optString(fieldId, "start")
val normalized = when (align.lowercase()) {
"center" -> "center"
"end", "right" -> "end"
else -> "start"
}
map[fieldId] = normalized
}
map
} catch (e: Exception) {
emptyMap()
}
}
fun setColumnAlignments(listId: String, alignments: Map<String, String>) {
val key = COLUMN_ALIGN_PREFIX + listId
val json = JSONObject()
alignments.forEach { (fieldId, alignment) ->
val normalized = when (alignment.lowercase()) {
"center" -> "center"
"end", "right" -> "end"
else -> "start"
}
json.put(fieldId, normalized)
}
prefs.edit().putString(key, json.toString()).apply()
}
companion object {
private const val KEY_SERVER_URL = "server_url"
private const val KEY_FIRST_RUN = "first_run"
@@ -238,6 +277,7 @@ class PreferencesManager(context: Context) {
private const val KEY_NOTIFY_LIST_REMOVED = "notify_list_removed"
private const val KEY_LAST_LIST_NOTIFY_CHECK_TS = "last_list_notify_check_ts"
private const val COLUMN_WIDTHS_PREFIX = "column_widths_" // + listId
private const val COLUMN_ALIGN_PREFIX = "column_align_" // + listId
private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/"
private const val DEFAULT_SYNC_POLL_INTERVAL_MS = 250L
private const val MIN_SYNC_POLL_INTERVAL_MS = 250L
@@ -68,6 +68,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@@ -146,6 +147,9 @@ fun ListDetailScreen(
// Field widths state (resizable columns)
val fieldWidths = remember { mutableStateMapOf<String, Dp>() }
// Per-column content alignment: "start" | "center" | "end"
val columnAlignments = remember { mutableStateMapOf<String, String>() }
// Initialize field widths (load persisted per-list widths, fallback to default)
LaunchedEffect(stableFields) {
// Load saved widths for this listId
@@ -155,6 +159,16 @@ fun ListDetailScreen(
val widthDp = (savedWidth ?: 150f).dp
fieldWidths[field.id] = widthDp
}
// Load saved alignments for this listId
val savedAlign = prefs.getColumnAlignments(listId)
stableFields.forEach { field ->
val a = savedAlign[field.id]?.lowercase() ?: "start"
columnAlignments[field.id] = when (a) {
"center" -> "center"
"end", "right" -> "end"
else -> "start"
}
}
}
// Scaffold with top bar and FAB
@@ -524,6 +538,11 @@ fun ListDetailScreen(
scrollState = horizontalScrollState,
isLast = (field.id == stableFields.lastOrNull()?.id),
onHeaderClick = { showManageColumnsDialog = true },
alignment = columnAlignments[field.id] ?: "start",
onAlignmentChange = { newAlign ->
columnAlignments[field.id] = newAlign
prefs.setColumnAlignments(listId, columnAlignments.toMap())
},
)
}
}
@@ -547,6 +566,7 @@ fun ListDetailScreen(
ItemRow(
fields = stableFields,
fieldWidths = fieldWidths,
fieldAlignments = columnAlignments,
itemWithValues = itemWithValues,
onClick = { itemToEdit = itemWithValues },
)
@@ -562,7 +582,19 @@ fun ListDetailScreen(
if (showManageColumnsDialog) {
ManageColumnsDialog(
fields = stableFields,
onDismiss = { showManageColumnsDialog = false },
onDismiss = {
showManageColumnsDialog = false
// Refresh alignments from preferences in case they changed while editing columns
val savedAlign = prefs.getColumnAlignments(listId)
stableFields.forEach { field ->
val a = savedAlign[field.id]?.lowercase() ?: columnAlignments[field.id] ?: "start"
columnAlignments[field.id] = when (a) {
"center" -> "center"
"end", "right" -> "end"
else -> "start"
}
}
},
onAddField = { name, fieldType, fieldOptions ->
viewModel.addField(name, fieldType, fieldOptions)
},
@@ -796,6 +828,8 @@ fun FieldHeader(
scrollState: androidx.compose.foundation.ScrollState,
isLast: Boolean,
onHeaderClick: () -> Unit,
alignment: String = "start",
onAlignmentChange: (String) -> Unit = {},
) {
val density = LocalDensity.current
val scope = rememberCoroutineScope()
@@ -826,6 +860,11 @@ fun FieldHeader(
Modifier
.weight(1f)
.clickable { onHeaderClick() },
textAlign = when (alignment) {
"center" -> TextAlign.Center
"end" -> TextAlign.End
else -> TextAlign.Start
},
)
// Resize handle
@@ -897,6 +936,21 @@ fun FieldHeader(
tint = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f),
)
}
// Alignment segmented control (single-select)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val opts = listOf("start" to "L", "center" to "C", "end" to "R")
opts.forEach { (value, label) ->
FilterChip(
selected = alignment == value,
onClick = { onAlignmentChange(value) },
label = { Text(label) },
)
}
}
}
}
}
@@ -907,6 +961,7 @@ fun FieldHeader(
fun ItemRow(
fields: List<Field>,
fieldWidths: Map<String, Dp>,
fieldAlignments: Map<String, String>,
itemWithValues: ItemWithValues,
onClick: () -> Unit,
) {
@@ -928,6 +983,16 @@ fun ItemRow(
key(field.id) {
val value = valuesByFieldId[field.id]
val fieldWidth = fieldWidths[field.id] ?: 150.dp
val alignment = when (fieldAlignments[field.id]?.lowercase()) {
"center" -> Alignment.Center
"end", "right" -> Alignment.CenterEnd
else -> Alignment.CenterStart
}
val textAlign = when (fieldAlignments[field.id]?.lowercase()) {
"center" -> TextAlign.Center
"end", "right" -> TextAlign.End
else -> TextAlign.Start
}
Box(
modifier =
@@ -945,6 +1010,7 @@ fun ItemRow(
Text(
text = value?.value ?: "",
style = MaterialTheme.typography.bodyMedium,
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -956,6 +1022,7 @@ fun ItemRow(
Text(
text = value?.value ?: "",
style = MaterialTheme.typography.bodyMedium,
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -967,6 +1034,7 @@ fun ItemRow(
Text(
text = value?.value ?: "",
style = MaterialTheme.typography.bodyMedium,
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -983,6 +1051,7 @@ fun ItemRow(
"${field.getCurrency()}${value?.value}"
},
style = MaterialTheme.typography.bodyMedium,
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -994,6 +1063,7 @@ fun ItemRow(
Text(
text = if (value?.value.isNullOrBlank()) "" else "${value?.value}%",
style = MaterialTheme.typography.bodyMedium,
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -1005,6 +1075,7 @@ fun ItemRow(
Text(
text = value?.value ?: "",
style = MaterialTheme.typography.bodyMedium,
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -1016,6 +1087,7 @@ fun ItemRow(
Text(
text = if (value?.value == "true") "" else "",
style = MaterialTheme.typography.bodyLarge,
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -1027,6 +1099,7 @@ fun ItemRow(
val uriHandler = LocalUriHandler.current
val urlValue = value?.value ?: ""
if (urlValue.isNotBlank()) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = alignment) {
ClickableText(
text =
buildAnnotatedString {
@@ -1047,16 +1120,14 @@ fun ItemRow(
// Handle invalid URL
}
},
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
Text(
text = "",
style = MaterialTheme.typography.bodyMedium,
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -1069,6 +1140,7 @@ fun ItemRow(
val uriHandler = LocalUriHandler.current
val emailValue = value?.value ?: ""
if (emailValue.isNotBlank()) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = alignment) {
ClickableText(
text =
buildAnnotatedString {
@@ -1089,16 +1161,14 @@ fun ItemRow(
// Handle invalid email
}
},
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
Text(
text = "",
style = MaterialTheme.typography.bodyMedium,
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -1111,6 +1181,7 @@ fun ItemRow(
val uriHandler = LocalUriHandler.current
val phoneValue = value?.value ?: ""
if (phoneValue.isNotBlank()) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = alignment) {
ClickableText(
text =
buildAnnotatedString {
@@ -1131,16 +1202,14 @@ fun ItemRow(
// Handle invalid phone
}
},
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
Text(
text = "",
style = MaterialTheme.typography.bodyMedium,
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -1153,6 +1222,7 @@ fun ItemRow(
Text(
text = value?.value ?: "",
style = MaterialTheme.typography.bodyMedium,
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -1164,6 +1234,7 @@ fun ItemRow(
Text(
text = value?.value ?: "",
style = MaterialTheme.typography.bodyMedium,
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -1175,6 +1246,7 @@ fun ItemRow(
Text(
text = value?.value ?: "",
style = MaterialTheme.typography.bodyMedium,
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -1225,10 +1297,10 @@ fun ItemRow(
com.collabtable.app.data.model.FieldType.RATING -> {
val rating = value?.value?.toIntOrNull() ?: 0
val maxRating = field.getMaxRating()
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = alignment) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
repeat(maxRating) { index ->
@@ -1246,6 +1318,7 @@ fun ItemRow(
}
}
}
}
com.collabtable.app.data.model.FieldType.COLOR -> {
Row(
@@ -1279,17 +1352,20 @@ fun ItemRow(
)
Spacer(modifier = Modifier.width(8.dp))
}
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = alignment) {
Text(
text = value?.value ?: "",
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
com.collabtable.app.data.model.FieldType.LOCATION -> {
Text(
text = value?.value ?: "",
style = MaterialTheme.typography.bodyMedium,
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -1302,6 +1378,7 @@ fun ItemRow(
Text(
text = "🖼️ ${value.value}",
style = MaterialTheme.typography.bodyMedium,
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -1311,6 +1388,7 @@ fun ItemRow(
Text(
text = "",
style = MaterialTheme.typography.bodyMedium,
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -1324,6 +1402,7 @@ fun ItemRow(
Text(
text = "📎 ${value.value}",
style = MaterialTheme.typography.bodyMedium,
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -1333,6 +1412,7 @@ fun ItemRow(
Text(
text = "",
style = MaterialTheme.typography.bodyMedium,
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -1348,6 +1428,7 @@ fun ItemRow(
MaterialTheme.typography.bodyMedium.copy(
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
),
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -1361,6 +1442,7 @@ fun ItemRow(
text = "✍️ Signed",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -1370,6 +1452,7 @@ fun ItemRow(
Text(
text = "",
style = MaterialTheme.typography.bodyMedium,
textAlign = textAlign,
modifier =
Modifier
.fillMaxWidth()
@@ -1710,6 +1793,14 @@ fun EditFieldDialog(
var dropdownOptions by remember { mutableStateOf(field.getDropdownOptions().joinToString(", ")) }
var currency by remember { mutableStateOf(field.getCurrency()) }
var expanded by remember { mutableStateOf(false) }
// Alignment state persisted per list/field in PreferencesManager
val context = LocalContext.current
val prefs = remember { PreferencesManager.getInstance(context) }
var selectedAlignment by remember {
mutableStateOf(
prefs.getColumnAlignments(field.listId)[field.id]?.lowercase() ?: "start",
)
}
AlertDialog(
onDismissRequest = onDismiss,
@@ -1925,6 +2016,23 @@ fun EditFieldDialog(
}
}
// Content alignment (single-select) using FilterChips to support current Material3 version
Text(
text = "Content Alignment",
style = MaterialTheme.typography.titleSmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val opts = listOf("start" to "Left", "center" to "Center", "end" to "Right")
opts.forEach { (value, label) ->
FilterChip(
selected = selectedAlignment == value,
onClick = { selectedAlignment = value },
label = { Text(label) },
leadingIcon = if (selectedAlignment == value) { { Icon(Icons.Default.Check, contentDescription = null) } } else null,
)
}
}
// Currency-specific options
if (selectedFieldType == "CURRENCY" || selectedFieldType == "PRICE") {
OutlinedTextField(
@@ -1996,6 +2104,14 @@ fun EditFieldDialog(
else -> ""
}
onUpdate(name.trim(), normalizedType, options)
// Persist alignment selection for this field
val current = prefs.getColumnAlignments(field.listId).toMutableMap()
current[field.id] = when (selectedAlignment.lowercase()) {
"center" -> "center"
"end", "right" -> "end"
else -> "start"
}
prefs.setColumnAlignments(field.listId, current)
},
enabled = name.isNotBlank() && (selectedFieldType !in listOf("DROPDOWN", "AUTOCOMPLETE") || dropdownOptions.isNotBlank()),
) {