From f3181116d4a15d33ec8633f596d371ff3ca7250a Mon Sep 17 00:00:00 2001 From: gabriel20xx Date: Wed, 12 Nov 2025 00:15:16 +0100 Subject: [PATCH] feat: add alignment property to fields and update related database and UI handling --- .../app/data/database/CollabTableDatabase.kt | 12 ++- .../com/collabtable/app/data/model/Field.kt | 2 + .../app/ui/screens/ListDetailScreen.kt | 80 ++++++++++--------- .../app/ui/screens/ListDetailViewModel.kt | 26 ++++++ CollabTableServer/src/db.ts | 11 +++ CollabTableServer/src/routes/fieldRoutes.ts | 12 +-- CollabTableServer/src/routes/syncRoutes.ts | 4 +- 7 files changed, 100 insertions(+), 47 deletions(-) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt index 7aef0c2..b8286d4 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt @@ -38,7 +38,7 @@ val migration2To3 = Item::class, ItemValue::class, ], - version = 4, + version = 5, exportSchema = false, ) abstract class CollabTableDatabase : RoomDatabase() { @@ -62,7 +62,7 @@ abstract class CollabTableDatabase : RoomDatabase() { context.applicationContext, CollabTableDatabase::class.java, "collab_table_database", - ).addMigrations(migration1To2, migration2To3, migration3To4) + ).addMigrations(migration1To2, migration2To3, migration3To4, migration4To5) .build() dbInstance = instance instance @@ -85,3 +85,11 @@ val migration3To4 = 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'") + } + } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Field.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Field.kt index c227c5d..346dabc 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Field.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Field.kt @@ -63,6 +63,8 @@ data class Field( val fieldType: String = "TEXT", // JSON string for dropdown options, currency symbol, etc. val fieldOptions: String = "", + // Content alignment for cells in this column: start | center | end + val alignment: String = "start", val order: Int, val createdAt: Long, val updatedAt: Long, diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt index ce0034a..d04ba7d 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt @@ -162,16 +162,15 @@ fun ListDetailScreen( val widthDp = (savedWidth ?: 150f).dp 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) stableFields.forEach { field -> - val a = savedAlign[field.id]?.lowercase() ?: "start" - columnAlignments[field.id] = - when (a) { - "center" -> "center" - "end", "right" -> "end" - else -> "start" - } + val raw = (field.alignment.ifBlank { savedAlign[field.id] ?: "start" }).lowercase() + columnAlignments[field.id] = when (raw) { + "center" -> "center" + "end", "right" -> "end" + else -> "start" + } } } @@ -556,7 +555,9 @@ fun ListDetailScreen( onHeaderClick = { showManageColumnsDialog = true }, alignment = columnAlignments[field.id] ?: "start", onAlignmentChange = { newAlign -> + // Update local state and persist via ViewModel + cache to prefs for backward-compat columnAlignments[field.id] = newAlign + viewModel.updateFieldAlignment(field.id, newAlign) prefs.setColumnAlignments(listId, columnAlignments.toMap()) }, ) @@ -609,16 +610,14 @@ fun ListDetailScreen( fields = stableFields, onDismiss = { showManageColumnsDialog = false - // Refresh alignments from preferences in case they changed while editing columns - val savedAlign = prefs.getColumnAlignments(listId) + // Refresh alignments from current fields (DB-backed) 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" - } + val a = field.alignment.lowercase().ifBlank { columnAlignments[field.id] ?: "start" } + columnAlignments[field.id] = when (a) { + "center" -> "center" + "end", "right" -> "end" + else -> "start" + } } }, onAddField = { name, fieldType, fieldOptions -> @@ -627,6 +626,9 @@ fun ListDetailScreen( onUpdateField = { fieldId, name, fieldType, fieldOptions -> viewModel.updateField(fieldId, name, fieldType, fieldOptions) }, + onUpdateAlignment = { fieldId, alignment -> + viewModel.updateFieldAlignment(fieldId, alignment) + }, onDeleteField = { fieldId -> viewModel.deleteField(fieldId) }, @@ -1852,7 +1854,7 @@ fun AddFieldDialog( fun EditFieldDialog( field: Field, onDismiss: () -> Unit, - onUpdate: (String, String, String) -> Unit, + onUpdate: (String, String, String, String) -> Unit, ) { var name by remember { mutableStateOf(field.name) } 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 if (selectedFieldType == "CURRENCY" || selectedFieldType == "PRICE") { OutlinedTextField( @@ -2148,6 +2132,24 @@ fun EditFieldDialog( 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 = { @@ -2172,7 +2174,7 @@ fun EditFieldDialog( "RATING" -> currency.trim().ifBlank { "5" } else -> "" } - onUpdate(name.trim(), normalizedType, options) + onUpdate(name.trim(), normalizedType, options, selectedAlignment) // Persist alignment selection for this field val current = prefs.getColumnAlignments(field.listId).toMutableMap() current[field.id] = @@ -2847,6 +2849,7 @@ fun ManageColumnsDialog( onDismiss: () -> Unit, onAddField: (String, String, String) -> Unit, onUpdateField: (String, String, String, String) -> Unit, + onUpdateAlignment: (String, String) -> Unit, onDeleteField: (String) -> Unit, onReorderFields: (List) -> Unit, ) { @@ -3034,8 +3037,9 @@ fun ManageColumnsDialog( EditFieldDialog( field = field, onDismiss = { fieldToEdit = null }, - onUpdate = { name, fieldType, fieldOptions -> + onUpdate = { name, fieldType, fieldOptions, selectedAlignment -> onUpdateField(field.id, name, fieldType, fieldOptions) + onUpdateAlignment(field.id, selectedAlignment) fieldToEdit = null }, ) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt index cdedc48..0f4f7f1 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt @@ -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() { viewModelScope.launch { val timestamp = System.currentTimeMillis() diff --git a/CollabTableServer/src/db.ts b/CollabTableServer/src/db.ts index f00b088..4ce2ef9 100644 --- a/CollabTableServer/src/db.ts +++ b/CollabTableServer/src/db.ts @@ -65,6 +65,8 @@ class SqliteAdapter implements DBAdapter { name TEXT NOT NULL, fieldType TEXT NOT NULL, fieldOptions TEXT, + alignment TEXT NOT NULL DEFAULT 'start', + alignment TEXT NOT NULL DEFAULT 'start', listId TEXT NOT NULL, "order" 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_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 { @@ -166,6 +174,7 @@ class PostgresAdapter implements DBAdapter { name TEXT NOT NULL, fieldType TEXT NOT NULL, fieldOptions TEXT, + alignment TEXT NOT NULL DEFAULT 'start', listId TEXT NOT NULL, "order" INTEGER 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_item_values_itemId ON item_values(itemId);`); 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'); } catch (e) { await client.query('ROLLBACK'); diff --git a/CollabTableServer/src/routes/fieldRoutes.ts b/CollabTableServer/src/routes/fieldRoutes.ts index 330216b..6fd7641 100644 --- a/CollabTableServer/src/routes/fieldRoutes.ts +++ b/CollabTableServer/src/routes/fieldRoutes.ts @@ -17,10 +17,10 @@ router.get('/list/:listId', async (req: Request, res: Response) => { // Create field router.post('/', async (req: Request, res: Response) => { 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( - 'INSERT INTO fields (id, name, fieldType, fieldOptions, listId, "order", createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', - [id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted ? 1 : 0] + 'INSERT INTO fields (id, name, fieldType, fieldOptions, alignment, listId, "order", createdAt, updatedAt, isDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [id, name, fieldType, fieldOptions, alignment ?? 'start', listId, order, createdAt, updatedAt, isDeleted ? 1 : 0] ); const field = await dbAdapter.queryOne('SELECT * FROM fields WHERE id = ?', [id]); 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) => { try { const updatedAt = Date.now(); - const { name, fieldType, fieldOptions, order } = req.body; + const { name, fieldType, fieldOptions, alignment, order } = req.body; const result = await dbAdapter.execute( - 'UPDATE fields SET name = ?, fieldType = ?, fieldOptions = ?, "order" = ?, updatedAt = ? WHERE id = ?', - [name, fieldType, fieldOptions, order, updatedAt, req.params.id] + 'UPDATE fields SET name = ?, fieldType = ?, fieldOptions = ?, alignment = ?, "order" = ?, updatedAt = ? WHERE id = ?', + [name, fieldType, fieldOptions, alignment ?? 'start', order, updatedAt, req.params.id] ); if (result.changes === 0) { diff --git a/CollabTableServer/src/routes/syncRoutes.ts b/CollabTableServer/src/routes/syncRoutes.ts index fa4cdb2..74b6390 100644 --- a/CollabTableServer/src/routes/syncRoutes.ts +++ b/CollabTableServer/src/routes/syncRoutes.ts @@ -59,7 +59,7 @@ setInterval(() => { // Helper function to get prepared statements (lazy initialization) // 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_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_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.fieldType, field.fieldOptions, + (field.alignment ?? 'start'), field.listId, field.order, field.createdAt, @@ -316,6 +317,7 @@ router.post('/sync', async (req: Request, res: Response) => { name: f.name, fieldType: f.fieldType ?? f.fieldtype, fieldOptions: f.fieldOptions ?? f.fieldoptions ?? '', + alignment: f.alignment ?? 'start', order: f.order, createdAt: toMillis(pick(f, 'createdAt', 'createdat')), updatedAt: toMillis(pick(f, 'updatedAt', 'updatedat')),