feat: add alignment property to fields and update related database and UI handling

This commit is contained in:
2025-11-12 00:15:16 +01:00
parent cb5880f861
commit f3181116d4
7 changed files with 100 additions and 47 deletions
@@ -38,7 +38,7 @@ val migration2To3 =
Item::class, Item::class,
ItemValue::class, ItemValue::class,
], ],
version = 4, version = 5,
exportSchema = false, exportSchema = false,
) )
abstract class CollabTableDatabase : RoomDatabase() { abstract class CollabTableDatabase : RoomDatabase() {
@@ -62,7 +62,7 @@ abstract class CollabTableDatabase : RoomDatabase() {
context.applicationContext, context.applicationContext,
CollabTableDatabase::class.java, CollabTableDatabase::class.java,
"collab_table_database", "collab_table_database",
).addMigrations(migration1To2, migration2To3, migration3To4) ).addMigrations(migration1To2, migration2To3, migration3To4, migration4To5)
.build() .build()
dbInstance = instance dbInstance = instance
instance instance
@@ -85,3 +85,11 @@ val migration3To4 =
database.execSQL("CREATE INDEX IF NOT EXISTS index_items_listId_createdAt ON items(listId, createdAt)") database.execSQL("CREATE INDEX IF NOT EXISTS index_items_listId_createdAt ON items(listId, createdAt)")
} }
} }
// Add alignment column to fields with default 'start'
val migration4To5 =
object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE fields ADD COLUMN alignment TEXT NOT NULL DEFAULT 'start'")
}
}
@@ -63,6 +63,8 @@ data class Field(
val fieldType: String = "TEXT", val fieldType: String = "TEXT",
// JSON string for dropdown options, currency symbol, etc. // JSON string for dropdown options, currency symbol, etc.
val fieldOptions: String = "", val fieldOptions: String = "",
// Content alignment for cells in this column: start | center | end
val alignment: String = "start",
val order: Int, val order: Int,
val createdAt: Long, val createdAt: Long,
val updatedAt: Long, val updatedAt: Long,
@@ -162,16 +162,15 @@ fun ListDetailScreen(
val widthDp = (savedWidth ?: 150f).dp val widthDp = (savedWidth ?: 150f).dp
fieldWidths[field.id] = widthDp fieldWidths[field.id] = widthDp
} }
// Load saved alignments for this listId // Initialize alignments from fields (server/DB source of truth); fallback to prefs if missing
val savedAlign = prefs.getColumnAlignments(listId) val savedAlign = prefs.getColumnAlignments(listId)
stableFields.forEach { field -> stableFields.forEach { field ->
val a = savedAlign[field.id]?.lowercase() ?: "start" val raw = (field.alignment.ifBlank { savedAlign[field.id] ?: "start" }).lowercase()
columnAlignments[field.id] = columnAlignments[field.id] = when (raw) {
when (a) { "center" -> "center"
"center" -> "center" "end", "right" -> "end"
"end", "right" -> "end" else -> "start"
else -> "start" }
}
} }
} }
@@ -556,7 +555,9 @@ fun ListDetailScreen(
onHeaderClick = { showManageColumnsDialog = true }, onHeaderClick = { showManageColumnsDialog = true },
alignment = columnAlignments[field.id] ?: "start", alignment = columnAlignments[field.id] ?: "start",
onAlignmentChange = { newAlign -> onAlignmentChange = { newAlign ->
// Update local state and persist via ViewModel + cache to prefs for backward-compat
columnAlignments[field.id] = newAlign columnAlignments[field.id] = newAlign
viewModel.updateFieldAlignment(field.id, newAlign)
prefs.setColumnAlignments(listId, columnAlignments.toMap()) prefs.setColumnAlignments(listId, columnAlignments.toMap())
}, },
) )
@@ -609,16 +610,14 @@ fun ListDetailScreen(
fields = stableFields, fields = stableFields,
onDismiss = { onDismiss = {
showManageColumnsDialog = false showManageColumnsDialog = false
// Refresh alignments from preferences in case they changed while editing columns // Refresh alignments from current fields (DB-backed)
val savedAlign = prefs.getColumnAlignments(listId)
stableFields.forEach { field -> stableFields.forEach { field ->
val a = savedAlign[field.id]?.lowercase() ?: columnAlignments[field.id] ?: "start" val a = field.alignment.lowercase().ifBlank { columnAlignments[field.id] ?: "start" }
columnAlignments[field.id] = columnAlignments[field.id] = when (a) {
when (a) { "center" -> "center"
"center" -> "center" "end", "right" -> "end"
"end", "right" -> "end" else -> "start"
else -> "start" }
}
} }
}, },
onAddField = { name, fieldType, fieldOptions -> onAddField = { name, fieldType, fieldOptions ->
@@ -627,6 +626,9 @@ fun ListDetailScreen(
onUpdateField = { fieldId, name, fieldType, fieldOptions -> onUpdateField = { fieldId, name, fieldType, fieldOptions ->
viewModel.updateField(fieldId, name, fieldType, fieldOptions) viewModel.updateField(fieldId, name, fieldType, fieldOptions)
}, },
onUpdateAlignment = { fieldId, alignment ->
viewModel.updateFieldAlignment(fieldId, alignment)
},
onDeleteField = { fieldId -> onDeleteField = { fieldId ->
viewModel.deleteField(fieldId) viewModel.deleteField(fieldId)
}, },
@@ -1852,7 +1854,7 @@ fun AddFieldDialog(
fun EditFieldDialog( fun EditFieldDialog(
field: Field, field: Field,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onUpdate: (String, String, String) -> Unit, onUpdate: (String, String, String, String) -> Unit,
) { ) {
var name by remember { mutableStateOf(field.name) } var name by remember { mutableStateOf(field.name) }
var selectedFieldType by remember { mutableStateOf(field.fieldType.uppercase()) } var selectedFieldType by remember { mutableStateOf(field.fieldType.uppercase()) }
@@ -2082,24 +2084,6 @@ fun EditFieldDialog(
} }
} }
// Content alignment (single-select) using a connected Material3 SegmentedButton group
Text(
text = "Content Alignment",
style = MaterialTheme.typography.titleSmall,
)
val alignmentOptions = listOf("start" to "Left", "center" to "Center", "end" to "Right")
SingleChoiceSegmentedButtonRow(modifier = Modifier.padding(top = 4.dp)) {
alignmentOptions.forEachIndexed { index, (value, label) ->
SegmentedButton(
selected = selectedAlignment == value,
onClick = { selectedAlignment = value },
shape = SegmentedButtonDefaults.itemShape(index = index, count = alignmentOptions.size),
icon = { if (selectedAlignment == value) Icon(Icons.Default.Check, contentDescription = null) },
label = { Text(label) },
)
}
}
// Currency-specific options // Currency-specific options
if (selectedFieldType == "CURRENCY" || selectedFieldType == "PRICE") { if (selectedFieldType == "CURRENCY" || selectedFieldType == "PRICE") {
OutlinedTextField( OutlinedTextField(
@@ -2148,6 +2132,24 @@ fun EditFieldDialog(
supportingText = { Text("Maximum number of stars (default: 5)") }, supportingText = { Text("Maximum number of stars (default: 5)") },
) )
} }
// Content alignment (single-select) using a connected Material3 SegmentedButton group
Text(
text = "Content Alignment",
style = MaterialTheme.typography.titleSmall,
)
val alignmentOptions = listOf("start" to "Left", "center" to "Center", "end" to "Right")
SingleChoiceSegmentedButtonRow(modifier = Modifier.padding(top = 4.dp)) {
alignmentOptions.forEachIndexed { index, (value, label) ->
SegmentedButton(
selected = selectedAlignment == value,
onClick = { selectedAlignment = value },
shape = SegmentedButtonDefaults.itemShape(index = index, count = alignmentOptions.size),
icon = { if (selectedAlignment == value) Icon(Icons.Default.Check, contentDescription = null) },
label = { Text(label) },
)
}
}
} }
}, },
confirmButton = { confirmButton = {
@@ -2172,7 +2174,7 @@ fun EditFieldDialog(
"RATING" -> currency.trim().ifBlank { "5" } "RATING" -> currency.trim().ifBlank { "5" }
else -> "" else -> ""
} }
onUpdate(name.trim(), normalizedType, options) onUpdate(name.trim(), normalizedType, options, selectedAlignment)
// Persist alignment selection for this field // Persist alignment selection for this field
val current = prefs.getColumnAlignments(field.listId).toMutableMap() val current = prefs.getColumnAlignments(field.listId).toMutableMap()
current[field.id] = current[field.id] =
@@ -2847,6 +2849,7 @@ fun ManageColumnsDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onAddField: (String, String, String) -> Unit, onAddField: (String, String, String) -> Unit,
onUpdateField: (String, String, String, String) -> Unit, onUpdateField: (String, String, String, String) -> Unit,
onUpdateAlignment: (String, String) -> Unit,
onDeleteField: (String) -> Unit, onDeleteField: (String) -> Unit,
onReorderFields: (List<Field>) -> Unit, onReorderFields: (List<Field>) -> Unit,
) { ) {
@@ -3034,8 +3037,9 @@ fun ManageColumnsDialog(
EditFieldDialog( EditFieldDialog(
field = field, field = field,
onDismiss = { fieldToEdit = null }, onDismiss = { fieldToEdit = null },
onUpdate = { name, fieldType, fieldOptions -> onUpdate = { name, fieldType, fieldOptions, selectedAlignment ->
onUpdateField(field.id, name, fieldType, fieldOptions) onUpdateField(field.id, name, fieldType, fieldOptions)
onUpdateAlignment(field.id, selectedAlignment)
fieldToEdit = null fieldToEdit = null
}, },
) )
@@ -254,6 +254,32 @@ class ListDetailViewModel(
} }
} }
fun updateFieldAlignment(fieldId: String, alignment: String) {
viewModelScope.launch {
val field = database.fieldDao().getFieldById(fieldId)
if (field != null) {
val ts = System.currentTimeMillis()
val normalized = when (alignment.lowercase()) {
"center" -> "center"
"end", "right" -> "end"
else -> "start"
}
database.withTransaction {
database.fieldDao().updateField(
field.copy(
alignment = normalized,
updatedAt = ts,
),
)
database.listDao().getListById(listId)?.let { l ->
database.listDao().updateList(l.copy(updatedAt = ts))
}
}
performSync()
}
}
}
fun addItem() { fun addItem() {
viewModelScope.launch { viewModelScope.launch {
val timestamp = System.currentTimeMillis() val timestamp = System.currentTimeMillis()
+11
View File
@@ -65,6 +65,8 @@ class SqliteAdapter implements DBAdapter {
name TEXT NOT NULL, name TEXT NOT NULL,
fieldType TEXT NOT NULL, fieldType TEXT NOT NULL,
fieldOptions TEXT, fieldOptions TEXT,
alignment TEXT NOT NULL DEFAULT 'start',
alignment TEXT NOT NULL DEFAULT 'start',
listId TEXT NOT NULL, listId TEXT NOT NULL,
"order" INTEGER NOT NULL, "order" INTEGER NOT NULL,
createdAt INTEGER NOT NULL, createdAt INTEGER NOT NULL,
@@ -97,6 +99,12 @@ class SqliteAdapter implements DBAdapter {
CREATE INDEX IF NOT EXISTS idx_item_values_itemId ON item_values(itemId); CREATE INDEX IF NOT EXISTS idx_item_values_itemId ON item_values(itemId);
CREATE INDEX IF NOT EXISTS idx_item_values_fieldId ON item_values(fieldId); CREATE INDEX IF NOT EXISTS idx_item_values_fieldId ON item_values(fieldId);
`); `);
// Attempt to add alignment column if upgrading an existing DB (ignore error if exists)
try {
this.db.exec(`ALTER TABLE fields ADD COLUMN alignment TEXT NOT NULL DEFAULT 'start'`);
} catch (e) {
// Column may already exist; ignore
}
} }
async queryAll(sql: string, params: Param[] = []): Promise<any[]> { async queryAll(sql: string, params: Param[] = []): Promise<any[]> {
@@ -166,6 +174,7 @@ class PostgresAdapter implements DBAdapter {
name TEXT NOT NULL, name TEXT NOT NULL,
fieldType TEXT NOT NULL, fieldType TEXT NOT NULL,
fieldOptions TEXT, fieldOptions TEXT,
alignment TEXT NOT NULL DEFAULT 'start',
listId TEXT NOT NULL, listId TEXT NOT NULL,
"order" INTEGER NOT NULL, "order" INTEGER NOT NULL,
createdAt BIGINT NOT NULL, createdAt BIGINT NOT NULL,
@@ -199,6 +208,8 @@ class PostgresAdapter implements DBAdapter {
await client.query(`CREATE INDEX IF NOT EXISTS idx_items_listId ON items(listId);`); await client.query(`CREATE INDEX IF NOT EXISTS idx_items_listId ON items(listId);`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_item_values_itemId ON item_values(itemId);`); await client.query(`CREATE INDEX IF NOT EXISTS idx_item_values_itemId ON item_values(itemId);`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_item_values_fieldId ON item_values(fieldId);`); await client.query(`CREATE INDEX IF NOT EXISTS idx_item_values_fieldId ON item_values(fieldId);`);
// Migrate existing DBs: ensure alignment column exists
await client.query(`ALTER TABLE fields ADD COLUMN IF NOT EXISTS alignment TEXT NOT NULL DEFAULT 'start';`);
await client.query('COMMIT'); await client.query('COMMIT');
} catch (e) { } catch (e) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
+6 -6
View File
@@ -17,10 +17,10 @@ router.get('/list/:listId', async (req: Request, res: Response) => {
// Create field // Create field
router.post('/', async (req: Request, res: Response) => { router.post('/', async (req: Request, res: Response) => {
try { try {
const { id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted } = req.body; const { id, name, fieldType, fieldOptions, alignment, listId, order, createdAt, updatedAt, isDeleted } = req.body;
await dbAdapter.execute( await dbAdapter.execute(
'INSERT INTO fields (id, name, fieldType, fieldOptions, listId, "order", createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', 'INSERT INTO fields (id, name, fieldType, fieldOptions, alignment, listId, "order", createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted ? 1 : 0] [id, name, fieldType, fieldOptions, alignment ?? 'start', listId, order, createdAt, updatedAt, isDeleted ? 1 : 0]
); );
const field = await dbAdapter.queryOne('SELECT * FROM fields WHERE id = ?', [id]); const field = await dbAdapter.queryOne('SELECT * FROM fields WHERE id = ?', [id]);
res.status(201).json({ ...(field as any), isDeleted: !!(field as any).isDeleted }); res.status(201).json({ ...(field as any), isDeleted: !!(field as any).isDeleted });
@@ -33,10 +33,10 @@ router.post('/', async (req: Request, res: Response) => {
router.put('/:id', async (req: Request, res: Response) => { router.put('/:id', async (req: Request, res: Response) => {
try { try {
const updatedAt = Date.now(); const updatedAt = Date.now();
const { name, fieldType, fieldOptions, order } = req.body; const { name, fieldType, fieldOptions, alignment, order } = req.body;
const result = await dbAdapter.execute( const result = await dbAdapter.execute(
'UPDATE fields SET name = ?, fieldType = ?, fieldOptions = ?, "order" = ?, updatedAt = ? WHERE id = ?', 'UPDATE fields SET name = ?, fieldType = ?, fieldOptions = ?, alignment = ?, "order" = ?, updatedAt = ? WHERE id = ?',
[name, fieldType, fieldOptions, order, updatedAt, req.params.id] [name, fieldType, fieldOptions, alignment ?? 'start', order, updatedAt, req.params.id]
); );
if (result.changes === 0) { if (result.changes === 0) {
+3 -1
View File
@@ -59,7 +59,7 @@ setInterval(() => {
// Helper function to get prepared statements (lazy initialization) // Helper function to get prepared statements (lazy initialization)
// We'll perform upserts with positional params for cross-DB portability // We'll perform upserts with positional params for cross-DB portability
const UPSERT_LIST = 'INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted'; const UPSERT_LIST = 'INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted';
const UPSERT_FIELD = 'INSERT INTO fields (id, name, fieldType, fieldOptions, listId, "order", createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, fieldType = EXCLUDED.fieldType, fieldOptions = EXCLUDED.fieldOptions, "order" = EXCLUDED."order", updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted'; const UPSERT_FIELD = 'INSERT INTO fields (id, name, fieldType, fieldOptions, alignment, listId, "order", createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, fieldType = EXCLUDED.fieldType, fieldOptions = EXCLUDED.fieldOptions, alignment = EXCLUDED.alignment, "order" = EXCLUDED."order", updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted';
const UPSERT_ITEM = 'INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted'; const UPSERT_ITEM = 'INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET updatedAt = EXCLUDED.updatedAt, isDeleted = EXCLUDED.isDeleted';
const UPSERT_ITEM_VALUE = 'INSERT INTO item_values (id, itemId, fieldId, value, updatedAt) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET value = EXCLUDED.value, updatedAt = EXCLUDED.updatedAt'; const UPSERT_ITEM_VALUE = 'INSERT INTO item_values (id, itemId, fieldId, value, updatedAt) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET value = EXCLUDED.value, updatedAt = EXCLUDED.updatedAt';
@@ -144,6 +144,7 @@ router.post('/sync', async (req: Request, res: Response) => {
field.name, field.name,
field.fieldType, field.fieldType,
field.fieldOptions, field.fieldOptions,
(field.alignment ?? 'start'),
field.listId, field.listId,
field.order, field.order,
field.createdAt, field.createdAt,
@@ -316,6 +317,7 @@ router.post('/sync', async (req: Request, res: Response) => {
name: f.name, name: f.name,
fieldType: f.fieldType ?? f.fieldtype, fieldType: f.fieldType ?? f.fieldtype,
fieldOptions: f.fieldOptions ?? f.fieldoptions ?? '', fieldOptions: f.fieldOptions ?? f.fieldoptions ?? '',
alignment: f.alignment ?? 'start',
order: f.order, order: f.order,
createdAt: toMillis(pick(f, 'createdAt', 'createdat')), createdAt: toMillis(pick(f, 'createdAt', 'createdat')),
updatedAt: toMillis(pick(f, 'updatedAt', 'updatedat')), updatedAt: toMillis(pick(f, 'updatedAt', 'updatedat')),