Add multi-photo support for boxes, room move feature, and fix camera orientation

- Multi-photo for boxes: BoxPhoto entity/DAO/repo/usecases mirroring ItemPhoto pattern,
  DB migration 6→7 copies existing photoPath into new box_photos table
- Room move: RoomDetailViewModel/Screen wired to MoveDestinationSheet (maxDepth=1)
- EditablePhoto moved to presentation/common for shared use by item and box screens
- PhotoManager: pixel rotation based on EXIF orientation, compressFromPath for camera files
- CameraScreen: OrientationEventListener continuously updates ImageCapture.targetRotation
  so CameraX writes correct EXIF for both portrait and landscape shots (fixes Sony Xperia 10 IV)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Quiver 2026-04-06 01:14:50 +02:00
parent d1d64c3854
commit 9aee8c786c
28 changed files with 2029 additions and 271 deletions

View file

@ -0,0 +1,698 @@
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "109eee80a55c4e29197e9bed9aca17fb",
"entities": [
{
"tableName": "locations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `photoPath` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "photoPath",
"columnName": "photoPath",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_locations_name",
"unique": false,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_locations_name` ON `${TABLE_NAME}` (`name`)"
}
]
},
{
"tableName": "storage_rooms",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `locationId` INTEGER NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `photoPath` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, FOREIGN KEY(`locationId`) REFERENCES `locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "locationId",
"columnName": "locationId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "photoPath",
"columnName": "photoPath",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_storage_rooms_locationId",
"unique": false,
"columnNames": [
"locationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_storage_rooms_locationId` ON `${TABLE_NAME}` (`locationId`)"
},
{
"name": "index_storage_rooms_name",
"unique": false,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_storage_rooms_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": [
{
"table": "locations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"locationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "boxes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `roomId` INTEGER NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `qrCode` TEXT, `color` TEXT, `photoPath` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, FOREIGN KEY(`roomId`) REFERENCES `storage_rooms`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "roomId",
"columnName": "roomId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "qrCode",
"columnName": "qrCode",
"affinity": "TEXT"
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "TEXT"
},
{
"fieldPath": "photoPath",
"columnName": "photoPath",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_boxes_roomId",
"unique": false,
"columnNames": [
"roomId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_boxes_roomId` ON `${TABLE_NAME}` (`roomId`)"
},
{
"name": "index_boxes_qrCode",
"unique": true,
"columnNames": [
"qrCode"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_boxes_qrCode` ON `${TABLE_NAME}` (`qrCode`)"
}
],
"foreignKeys": [
{
"table": "storage_rooms",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"roomId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "box_photos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `boxId` INTEGER NOT NULL, `filePath` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`boxId`) REFERENCES `boxes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "boxId",
"columnName": "boxId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filePath",
"columnName": "filePath",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_box_photos_boxId",
"unique": false,
"columnNames": [
"boxId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_box_photos_boxId` ON `${TABLE_NAME}` (`boxId`)"
}
],
"foreignKeys": [
{
"table": "boxes",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"boxId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "items",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `boxId` INTEGER, `roomId` INTEGER, `locationId` INTEGER, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `quantity` INTEGER NOT NULL, `value` REAL, `unit` TEXT, `notes` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, FOREIGN KEY(`boxId`) REFERENCES `boxes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`roomId`) REFERENCES `storage_rooms`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`locationId`) REFERENCES `locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "boxId",
"columnName": "boxId",
"affinity": "INTEGER"
},
{
"fieldPath": "roomId",
"columnName": "roomId",
"affinity": "INTEGER"
},
{
"fieldPath": "locationId",
"columnName": "locationId",
"affinity": "INTEGER"
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "quantity",
"columnName": "quantity",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "REAL"
},
{
"fieldPath": "unit",
"columnName": "unit",
"affinity": "TEXT"
},
{
"fieldPath": "notes",
"columnName": "notes",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_items_boxId",
"unique": false,
"columnNames": [
"boxId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_items_boxId` ON `${TABLE_NAME}` (`boxId`)"
},
{
"name": "index_items_roomId",
"unique": false,
"columnNames": [
"roomId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_items_roomId` ON `${TABLE_NAME}` (`roomId`)"
},
{
"name": "index_items_locationId",
"unique": false,
"columnNames": [
"locationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_items_locationId` ON `${TABLE_NAME}` (`locationId`)"
},
{
"name": "index_items_name",
"unique": false,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_items_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": [
{
"table": "boxes",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"boxId"
],
"referencedColumns": [
"id"
]
},
{
"table": "storage_rooms",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"roomId"
],
"referencedColumns": [
"id"
]
},
{
"table": "locations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"locationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "item_photos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `itemId` INTEGER NOT NULL, `filePath` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`itemId`) REFERENCES `items`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filePath",
"columnName": "filePath",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_item_photos_itemId",
"unique": false,
"columnNames": [
"itemId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_item_photos_itemId` ON `${TABLE_NAME}` (`itemId`)"
}
],
"foreignKeys": [
{
"table": "items",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"itemId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "tags",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `colorHex` TEXT NOT NULL, `createdAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "colorHex",
"columnName": "colorHex",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_tags_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tags_name` ON `${TABLE_NAME}` (`name`)"
}
]
},
{
"tableName": "item_tags",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`itemId` INTEGER NOT NULL, `tagId` INTEGER NOT NULL, PRIMARY KEY(`itemId`, `tagId`), FOREIGN KEY(`itemId`) REFERENCES `items`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`tagId`) REFERENCES `tags`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tagId",
"columnName": "tagId",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"itemId",
"tagId"
]
},
"indices": [
{
"name": "index_item_tags_itemId",
"unique": false,
"columnNames": [
"itemId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_item_tags_itemId` ON `${TABLE_NAME}` (`itemId`)"
},
{
"name": "index_item_tags_tagId",
"unique": false,
"columnNames": [
"tagId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_item_tags_tagId` ON `${TABLE_NAME}` (`tagId`)"
}
],
"foreignKeys": [
{
"table": "items",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"itemId"
],
"referencedColumns": [
"id"
]
},
{
"table": "tags",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"tagId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "items_fts",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`name` TEXT NOT NULL, `description` TEXT NOT NULL, `notes` TEXT NOT NULL, content=`items`)",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notes",
"columnName": "notes",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": []
},
"ftsVersion": "FTS4",
"ftsOptions": {
"tokenizer": "simple",
"tokenizerArgs": [],
"contentTable": "items",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [],
"prefixSizes": [],
"preferredOrder": "ASC"
},
"contentSyncTriggers": [
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_items_fts_BEFORE_UPDATE BEFORE UPDATE ON `items` BEGIN DELETE FROM `items_fts` WHERE `docid`=OLD.`rowid`; END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_items_fts_BEFORE_DELETE BEFORE DELETE ON `items` BEGIN DELETE FROM `items_fts` WHERE `docid`=OLD.`rowid`; END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_items_fts_AFTER_UPDATE AFTER UPDATE ON `items` BEGIN INSERT INTO `items_fts`(`docid`, `name`, `description`, `notes`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`description`, NEW.`notes`); END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_items_fts_AFTER_INSERT AFTER INSERT ON `items` BEGIN INSERT INTO `items_fts`(`docid`, `name`, `description`, `notes`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`description`, NEW.`notes`); END"
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '109eee80a55c4e29197e9bed9aca17fb')"
]
}
}

View file

@ -0,0 +1,28 @@
package com.roundingmobile.boi.data.local.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.roundingmobile.boi.data.local.entity.BoxPhotoEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface BoxPhotoDao {
@Query("SELECT * FROM box_photos WHERE boxId = :boxId ORDER BY sortOrder ASC, createdAt ASC")
fun getPhotosByBox(boxId: Long): Flow<List<BoxPhotoEntity>>
@Query("SELECT * FROM box_photos WHERE boxId = :boxId ORDER BY sortOrder ASC, createdAt ASC")
suspend fun getPhotosByBoxOnce(boxId: Long): List<BoxPhotoEntity>
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(photo: BoxPhotoEntity): Long
@Delete
suspend fun delete(photo: BoxPhotoEntity)
@Query("DELETE FROM box_photos WHERE boxId = :boxId")
suspend fun deleteAllPhotosForBox(boxId: Long)
}

View file

@ -6,6 +6,7 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.roundingmobile.boi.data.local.dao.BackupDao
import com.roundingmobile.boi.data.local.dao.BoxDao
import com.roundingmobile.boi.data.local.dao.BoxPhotoDao
import com.roundingmobile.boi.data.local.dao.ItemDao
import com.roundingmobile.boi.data.local.dao.ItemPhotoDao
import com.roundingmobile.boi.data.local.dao.LocationDao
@ -13,6 +14,7 @@ import com.roundingmobile.boi.data.local.dao.SearchDao
import com.roundingmobile.boi.data.local.dao.StorageRoomDao
import com.roundingmobile.boi.data.local.dao.TagDao
import com.roundingmobile.boi.data.local.entity.BoxEntity
import com.roundingmobile.boi.data.local.entity.BoxPhotoEntity
import com.roundingmobile.boi.data.local.entity.ItemEntity
import com.roundingmobile.boi.data.local.entity.ItemFtsEntity
import com.roundingmobile.boi.data.local.entity.ItemPhotoEntity
@ -41,13 +43,14 @@ import com.roundingmobile.boi.data.local.entity.TagEntity
LocationEntity::class,
StorageRoomEntity::class,
BoxEntity::class,
BoxPhotoEntity::class,
ItemEntity::class,
ItemPhotoEntity::class,
TagEntity::class,
ItemTagEntity::class,
ItemFtsEntity::class,
],
version = 6,
version = 7,
exportSchema = true,
)
abstract class AppDatabase : RoomDatabase() {
@ -56,6 +59,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun locationDao(): LocationDao
abstract fun storageRoomDao(): StorageRoomDao
abstract fun boxDao(): BoxDao
abstract fun boxPhotoDao(): BoxPhotoDao
abstract fun itemDao(): ItemDao
abstract fun itemPhotoDao(): ItemPhotoDao
abstract fun tagDao(): TagDao
@ -177,5 +181,38 @@ abstract class AppDatabase : RoomDatabase() {
db.execSQL("CREATE TRIGGER IF NOT EXISTS `room_fts_content_sync_items_fts_AFTER_INSERT` AFTER INSERT ON `items` BEGIN INSERT INTO `items_fts`(`docid`, `name`, `description`, `notes`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`description`, NEW.`notes`); END")
}
}
/**
* Fas Multi-foto-stöd för lådor.
*
* Skapar tabellen box_photos (spegel av item_photos) och kopierar
* befintliga enkelfotosökvägar från boxes.photoPath.
* boxes.photoPath bevaras i schemat men används inte längre av appen.
*/
val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE IF NOT EXISTS `box_photos` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`boxId` INTEGER NOT NULL,
`filePath` TEXT NOT NULL,
`sortOrder` INTEGER NOT NULL DEFAULT 0,
`createdAt` INTEGER NOT NULL,
FOREIGN KEY(`boxId`) REFERENCES `boxes`(`id`)
ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent())
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_box_photos_boxId` ON `box_photos` (`boxId`)"
)
// Kopiera befintliga enkelfotosökvägar till den nya tabellen
db.execSQL("""
INSERT INTO `box_photos` (`boxId`, `filePath`, `sortOrder`, `createdAt`)
SELECT `id`, `photoPath`, 0, ${System.currentTimeMillis()}
FROM `boxes`
WHERE `photoPath` IS NOT NULL
""".trimIndent())
}
}
}
}

View file

@ -0,0 +1,27 @@
package com.roundingmobile.boi.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "box_photos",
foreignKeys = [
ForeignKey(
entity = BoxEntity::class,
parentColumns = ["id"],
childColumns = ["boxId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("boxId")],
)
data class BoxPhotoEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val boxId: Long,
val filePath: String,
val sortOrder: Int = 0,
val createdAt: Long = System.currentTimeMillis(),
)

View file

@ -0,0 +1,42 @@
package com.roundingmobile.boi.data.repository
import com.roundingmobile.boi.data.local.dao.BoxPhotoDao
import com.roundingmobile.boi.domain.model.BoxPhoto
import com.roundingmobile.boi.domain.repository.BoxPhotoRepository
import com.roundingmobile.boi.util.CoroutineDispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import javax.inject.Inject
class BoxPhotoRepositoryImpl @Inject constructor(
private val boxPhotoDao: BoxPhotoDao,
private val dispatchers: CoroutineDispatchers,
) : BoxPhotoRepository {
override fun getPhotosByBox(boxId: Long): Flow<List<BoxPhoto>> =
boxPhotoDao.getPhotosByBox(boxId)
.map { entities -> entities.map { it.toDomain() } }
.flowOn(dispatchers.io)
override suspend fun getPhotosByBoxOnce(boxId: Long): List<BoxPhoto> =
withContext(dispatchers.io) {
boxPhotoDao.getPhotosByBoxOnce(boxId).map { it.toDomain() }
}
override suspend fun addPhoto(photo: BoxPhoto): Result<Long> =
withContext(dispatchers.io) {
runCatching { boxPhotoDao.insert(photo.toEntity()) }
}
override suspend fun deletePhoto(photo: BoxPhoto): Result<Unit> =
withContext(dispatchers.io) {
runCatching { boxPhotoDao.delete(photo.toEntity()) }
}
override suspend fun deleteAllPhotosForBox(boxId: Long): Result<Unit> =
withContext(dispatchers.io) {
runCatching { boxPhotoDao.deleteAllPhotosForBox(boxId) }
}
}

View file

@ -1,6 +1,7 @@
package com.roundingmobile.boi.data.repository
import com.roundingmobile.boi.data.local.entity.BoxEntity
import com.roundingmobile.boi.data.local.entity.BoxPhotoEntity
import com.roundingmobile.boi.data.local.entity.ItemEntity
import com.roundingmobile.boi.data.local.entity.ItemPhotoEntity
import com.roundingmobile.boi.data.local.entity.LocationEntity
@ -8,6 +9,7 @@ import com.roundingmobile.boi.data.local.entity.LocationWithCounts
import com.roundingmobile.boi.data.local.entity.StorageRoomEntity
import com.roundingmobile.boi.data.local.entity.TagEntity
import com.roundingmobile.boi.domain.model.Box
import com.roundingmobile.boi.domain.model.BoxPhoto
import com.roundingmobile.boi.domain.model.Item
import com.roundingmobile.boi.domain.model.ItemPhoto
import com.roundingmobile.boi.domain.model.Location
@ -159,6 +161,27 @@ internal fun ItemPhoto.toEntity(): ItemPhotoEntity {
)
}
// ── BoxPhoto ──────────────────────────────────────────────────────────────────
internal fun BoxPhotoEntity.toDomain() = BoxPhoto(
id = id,
boxId = boxId,
filePath = filePath,
sortOrder = sortOrder,
createdAt = createdAt,
)
internal fun BoxPhoto.toEntity(): BoxPhotoEntity {
val now = System.currentTimeMillis()
return BoxPhotoEntity(
id = id,
boxId = boxId,
filePath = filePath,
sortOrder = sortOrder,
createdAt = if (createdAt == 0L) now else createdAt,
)
}
// ── Tag ───────────────────────────────────────────────────────────────────────
internal fun TagEntity.toDomain() = Tag(

View file

@ -6,6 +6,7 @@ import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.roundingmobile.boi.data.local.dao.BackupDao
import com.roundingmobile.boi.data.local.dao.BoxDao
import com.roundingmobile.boi.data.local.dao.BoxPhotoDao
import com.roundingmobile.boi.data.local.dao.ItemDao
import com.roundingmobile.boi.data.local.dao.ItemPhotoDao
import com.roundingmobile.boi.data.local.dao.LocationDao
@ -68,6 +69,7 @@ object DatabaseModule {
AppDatabase.MIGRATION_3_4,
AppDatabase.MIGRATION_4_5,
AppDatabase.MIGRATION_5_6,
AppDatabase.MIGRATION_6_7,
)
.build()
}
@ -122,6 +124,9 @@ object DatabaseModule {
@Provides
fun provideBoxDao(db: AppDatabase): BoxDao = db.boxDao()
@Provides
fun provideBoxPhotoDao(db: AppDatabase): BoxPhotoDao = db.boxPhotoDao()
@Provides
fun provideItemDao(db: AppDatabase): ItemDao = db.itemDao()

View file

@ -1,6 +1,7 @@
package com.roundingmobile.boi.di
import com.roundingmobile.boi.data.repository.BiometricRepositoryImpl
import com.roundingmobile.boi.data.repository.BoxPhotoRepositoryImpl
import com.roundingmobile.boi.data.repository.BoxRepositoryImpl
import com.roundingmobile.boi.data.repository.ItemPhotoRepositoryImpl
import com.roundingmobile.boi.data.repository.ItemRepositoryImpl
@ -10,6 +11,7 @@ import com.roundingmobile.boi.data.repository.SearchRepositoryImpl
import com.roundingmobile.boi.data.repository.StorageRoomRepositoryImpl
import com.roundingmobile.boi.data.repository.TagRepositoryImpl
import com.roundingmobile.boi.domain.repository.BiometricRepository
import com.roundingmobile.boi.domain.repository.BoxPhotoRepository
import com.roundingmobile.boi.domain.repository.BoxRepository
import com.roundingmobile.boi.domain.repository.ItemPhotoRepository
import com.roundingmobile.boi.domain.repository.ItemRepository
@ -76,6 +78,12 @@ abstract class RepositoryModule {
impl: BoxRepositoryImpl,
): BoxRepository
@Binds
@Singleton
abstract fun bindBoxPhotoRepository(
impl: BoxPhotoRepositoryImpl,
): BoxPhotoRepository
@Binds
@Singleton
abstract fun bindItemRepository(

View file

@ -0,0 +1,9 @@
package com.roundingmobile.boi.domain.model
data class BoxPhoto(
val id: Long = 0,
val boxId: Long,
val filePath: String,
val sortOrder: Int = 0,
val createdAt: Long = 0,
)

View file

@ -0,0 +1,12 @@
package com.roundingmobile.boi.domain.repository
import com.roundingmobile.boi.domain.model.BoxPhoto
import kotlinx.coroutines.flow.Flow
interface BoxPhotoRepository {
fun getPhotosByBox(boxId: Long): Flow<List<BoxPhoto>>
suspend fun getPhotosByBoxOnce(boxId: Long): List<BoxPhoto>
suspend fun addPhoto(photo: BoxPhoto): Result<Long>
suspend fun deletePhoto(photo: BoxPhoto): Result<Unit>
suspend fun deleteAllPhotosForBox(boxId: Long): Result<Unit>
}

View file

@ -0,0 +1,12 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.BoxPhoto
import com.roundingmobile.boi.domain.repository.BoxPhotoRepository
import javax.inject.Inject
class AddBoxPhotoUseCase @Inject constructor(
private val boxPhotoRepository: BoxPhotoRepository,
) {
suspend operator fun invoke(photo: BoxPhoto): Result<Long> =
boxPhotoRepository.addPhoto(photo)
}

View file

@ -0,0 +1,12 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.BoxPhoto
import com.roundingmobile.boi.domain.repository.BoxPhotoRepository
import javax.inject.Inject
class DeleteBoxPhotoUseCase @Inject constructor(
private val boxPhotoRepository: BoxPhotoRepository,
) {
suspend operator fun invoke(photo: BoxPhoto): Result<Unit> =
boxPhotoRepository.deletePhoto(photo)
}

View file

@ -0,0 +1,13 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.BoxPhoto
import com.roundingmobile.boi.domain.repository.BoxPhotoRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class GetPhotosByBoxUseCase @Inject constructor(
private val boxPhotoRepository: BoxPhotoRepository,
) {
operator fun invoke(boxId: Long): Flow<List<BoxPhoto>> =
boxPhotoRepository.getPhotosByBox(boxId)
}

View file

@ -17,6 +17,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
@ -27,8 +28,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
@ -39,6 +42,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
@ -47,6 +51,7 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -68,8 +73,11 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.roundingmobile.boi.presentation.common.BackNavigationIcon
import com.roundingmobile.boi.presentation.common.EditablePhoto
import kotlinx.coroutines.launch
// ── Entry-point ────────────────────────────────────────────────────────────────
@Composable
fun AddEditBoxScreen(
onNavigateBack: () -> Unit,
@ -98,7 +106,7 @@ fun AddEditBoxScreen(
name = viewModel.name,
description = viewModel.description,
selectedColor = viewModel.color,
photoPath = viewModel.photoPath,
photos = viewModel.photos,
isSaveEnabled = viewModel.isSaveEnabled,
isSaving = uiState is AddEditBoxUiState.Saving,
hasUnsavedChanges = viewModel.hasUnsavedChanges,
@ -106,8 +114,10 @@ fun AddEditBoxScreen(
onNameChange = viewModel::onNameChange,
onDescriptionChange = viewModel::onDescriptionChange,
onColorChange = viewModel::onColorChange,
onPhotoSelected = { uri -> viewModel.setPhotoFromUri(uri) },
onPhotoRemoved = viewModel::removePhoto,
onAddPhotoFromUri = viewModel::addPhotoFromUri,
onAddPhotoFromCameraPath = viewModel::addPhotoFromCameraPath,
onRemovePhoto = viewModel::removePhoto,
onReorderPhoto = viewModel::reorderPhoto,
onSave = viewModel::save,
onNavigateBack = {
viewModel.onCancel()
@ -117,6 +127,8 @@ fun AddEditBoxScreen(
)
}
// ── Huvudinnehåll ──────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AddEditBoxContent(
@ -124,7 +136,7 @@ private fun AddEditBoxContent(
name: String,
description: String,
selectedColor: String,
photoPath: String?,
photos: List<EditablePhoto>,
isSaveEnabled: Boolean,
isSaving: Boolean,
hasUnsavedChanges: Boolean,
@ -132,8 +144,10 @@ private fun AddEditBoxContent(
onNameChange: (String) -> Unit,
onDescriptionChange: (String) -> Unit,
onColorChange: (String) -> Unit,
onPhotoSelected: (Uri) -> Unit,
onPhotoRemoved: () -> Unit,
onAddPhotoFromUri: (Uri) -> Unit,
onAddPhotoFromCameraPath: (String) -> Unit,
onRemovePhoto: (Int) -> Unit,
onReorderPhoto: (Int, Int) -> Unit,
onSave: () -> Unit,
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier,
@ -176,11 +190,13 @@ private fun AddEditBoxContent(
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
BoxPhotoSection(
photoPath = photoPath,
BoxPhotoGrid(
photos = photos,
snackbarHostState = snackbarHostState,
onPhotoSelected = onPhotoSelected,
onPhotoRemoved = onPhotoRemoved,
onAddPhotoFromUri = onAddPhotoFromUri,
onAddPhotoFromCameraPath = onAddPhotoFromCameraPath,
onRemovePhoto = onRemovePhoto,
onReorderPhoto = onReorderPhoto,
)
OutlinedTextField(
@ -231,6 +247,273 @@ private fun AddEditBoxContent(
}
}
// ── Fotosektion med kamera/galleri ─────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BoxPhotoGrid(
photos: List<EditablePhoto>,
snackbarHostState: SnackbarHostState,
onAddPhotoFromUri: (Uri) -> Unit,
onAddPhotoFromCameraPath: (String) -> Unit,
onRemovePhoto: (Int) -> Unit,
onReorderPhoto: (Int, Int) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val activity = context as? Activity
val scope = rememberCoroutineScope()
var showPhotoSheet by rememberSaveable { mutableStateOf(false) }
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
var pendingCameraPath by remember { mutableStateOf<String?>(null) }
var showCameraPermissionDialog by rememberSaveable { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val galleryLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri -> uri?.let(onAddPhotoFromUri) }
val cameraLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.TakePicture()
) { success ->
if (success) {
pendingCameraPath?.let { onAddPhotoFromCameraPath(it) }
}
pendingCameraUri = null
pendingCameraPath = null
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
pendingCameraUri?.let { cameraLauncher.launch(it) }
} else {
val permanentlyDenied = activity?.let {
!ActivityCompat.shouldShowRequestPermissionRationale(it, Manifest.permission.CAMERA)
} ?: false
if (permanentlyDenied) {
showCameraPermissionDialog = true
} else {
scope.launch {
snackbarHostState.showSnackbar("Kamerabehörighet krävs för att ta foto")
}
}
}
}
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = "Foton (${photos.size})",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
photos.forEachIndexed { index, photo ->
BoxPhotoItem(
photo = photo,
index = index,
totalCount = photos.size,
onRemove = { onRemovePhoto(index) },
onMoveLeft = { if (index > 0) onReorderPhoto(index, index - 1) },
onMoveRight = { if (index < photos.size - 1) onReorderPhoto(index, index + 1) },
modifier = Modifier.size(88.dp),
)
}
AddBoxPhotoButton(
onClick = { showPhotoSheet = true },
modifier = Modifier.size(88.dp),
)
}
}
if (showPhotoSheet) {
ModalBottomSheet(
onDismissRequest = { showPhotoSheet = false },
sheetState = sheetState,
) {
Column(modifier = Modifier.padding(bottom = 32.dp)) {
OutlinedButton(
onClick = {
showPhotoSheet = false
galleryLauncher.launch("image/*")
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
) {
Icon(
Icons.Filled.PhotoLibrary,
contentDescription = null,
modifier = Modifier.padding(end = 12.dp),
)
Text("Välj från galleri", modifier = Modifier.weight(1f))
}
OutlinedButton(
onClick = {
showPhotoSheet = false
val imagesDir = File(context.filesDir, "images").also { it.mkdirs() }
val photoFile = File(imagesDir, "box_${UUID.randomUUID()}.jpg")
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
photoFile,
)
pendingCameraUri = uri
pendingCameraPath = photoFile.absolutePath
val hasPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
if (hasPermission) {
cameraLauncher.launch(uri)
} else {
permissionLauncher.launch(Manifest.permission.CAMERA)
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
) {
Icon(
Icons.Filled.CameraAlt,
contentDescription = null,
modifier = Modifier.padding(end = 12.dp),
)
Text("Ta foto", modifier = Modifier.weight(1f))
}
}
}
}
if (showCameraPermissionDialog) {
AlertDialog(
onDismissRequest = { showCameraPermissionDialog = false },
title = { Text("Kamerabehörighet saknas") },
text = { Text("Kamerabehörighet har nekats permanent. Gå till appens inställningar för att bevilja den.") },
confirmButton = {
TextButton(
onClick = {
showCameraPermissionDialog = false
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
context.startActivity(intent)
}
) { Text("Öppna inställningar") }
},
dismissButton = {
TextButton(onClick = { showCameraPermissionDialog = false }) { Text("Avbryt") }
},
)
}
}
@Composable
private fun BoxPhotoItem(
photo: EditablePhoto,
index: Int,
totalCount: Int,
onRemove: () -> Unit,
onMoveLeft: () -> Unit,
onMoveRight: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp)),
) {
AsyncImage(
model = photo.filePath,
contentDescription = "Foto ${index + 1}",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
IconButton(
onClick = onRemove,
modifier = Modifier
.align(Alignment.TopEnd)
.size(28.dp)
.background(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f),
shape = RoundedCornerShape(bottomStart = 8.dp),
),
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Ta bort foto",
modifier = Modifier.size(16.dp),
)
}
if (index > 0) {
TextButton(
onClick = onMoveLeft,
modifier = Modifier
.align(Alignment.BottomStart)
.size(28.dp)
.background(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f),
shape = RoundedCornerShape(topEnd = 8.dp),
)
.padding(0.dp),
contentPadding = PaddingValues(0.dp),
) {
Text("", style = MaterialTheme.typography.labelSmall)
}
}
if (index < totalCount - 1) {
TextButton(
onClick = onMoveRight,
modifier = Modifier
.align(Alignment.BottomEnd)
.size(28.dp)
.background(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f),
shape = RoundedCornerShape(topStart = 8.dp),
)
.padding(0.dp),
contentPadding = PaddingValues(0.dp),
) {
Text("", style = MaterialTheme.typography.labelSmall)
}
}
}
}
@Composable
private fun AddBoxPhotoButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.border(
2.dp,
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
RoundedCornerShape(8.dp),
)
.background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)),
contentAlignment = Alignment.Center,
) {
IconButton(onClick = onClick, modifier = Modifier.fillMaxSize()) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "Lägg till foto",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp),
)
}
}
}
// ── Färgval ────────────────────────────────────────────────────────────────────
@Composable
private fun ColorSwatchSection(
selectedColor: String,
@ -294,143 +577,3 @@ private fun isLightColor(color: Color): Boolean {
val luminance = 0.299 * color.red + 0.587 * color.green + 0.114 * color.blue
return luminance > 0.5
}
@Composable
private fun BoxPhotoSection(
photoPath: String?,
snackbarHostState: SnackbarHostState,
onPhotoSelected: (Uri) -> Unit,
onPhotoRemoved: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val activity = context as? Activity
val scope = rememberCoroutineScope()
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
var showCameraPermissionDialog by rememberSaveable { mutableStateOf(false) }
val galleryLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri -> uri?.let(onPhotoSelected) }
val cameraLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.TakePicture()
) { success -> if (success) pendingCameraUri?.let(onPhotoSelected) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
pendingCameraUri?.let { cameraLauncher.launch(it) }
} else {
val permanentlyDenied = activity?.let {
!ActivityCompat.shouldShowRequestPermissionRationale(it, Manifest.permission.CAMERA)
} ?: false
if (permanentlyDenied) {
showCameraPermissionDialog = true
} else {
scope.launch {
snackbarHostState.showSnackbar("Kamerabehörighet krävs för att ta foto")
}
}
}
}
Column(modifier = modifier.fillMaxWidth()) {
if (photoPath != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
) {
AsyncImage(
model = photoPath,
contentDescription = "Foto av lådan",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
IconButton(
onClick = onPhotoRemoved,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.background(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
shape = MaterialTheme.shapes.small,
),
) {
Icon(Icons.Filled.Close, contentDescription = "Ta bort foto")
}
}
} else {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
OutlinedButton(
onClick = { galleryLauncher.launch("image/*") },
modifier = Modifier.weight(1f),
) {
Icon(
imageVector = Icons.Filled.PhotoLibrary,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
)
Text("Galleri")
}
OutlinedButton(
onClick = {
val imagesDir = File(context.filesDir, "images").also { it.mkdirs() }
val photoFile = File(imagesDir, "temp_${UUID.randomUUID()}.jpg")
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
photoFile,
)
pendingCameraUri = uri
val hasPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
if (hasPermission) {
cameraLauncher.launch(uri)
} else {
permissionLauncher.launch(Manifest.permission.CAMERA)
}
},
modifier = Modifier.weight(1f),
) {
Icon(
imageVector = Icons.Filled.CameraAlt,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
)
Text("Kamera")
}
}
}
}
if (showCameraPermissionDialog) {
AlertDialog(
onDismissRequest = { showCameraPermissionDialog = false },
title = { Text("Kamerabehörighet saknas") },
text = { Text("Kamerabehörighet har nekats permanent. Gå till appens inställningar för att bevilja den.") },
confirmButton = {
TextButton(
onClick = {
showCameraPermissionDialog = false
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
context.startActivity(intent)
}
) { Text("Öppna inställningar") }
},
dismissButton = {
TextButton(onClick = { showCameraPermissionDialog = false }) {
Text("Avbryt")
}
},
)
}
}

View file

@ -2,6 +2,7 @@ package com.roundingmobile.boi.presentation.box
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.SavedStateHandle
@ -9,14 +10,20 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.roundingmobile.boi.BuildConfig
import com.roundingmobile.boi.domain.model.Box
import com.roundingmobile.boi.domain.model.BoxPhoto
import com.roundingmobile.boi.domain.usecase.AddBoxPhotoUseCase
import com.roundingmobile.boi.domain.usecase.AddBoxUseCase
import com.roundingmobile.boi.domain.usecase.DeleteBoxPhotoUseCase
import com.roundingmobile.boi.domain.usecase.GetBoxByIdUseCase
import com.roundingmobile.boi.domain.usecase.GetPhotosByBoxUseCase
import com.roundingmobile.boi.domain.usecase.UpdateBoxUseCase
import com.roundingmobile.boi.presentation.common.EditablePhoto
import com.roundingmobile.boi.util.PhotoManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.UUID
import javax.inject.Inject
@ -27,6 +34,9 @@ class AddEditBoxViewModel @Inject constructor(
private val getBoxByIdUseCase: GetBoxByIdUseCase,
private val addBoxUseCase: AddBoxUseCase,
private val updateBoxUseCase: UpdateBoxUseCase,
private val getPhotosByBoxUseCase: GetPhotosByBoxUseCase,
private val addBoxPhotoUseCase: AddBoxPhotoUseCase,
private val deleteBoxPhotoUseCase: DeleteBoxPhotoUseCase,
private val photoManager: PhotoManager,
) : ViewModel() {
@ -35,24 +45,23 @@ class AddEditBoxViewModel @Inject constructor(
val isEditMode: Boolean = boxIdArg != null
// Form fields
// ── Formulärfält ──────────────────────────────────────────────────────────
var name by mutableStateOf("")
private set
var description by mutableStateOf("")
private set
var color by mutableStateOf(BoxColors.DEFAULT)
private set
var photoPath by mutableStateOf<String?>(null)
private set
// ── Foton ─────────────────────────────────────────────────────────────────
val photos = mutableStateListOf<EditablePhoto>()
private val photosToDelete = mutableListOf<BoxPhoto>()
private var originalName = ""
private var originalDescription = ""
private var originalColor = BoxColors.DEFAULT
private var originalPhotoPath: String? = null
/** QR-kod genereras en gång vid skapande och förblir immutable. */
private var existingQrCode: String? = null
private var existingBox: Box? = null
private var existingQrCode: String? = null
private val _uiState = MutableStateFlow<AddEditBoxUiState>(AddEditBoxUiState.Idle)
val uiState: StateFlow<AddEditBoxUiState> = _uiState.asStateFlow()
@ -64,7 +73,8 @@ class AddEditBoxViewModel @Inject constructor(
get() = name != originalName ||
description != originalDescription ||
color != originalColor ||
photoPath != originalPhotoPath
photos.any { it.dbId == 0L } ||
photosToDelete.isNotEmpty()
init {
if (boxIdArg != null) {
@ -74,45 +84,57 @@ class AddEditBoxViewModel @Inject constructor(
private fun loadBox(id: Long) {
viewModelScope.launch {
val box = getBoxByIdUseCase(id)
if (box != null) {
existingBox = box
existingQrCode = box.qrCode
name = box.name
description = box.description
color = box.color ?: BoxColors.DEFAULT
photoPath = box.photoPath
originalName = box.name
originalDescription = box.description
originalColor = box.color ?: BoxColors.DEFAULT
originalPhotoPath = box.photoPath
}
val box = getBoxByIdUseCase(id) ?: return@launch
existingBox = box
existingQrCode = box.qrCode
name = box.name
description = box.description
color = box.color ?: BoxColors.DEFAULT
originalName = box.name
originalDescription = box.description
originalColor = box.color ?: BoxColors.DEFAULT
val existingPhotos = getPhotosByBoxUseCase(id).first()
photos.addAll(existingPhotos.map { EditablePhoto(dbId = it.id, filePath = it.filePath) })
}
}
fun onNameChange(value: String) {
name = value
}
fun onNameChange(value: String) { name = value }
fun onDescriptionChange(value: String) { description = value }
fun onColorChange(hex: String) { color = hex }
fun onDescriptionChange(value: String) {
description = value
}
fun onColorChange(hex: String) {
color = hex
}
fun setPhotoFromUri(uri: Uri) {
fun addPhotoFromUri(uri: Uri) {
viewModelScope.launch {
val newPath = photoManager.compressAndSave(uri) ?: return@launch
photoPath?.let { if (it != originalPhotoPath) photoManager.deletePhoto(it) }
photoPath = newPath
val path = photoManager.compressAndSave(uri) ?: return@launch
photos.add(EditablePhoto(filePath = path))
}
}
fun removePhoto() {
photoPath?.let { if (it != originalPhotoPath) photoManager.deletePhoto(it) }
photoPath = null
fun addPhotoFromCameraPath(path: String) {
viewModelScope.launch {
val compressed = photoManager.compressFromPath(path) ?: return@launch
photos.add(EditablePhoto(filePath = compressed))
}
}
fun removePhoto(index: Int) {
if (index < 0 || index >= photos.size) return
val photo = photos.removeAt(index)
if (photo.dbId > 0L) {
photosToDelete.add(
BoxPhoto(id = photo.dbId, boxId = boxIdArg ?: 0L, filePath = photo.filePath)
)
} else {
photoManager.deletePhoto(photo.filePath)
}
}
fun reorderPhoto(fromIndex: Int, toIndex: Int) {
if (fromIndex == toIndex) return
if (fromIndex < 0 || fromIndex >= photos.size) return
if (toIndex < 0 || toIndex >= photos.size) return
val moved = photos.removeAt(fromIndex)
photos.add(toIndex, moved)
}
fun save() {
@ -120,7 +142,8 @@ class AddEditBoxViewModel @Inject constructor(
_uiState.value = AddEditBoxUiState.Saving
viewModelScope.launch {
val result = if (boxIdArg == null) {
if (boxIdArg == null) {
// ── Ny låda ───────────────────────────────────────────────────
val roomId = roomIdArg ?: run {
_uiState.value = AddEditBoxUiState.Error("Rum-ID saknas")
return@launch
@ -133,10 +156,17 @@ class AddEditBoxViewModel @Inject constructor(
description = description.trim(),
qrCode = qrCode,
color = color,
photoPath = photoPath,
photoPath = null,
),
)
).onSuccess { newBoxId ->
savePhotosForBox(newBoxId)
_uiState.value = AddEditBoxUiState.Saved
}.onFailure { e ->
if (BuildConfig.DEBUG) android.util.Log.e("AddEditBoxVM", "add error", e)
_uiState.value = AddEditBoxUiState.Error(e.message ?: "Okänt fel")
}
} else {
// ── Redigera befintlig låda ───────────────────────────────────
val base = existingBox ?: run {
_uiState.value = AddEditBoxUiState.Error("Lådan hittades inte")
return@launch
@ -146,30 +176,50 @@ class AddEditBoxViewModel @Inject constructor(
name = name.trim(),
description = description.trim(),
color = color,
photoPath = photoPath,
),
)
).onSuccess {
for (photo in photosToDelete) {
deleteBoxPhotoUseCase(photo)
photoManager.deletePhoto(photo.filePath)
}
photosToDelete.clear()
savePhotosForBox(boxIdArg)
_uiState.value = AddEditBoxUiState.Saved
}.onFailure { e ->
if (BuildConfig.DEBUG) android.util.Log.e("AddEditBoxVM", "update error", e)
_uiState.value = AddEditBoxUiState.Error(e.message ?: "Okänt fel")
}
}
}
}
result.onSuccess {
if (photoPath != originalPhotoPath) originalPhotoPath?.let { photoManager.deletePhoto(it) }
_uiState.value = AddEditBoxUiState.Saved
}.onFailure { e ->
if (BuildConfig.DEBUG) android.util.Log.e("AddEditBoxVM", "save error", e)
_uiState.value = AddEditBoxUiState.Error(e.message ?: "Okänt fel")
private suspend fun savePhotosForBox(boxId: Long) {
photos.forEachIndexed { index, editablePhoto ->
if (editablePhoto.dbId == 0L) {
addBoxPhotoUseCase(
BoxPhoto(
boxId = boxId,
filePath = editablePhoto.filePath,
sortOrder = index,
)
)
}
}
}
fun onCancel() {
// Ta bort temporärt foto om det inte är originalfoto
val current = photoPath
if (current != null && current != originalPhotoPath) {
photoManager.deletePhoto(current)
}
// Radera osparade nya foton
photos.filter { it.dbId == 0L }.forEach { photoManager.deletePhoto(it.filePath) }
}
fun clearError() {
_uiState.value = AddEditBoxUiState.Idle
}
override fun onCleared() {
super.onCleared()
if (_uiState.value !is AddEditBoxUiState.Saved) {
photos.filter { it.dbId == 0L }.forEach { photoManager.deletePhoto(it.filePath) }
}
}
}

View file

@ -19,10 +19,14 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DriveFileMove
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Inventory2
import androidx.compose.material.icons.filled.MoreVert
@ -51,6 +55,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
@ -60,8 +66,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.roundingmobile.boi.domain.model.Box
import com.roundingmobile.boi.domain.model.BoxPhoto
import com.roundingmobile.boi.domain.model.Item
import com.roundingmobile.boi.presentation.common.BackNavigationIcon
import com.roundingmobile.boi.presentation.common.MoveDestinationSheet
// ── Entry-point ────────────────────────────────────────────────────────────────
@ -76,6 +84,7 @@ fun BoxDetailScreen(
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val qrBitmap by viewModel.qrBitmap.collectAsStateWithLifecycle()
val moveSheet by viewModel.moveSheet.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(uiState) {
@ -98,6 +107,7 @@ fun BoxDetailScreen(
is BoxDetailUiState.Error -> BoxErrorContent(state.message, viewModel::clearError, modifier)
is BoxDetailUiState.Success -> BoxDetailContent(
box = state.box,
photos = state.photos,
items = state.items,
roomName = state.roomName,
locationName = state.locationName,
@ -106,6 +116,7 @@ fun BoxDetailScreen(
onNavigateToEdit = onNavigateToEdit,
onNavigateToAddItem = onNavigateToAddItem,
onNavigateToItemDetail = onNavigateToItemDetail,
onMoveBox = viewModel::openMoveSheet,
onShowQr = viewModel::showQrCode,
onDismissQr = viewModel::dismissQrCode,
onGenerateLabel = viewModel::generateLabel,
@ -114,6 +125,19 @@ fun BoxDetailScreen(
)
is BoxDetailUiState.Deleted -> Unit
}
moveSheet?.let { sheetState ->
MoveDestinationSheet(
title = "Flytta låda till",
state = sheetState,
maxDepth = 2,
onLocationSelected = viewModel::onMoveLocationSelected,
onRoomSelected = {}, // används ej vid maxDepth=2
onConfirm = viewModel::moveBoxTo,
onBack = viewModel::onMoveBack,
onDismiss = viewModel::closeMoveSheet,
)
}
}
// ── Loading / Error ────────────────────────────────────────────────────────────
@ -146,6 +170,7 @@ private fun BoxErrorContent(
@Composable
private fun BoxDetailContent(
box: Box,
photos: List<BoxPhoto>,
items: List<Item>,
roomName: String,
locationName: String,
@ -154,6 +179,7 @@ private fun BoxDetailContent(
onNavigateToEdit: () -> Unit,
onNavigateToAddItem: () -> Unit,
onNavigateToItemDetail: (itemId: Long) -> Unit,
onMoveBox: () -> Unit,
onShowQr: () -> Unit,
onDismissQr: () -> Unit,
onGenerateLabel: () -> Unit,
@ -170,6 +196,7 @@ private fun BoxDetailContent(
title = box.name,
onNavigateBack = onNavigateBack,
onEditClick = onNavigateToEdit,
onMoveClick = onMoveBox,
onDeleteClick = { showDeleteDialog = true },
)
},
@ -190,6 +217,7 @@ private fun BoxDetailContent(
item {
BoxHeader(
box = box,
photos = photos,
roomName = roomName,
locationName = locationName,
onShowQr = onShowQr,
@ -247,6 +275,7 @@ private fun BoxDetailTopAppBar(
title: String,
onNavigateBack: () -> Unit,
onEditClick: () -> Unit,
onMoveClick: () -> Unit,
onDeleteClick: () -> Unit,
) {
var menuExpanded by rememberSaveable { mutableStateOf(false) }
@ -266,18 +295,17 @@ private fun BoxDetailTopAppBar(
DropdownMenuItem(
text = { Text("Redigera") },
leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) },
onClick = {
menuExpanded = false
onEditClick()
},
onClick = { menuExpanded = false; onEditClick() },
)
DropdownMenuItem(
text = { Text("Flytta") },
leadingIcon = { Icon(Icons.Filled.DriveFileMove, contentDescription = null) },
onClick = { menuExpanded = false; onMoveClick() },
)
DropdownMenuItem(
text = { Text("Ta bort") },
leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) },
onClick = {
menuExpanded = false
onDeleteClick()
},
onClick = { menuExpanded = false; onDeleteClick() },
)
}
}
@ -290,21 +318,15 @@ private fun BoxDetailTopAppBar(
@Composable
private fun BoxHeader(
box: Box,
photos: List<BoxPhoto>,
roomName: String,
locationName: String,
onShowQr: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth()) {
if (box.photoPath != null) {
AsyncImage(
model = box.photoPath,
contentDescription = "Foto av ${box.name}",
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(220.dp),
)
if (photos.isNotEmpty()) {
BoxPhotoPager(photos = photos)
}
Column(modifier = Modifier.padding(16.dp)) {
@ -358,6 +380,48 @@ private fun BoxHeader(
}
}
// ── Fotopager ──────────────────────────────────────────────────────────────────
@Composable
private fun BoxPhotoPager(
photos: List<BoxPhoto>,
modifier: Modifier = Modifier,
) {
val pagerState = rememberPagerState(pageCount = { photos.size })
Box(
modifier = modifier
.fillMaxWidth()
.height(220.dp),
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
) { page ->
AsyncImage(
model = photos[page].filePath,
contentDescription = "Foto ${page + 1} av ${photos.size}",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
if (photos.size > 1) {
Text(
text = "${pagerState.currentPage + 1} / ${photos.size}",
style = MaterialTheme.typography.labelMedium,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 8.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.Black.copy(alpha = 0.5f))
.padding(horizontal = 8.dp, vertical = 2.dp),
)
}
}
}
// ── Item list row ──────────────────────────────────────────────────────────────
@Composable

View file

@ -1,12 +1,14 @@
package com.roundingmobile.boi.presentation.box
import com.roundingmobile.boi.domain.model.Box
import com.roundingmobile.boi.domain.model.BoxPhoto
import com.roundingmobile.boi.domain.model.Item
sealed class BoxDetailUiState {
data object Loading : BoxDetailUiState()
data class Success(
val box: Box,
val photos: List<BoxPhoto>,
val items: List<Item>,
val roomName: String,
val locationName: String,

View file

@ -6,12 +6,19 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.roundingmobile.boi.BuildConfig
import com.roundingmobile.boi.domain.model.Location
import com.roundingmobile.boi.domain.usecase.DeleteBoxUseCase
import com.roundingmobile.boi.domain.usecase.GetBoxByIdUseCase
import com.roundingmobile.boi.domain.usecase.GetItemsByBoxUseCase
import com.roundingmobile.boi.domain.usecase.GetLocationByIdUseCase
import com.roundingmobile.boi.domain.usecase.GetLocationsUseCase
import com.roundingmobile.boi.domain.usecase.GetPhotosByBoxUseCase
import com.roundingmobile.boi.domain.usecase.GetRoomByIdUseCase
import com.roundingmobile.boi.domain.usecase.GetRoomsByLocationUseCase
import com.roundingmobile.boi.domain.usecase.ObserveBoxByIdUseCase
import com.roundingmobile.boi.domain.usecase.UpdateBoxUseCase
import com.roundingmobile.boi.presentation.common.MoveSheetState
import com.roundingmobile.boi.presentation.common.MoveTarget
import com.roundingmobile.boi.util.PdfLabelGenerator
import com.roundingmobile.boi.util.PhotoManager
import com.roundingmobile.boi.util.QrCodeGenerator
@ -25,6 +32,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -33,8 +41,12 @@ class BoxDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val getBoxByIdUseCase: GetBoxByIdUseCase,
private val observeBoxByIdUseCase: ObserveBoxByIdUseCase,
private val updateBoxUseCase: UpdateBoxUseCase,
private val getItemsByBoxUseCase: GetItemsByBoxUseCase,
private val getPhotosByBoxUseCase: GetPhotosByBoxUseCase,
private val getRoomByIdUseCase: GetRoomByIdUseCase,
private val getLocationsUseCase: GetLocationsUseCase,
private val getRoomsByLocationUseCase: GetRoomsByLocationUseCase,
private val getLocationByIdUseCase: GetLocationByIdUseCase,
private val deleteBoxUseCase: DeleteBoxUseCase,
private val photoManager: PhotoManager,
@ -47,11 +59,12 @@ class BoxDetailViewModel @Inject constructor(
private val _uiState = MutableStateFlow<BoxDetailUiState>(BoxDetailUiState.Loading)
val uiState: StateFlow<BoxDetailUiState> = _uiState.asStateFlow()
/** Emittar QR-bitmappen när "Visa QR"-dialogen ska visas. */
private val _moveSheet = MutableStateFlow<MoveSheetState?>(null)
val moveSheet: StateFlow<MoveSheetState?> = _moveSheet.asStateFlow()
private val _qrBitmap = MutableStateFlow<Bitmap?>(null)
val qrBitmap: StateFlow<Bitmap?> = _qrBitmap.asStateFlow()
/** Emittar PDF-URI:n för ShareSheet. */
private val _labelUri = MutableSharedFlow<Uri>()
val labelUri: SharedFlow<Uri> = _labelUri.asSharedFlow()
@ -64,12 +77,13 @@ class BoxDetailViewModel @Inject constructor(
combine(
observeBoxByIdUseCase(boxId),
getItemsByBoxUseCase(boxId),
) { box, items -> Pair(box, items) }
getPhotosByBoxUseCase(boxId),
) { box, items, photos -> Triple(box, items, photos) }
.catch { e ->
if (BuildConfig.DEBUG) android.util.Log.e("BoxDetailVM", "loadData error", e)
_uiState.value = BoxDetailUiState.Error(e.message ?: "Okänt fel")
}
.collectLatest { (box, items) ->
.collectLatest { (box, items, photos) ->
if (box == null) {
_uiState.value = BoxDetailUiState.Error("Lådan hittades inte")
return@collectLatest
@ -78,6 +92,7 @@ class BoxDetailViewModel @Inject constructor(
val location = room?.let { getLocationByIdUseCase(it.locationId) }
_uiState.value = BoxDetailUiState.Success(
box = box,
photos = photos,
items = items,
roomName = room?.name ?: "",
locationName = location?.name ?: "",
@ -121,6 +136,9 @@ class BoxDetailViewModel @Inject constructor(
viewModelScope.launch {
deleteBoxUseCase(state.box).fold(
onSuccess = {
// Radera fotofiler från disk (DB-rader rensas via CASCADE)
state.photos.forEach { photoManager.deletePhoto(it.filePath) }
// Radera eventuellt gammalt enkelfoto (legacy photoPath)
state.box.photoPath?.let { photoManager.deletePhoto(it) }
_uiState.value = BoxDetailUiState.Deleted
},
@ -135,4 +153,46 @@ class BoxDetailViewModel @Inject constructor(
fun clearError() {
loadData()
}
// ── Flytt ─────────────────────────────────────────────────────────────────
fun openMoveSheet() {
viewModelScope.launch {
_moveSheet.value = MoveSheetState(isLoading = true)
val locations = getLocationsUseCase().first()
_moveSheet.value = MoveSheetState(locations = locations)
}
}
fun onMoveLocationSelected(location: Location) {
val s = _moveSheet.value ?: return
viewModelScope.launch {
_moveSheet.value = s.copy(isLoading = true)
val rooms = getRoomsByLocationUseCase(location.id).first()
_moveSheet.value = s.copy(selectedLocation = location, rooms = rooms, isLoading = false)
}
}
fun onMoveBack() {
val s = _moveSheet.value ?: return
if (s.selectedLocation != null) {
_moveSheet.value = s.copy(selectedLocation = null, rooms = emptyList())
}
}
fun moveBoxTo(target: MoveTarget) {
if (target !is MoveTarget.ToRoom) return
val state = _uiState.value as? BoxDetailUiState.Success ?: return
_moveSheet.value = null
viewModelScope.launch {
updateBoxUseCase(state.box.copy(roomId = target.roomId)).onFailure { e ->
if (BuildConfig.DEBUG) android.util.Log.e("BoxDetailVM", "move failed", e)
_uiState.value = BoxDetailUiState.Error(e.message ?: "Kunde inte flytta lådan")
}
}
}
fun closeMoveSheet() {
_moveSheet.value = null
}
}

View file

@ -1,5 +1,7 @@
package com.roundingmobile.boi.presentation.common
import android.view.OrientationEventListener
import android.view.Surface
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
@ -18,6 +20,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@ -47,18 +50,12 @@ fun CameraScreen(
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
// Behörighet hanteras av anroparen (AddEditItemScreen) innan navigation hit.
// CameraScreen förutsätter att CAMERA-behörighet redan är beviljad.
Box(modifier = modifier.fillMaxSize().background(Color.Black)) {
CameraPreview(
onPhotoTaken = onPhotoTaken,
modifier = Modifier.fillMaxSize(),
)
// Stäng-knapp
IconButton(
onClick = onNavigateBack,
modifier = Modifier
@ -86,6 +83,26 @@ private fun CameraPreview(
val imageCapture = remember { ImageCapture.Builder().build() }
val previewView = remember { PreviewView(context) }
// Håller imageCapture.targetRotation uppdaterad medan kameran är aktiv.
// Mappningen följer CameraX-dokumentationen för OrientationEventListener.
// Utan detta skriver CameraX alltid EXIF för den orientering som gällde
// vid uppstart — vilket ger fel rotation för landskapsfoton.
DisposableEffect(context) {
val listener = object : OrientationEventListener(context) {
override fun onOrientationChanged(orientation: Int) {
if (orientation == ORIENTATION_UNKNOWN) return
imageCapture.targetRotation = when (orientation) {
in 45..134 -> Surface.ROTATION_270
in 135..224 -> Surface.ROTATION_180
in 225..314 -> Surface.ROTATION_90
else -> Surface.ROTATION_0
}
}
}
listener.enable()
onDispose { listener.disable() }
}
LaunchedEffect(previewView) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
@ -115,7 +132,6 @@ private fun CameraPreview(
modifier = Modifier.fillMaxSize(),
)
// Slutarknapp
ShutterButton(
onClick = {
val tempFile = File(context.cacheDir, "camera_${UUID.randomUUID()}.jpg")

View file

@ -0,0 +1,12 @@
package com.roundingmobile.boi.presentation.common
/**
* EditablePhoto representerar ett foto under redigering i ett formulär.
*
* dbId > 0 foto som redan finns i databasen.
* dbId == 0 nytt foto (inte sparat i DB ännu).
*/
data class EditablePhoto(
val dbId: Long = 0L,
val filePath: String,
)

View file

@ -0,0 +1,270 @@
package com.roundingmobile.boi.presentation.common
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Inventory2
import androidx.compose.material.icons.filled.MeetingRoom
import androidx.compose.material.icons.filled.Place
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.roundingmobile.boi.domain.model.Box
import com.roundingmobile.boi.domain.model.Location
import com.roundingmobile.boi.domain.model.StorageRoom
// ── State ──────────────────────────────────────────────────────────────────────
data class MoveSheetState(
val locations: List<Location> = emptyList(),
val selectedLocation: Location? = null,
val rooms: List<StorageRoom> = emptyList(),
val selectedRoom: StorageRoom? = null,
val boxes: List<Box> = emptyList(),
val isLoading: Boolean = false,
)
sealed class MoveTarget {
data class ToBox(val boxId: Long) : MoveTarget()
data class ToRoom(val roomId: Long) : MoveTarget()
data class ToLocation(val locationId: Long) : MoveTarget()
}
private enum class MoveStep { Location, Room, Box }
// ── Sheet ──────────────────────────────────────────────────────────────────────
/**
* Återanvändbart bottom sheet för att välja destination vid flytt.
*
* maxDepth styr hur djupt hierarkin visas:
* 1 = Plats (för att flytta rum)
* 2 = Plats Rum (för att flytta lådor)
* 3 = Plats Rum Låda, med "Direkt på/i"-alternativ (för att flytta föremål)
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MoveDestinationSheet(
title: String,
state: MoveSheetState,
maxDepth: Int = 3,
onLocationSelected: (Location) -> Unit,
onRoomSelected: (StorageRoom) -> Unit,
onConfirm: (MoveTarget) -> Unit,
onBack: () -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val step = when {
state.selectedRoom != null && maxDepth >= 3 -> MoveStep.Box
state.selectedLocation != null && maxDepth >= 2 -> MoveStep.Room
else -> MoveStep.Location
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
// ── Rubrik ────────────────────────────────────────────────────────────
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 4.dp),
) {
if (step != MoveStep.Location) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
}
} else {
Spacer(Modifier.size(48.dp))
}
Text(
text = when (step) {
MoveStep.Location -> title
MoveStep.Room -> state.selectedLocation?.name ?: ""
MoveStep.Box -> state.selectedRoom?.name ?: ""
},
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.weight(1f)
.padding(start = 4.dp),
)
}
HorizontalDivider()
if (state.isLoading) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
} else {
LazyColumn(
contentPadding = PaddingValues(bottom = 40.dp),
modifier = Modifier.fillMaxWidth(),
) {
when (step) {
// ── Steg 1: Välj plats ────────────────────────────────────
MoveStep.Location -> {
items(state.locations, key = { it.id }) { location ->
LocationRow(
location = location,
showArrow = maxDepth > 1,
onClick = {
if (maxDepth == 1) onConfirm(MoveTarget.ToLocation(location.id))
else onLocationSelected(location)
},
)
HorizontalDivider(Modifier.padding(start = 72.dp))
}
}
// ── Steg 2: Välj rum (eller direkt på platsen) ────────────
MoveStep.Room -> {
// "Direkt på platsen" — bara för föremål (maxDepth == 3)
if (maxDepth == 3) {
item {
DirectHereRow(
label = "Direkt på platsen",
onClick = {
onConfirm(MoveTarget.ToLocation(state.selectedLocation!!.id))
},
)
HorizontalDivider(Modifier.padding(start = 72.dp))
}
}
items(state.rooms, key = { it.id }) { room ->
RoomRow(
room = room,
showArrow = maxDepth >= 3,
onClick = {
if (maxDepth == 2) onConfirm(MoveTarget.ToRoom(room.id))
else onRoomSelected(room)
},
)
HorizontalDivider(Modifier.padding(start = 72.dp))
}
}
// ── Steg 3: Välj låda (eller direkt i rummet) ─────────────
MoveStep.Box -> {
item {
DirectHereRow(
label = "Direkt i rummet",
onClick = {
onConfirm(MoveTarget.ToRoom(state.selectedRoom!!.id))
},
)
HorizontalDivider(Modifier.padding(start = 72.dp))
}
items(state.boxes, key = { it.id }) { box ->
BoxRow(
box = box,
onClick = { onConfirm(MoveTarget.ToBox(box.id)) },
)
HorizontalDivider(Modifier.padding(start = 72.dp))
}
}
}
}
}
}
}
// ── Rad-composables ────────────────────────────────────────────────────────────
@Composable
private fun LocationRow(location: Location, showArrow: Boolean, onClick: () -> Unit) {
ListItem(
headlineContent = { Text(location.name) },
leadingContent = { Icon(Icons.Filled.Place, contentDescription = null) },
trailingContent = if (showArrow) {
{ Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) }
} else null,
modifier = Modifier.clickable(onClick = onClick),
)
}
@Composable
private fun RoomRow(room: StorageRoom, showArrow: Boolean, onClick: () -> Unit) {
ListItem(
headlineContent = { Text(room.name) },
supportingContent = if (room.description.isNotBlank()) {
{ Text(room.description) }
} else null,
leadingContent = { Icon(Icons.Filled.MeetingRoom, contentDescription = null) },
trailingContent = if (showArrow) {
{ Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) }
} else null,
modifier = Modifier.clickable(onClick = onClick),
)
}
@Composable
private fun BoxRow(box: Box, onClick: () -> Unit) {
ListItem(
headlineContent = { Text(box.name) },
supportingContent = if (box.description.isNotBlank()) {
{ Text(box.description) }
} else null,
leadingContent = { Icon(Icons.Filled.Inventory2, contentDescription = null) },
trailingContent = {
Icon(
Icons.Filled.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier = Modifier.clickable(onClick = onClick),
)
}
@Composable
private fun DirectHereRow(label: String, onClick: () -> Unit) {
ListItem(
headlineContent = {
Text(
text = label,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
)
},
leadingContent = {
Icon(
Icons.Filled.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier = Modifier.clickable(onClick = onClick),
)
}

View file

@ -76,6 +76,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.roundingmobile.boi.presentation.common.BackNavigationIcon
import com.roundingmobile.boi.presentation.common.EditablePhoto
import com.yalantis.ucrop.UCrop
import kotlinx.coroutines.launch
import java.io.File

View file

@ -14,6 +14,7 @@ import com.roundingmobile.boi.domain.model.FreemiumException
import com.roundingmobile.boi.domain.model.Item
import com.roundingmobile.boi.domain.model.ItemPhoto
import com.roundingmobile.boi.domain.model.Tag
import com.roundingmobile.boi.presentation.common.EditablePhoto
import com.roundingmobile.boi.domain.repository.ProStatusRepository
import com.roundingmobile.boi.domain.usecase.AddItemUseCase
import com.roundingmobile.boi.domain.usecase.AddPhotoUseCase
@ -36,17 +37,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* EditablePhoto representerar ett foto under redigering.
*
* dbId > 0 foto som redan finns i databasen.
* dbId == 0 nytt foto (inte sparat i DB ännu).
*/
data class EditablePhoto(
val dbId: Long = 0L,
val filePath: String,
)
/**
* SuggestedTag en ML Kit-föreslagen tagg.
*

View file

@ -20,6 +20,7 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DriveFileMove
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Inventory2
import androidx.compose.material.icons.filled.MoreVert
@ -57,6 +58,8 @@ import com.roundingmobile.boi.domain.model.Item
import com.roundingmobile.boi.domain.model.ItemPhoto
import com.roundingmobile.boi.domain.model.Tag
import com.roundingmobile.boi.presentation.common.BackNavigationIcon
import com.roundingmobile.boi.presentation.common.MoveDestinationSheet
import com.roundingmobile.boi.presentation.common.MoveSheetState
// ── Entry-point ────────────────────────────────────────────────────────────────
@ -68,6 +71,7 @@ fun ItemDetailScreen(
viewModel: ItemDetailViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val moveSheet by viewModel.moveSheet.collectAsStateWithLifecycle()
LaunchedEffect(uiState) {
if (uiState is ItemDetailUiState.Deleted) onNavigateBack()
@ -84,10 +88,24 @@ fun ItemDetailScreen(
onNavigateBack = onNavigateBack,
onNavigateToEdit = onNavigateToEdit,
onDeleteItem = viewModel::deleteItem,
onMoveItem = viewModel::openMoveSheet,
modifier = modifier,
)
is ItemDetailUiState.Deleted -> Unit
}
moveSheet?.let { sheetState ->
MoveDestinationSheet(
title = "Flytta föremål till",
state = sheetState,
maxDepth = 3,
onLocationSelected = viewModel::onMoveLocationSelected,
onRoomSelected = viewModel::onMoveRoomSelected,
onConfirm = viewModel::moveItemTo,
onBack = viewModel::onMoveBack,
onDismiss = viewModel::closeMoveSheet,
)
}
}
// ── Loading / Error ────────────────────────────────────────────────────────────
@ -126,6 +144,7 @@ private fun ItemDetailContent(
onNavigateBack: () -> Unit,
onNavigateToEdit: () -> Unit,
onDeleteItem: () -> Unit,
onMoveItem: () -> Unit,
modifier: Modifier = Modifier,
) {
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
@ -138,6 +157,7 @@ private fun ItemDetailContent(
title = item.name,
onNavigateBack = onNavigateBack,
onEditClick = onNavigateToEdit,
onMoveClick = onMoveItem,
onDeleteClick = { showDeleteDialog = true },
)
},
@ -211,6 +231,7 @@ private fun ItemDetailTopAppBar(
title: String,
onNavigateBack: () -> Unit,
onEditClick: () -> Unit,
onMoveClick: () -> Unit,
onDeleteClick: () -> Unit,
) {
var menuExpanded by rememberSaveable { mutableStateOf(false) }
@ -235,6 +256,14 @@ private fun ItemDetailTopAppBar(
onEditClick()
},
)
DropdownMenuItem(
text = { Text("Flytta") },
leadingIcon = { Icon(Icons.Filled.DriveFileMove, contentDescription = null) },
onClick = {
menuExpanded = false
onMoveClick()
},
)
DropdownMenuItem(
text = { Text("Ta bort") },
leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) },

View file

@ -4,12 +4,20 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.roundingmobile.boi.BuildConfig
import com.roundingmobile.boi.domain.model.Location
import com.roundingmobile.boi.domain.model.StorageRoom
import com.roundingmobile.boi.domain.repository.BoxRepository
import com.roundingmobile.boi.domain.usecase.DeleteItemUseCase
import com.roundingmobile.boi.domain.usecase.DeletePhotoUseCase
import com.roundingmobile.boi.domain.usecase.GetBoxesByRoomUseCase
import com.roundingmobile.boi.domain.usecase.GetItemByIdUseCase
import com.roundingmobile.boi.domain.usecase.GetLocationsUseCase
import com.roundingmobile.boi.domain.usecase.GetPhotosByItemUseCase
import com.roundingmobile.boi.domain.usecase.GetRoomsByLocationUseCase
import com.roundingmobile.boi.domain.usecase.GetTagsForItemUseCase
import com.roundingmobile.boi.domain.repository.BoxRepository
import com.roundingmobile.boi.domain.usecase.UpdateItemUseCase
import com.roundingmobile.boi.presentation.common.MoveSheetState
import com.roundingmobile.boi.presentation.common.MoveTarget
import com.roundingmobile.boi.util.PhotoManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@ -18,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -29,6 +38,10 @@ class ItemDetailViewModel @Inject constructor(
private val getTagsForItemUseCase: GetTagsForItemUseCase,
private val deleteItemUseCase: DeleteItemUseCase,
private val deletePhotoUseCase: DeletePhotoUseCase,
private val updateItemUseCase: UpdateItemUseCase,
private val getLocationsUseCase: GetLocationsUseCase,
private val getRoomsByLocationUseCase: GetRoomsByLocationUseCase,
private val getBoxesByRoomUseCase: GetBoxesByRoomUseCase,
private val photoManager: PhotoManager,
private val boxRepository: BoxRepository,
) : ViewModel() {
@ -38,6 +51,9 @@ class ItemDetailViewModel @Inject constructor(
private val _uiState = MutableStateFlow<ItemDetailUiState>(ItemDetailUiState.Loading)
val uiState: StateFlow<ItemDetailUiState> = _uiState.asStateFlow()
private val _moveSheet = MutableStateFlow<MoveSheetState?>(null)
val moveSheet: StateFlow<MoveSheetState?> = _moveSheet.asStateFlow()
init {
loadData()
}
@ -89,4 +105,61 @@ class ItemDetailViewModel @Inject constructor(
fun clearError() {
loadData()
}
// ── Flytt ─────────────────────────────────────────────────────────────────
fun openMoveSheet() {
viewModelScope.launch {
_moveSheet.value = MoveSheetState(isLoading = true)
val locations = getLocationsUseCase().first()
_moveSheet.value = MoveSheetState(locations = locations)
}
}
fun onMoveLocationSelected(location: Location) {
val s = _moveSheet.value ?: return
viewModelScope.launch {
_moveSheet.value = s.copy(isLoading = true)
val rooms = getRoomsByLocationUseCase(location.id).first()
_moveSheet.value = s.copy(selectedLocation = location, rooms = rooms, isLoading = false)
}
}
fun onMoveRoomSelected(room: StorageRoom) {
val s = _moveSheet.value ?: return
viewModelScope.launch {
_moveSheet.value = s.copy(isLoading = true)
val boxes = getBoxesByRoomUseCase(room.id).first()
_moveSheet.value = s.copy(selectedRoom = room, boxes = boxes, isLoading = false)
}
}
fun onMoveBack() {
val s = _moveSheet.value ?: return
_moveSheet.value = when {
s.selectedRoom != null -> s.copy(selectedRoom = null, boxes = emptyList())
s.selectedLocation != null -> s.copy(selectedLocation = null, rooms = emptyList())
else -> s
}
}
fun moveItemTo(target: MoveTarget) {
val state = _uiState.value as? ItemDetailUiState.Success ?: return
_moveSheet.value = null
viewModelScope.launch {
val updated = when (target) {
is MoveTarget.ToBox -> state.item.copy(boxId = target.boxId, roomId = null, locationId = null)
is MoveTarget.ToRoom -> state.item.copy(boxId = null, roomId = target.roomId, locationId = null)
is MoveTarget.ToLocation -> state.item.copy(boxId = null, roomId = null, locationId = target.locationId)
}
updateItemUseCase(updated).onFailure { e ->
if (BuildConfig.DEBUG) android.util.Log.e("ItemDetailVM", "move failed", e)
_uiState.value = ItemDetailUiState.Error(e.message ?: "Kunde inte flytta föremålet")
}
}
}
fun closeMoveSheet() {
_moveSheet.value = null
}
}

View file

@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DriveFileMove
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Inventory2
import androidx.compose.material.icons.filled.MoreVert
@ -52,6 +53,7 @@ import com.roundingmobile.boi.domain.model.Box
import com.roundingmobile.boi.domain.model.Item
import com.roundingmobile.boi.domain.model.StorageRoom
import com.roundingmobile.boi.presentation.common.BackNavigationIcon
import com.roundingmobile.boi.presentation.common.MoveDestinationSheet
// ── Entry-point ────────────────────────────────────────────────────────────────
@ -67,6 +69,7 @@ fun RoomDetailScreen(
viewModel: RoomDetailViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val moveSheet by viewModel.moveSheet.collectAsStateWithLifecycle()
LaunchedEffect(uiState) {
if (uiState is RoomDetailUiState.Deleted) onNavigateBack()
@ -85,11 +88,25 @@ fun RoomDetailScreen(
onNavigateToBoxDetail = onNavigateToBoxDetail,
onNavigateToItemDetail = onNavigateToItemDetail,
onNavigateToAddItemToRoom = onNavigateToAddItemToRoom,
onMoveRoom = viewModel::openMoveSheet,
onDeleteConfirmed = viewModel::deleteRoom,
modifier = modifier,
)
is RoomDetailUiState.Deleted -> Unit
}
moveSheet?.let { sheetState ->
MoveDestinationSheet(
title = "Flytta rum till",
state = sheetState,
maxDepth = 1,
onLocationSelected = viewModel::onMoveLocationSelected,
onRoomSelected = {},
onConfirm = viewModel::moveRoomTo,
onBack = viewModel::closeMoveSheet,
onDismiss = viewModel::closeMoveSheet,
)
}
}
// ── Tillstånds-Composables ─────────────────────────────────────────────────────
@ -125,6 +142,7 @@ private fun RoomDetailContent(
onNavigateToBoxDetail: (boxId: Long) -> Unit,
onNavigateToItemDetail: (itemId: Long) -> Unit,
onNavigateToAddItemToRoom: () -> Unit,
onMoveRoom: () -> Unit,
onDeleteConfirmed: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -138,6 +156,7 @@ private fun RoomDetailContent(
title = room.name,
onNavigateBack = onNavigateBack,
onEdit = onNavigateToEdit,
onMove = onMoveRoom,
onDelete = { showDeleteDialog = true },
)
},
@ -332,6 +351,7 @@ private fun RoomDetailTopAppBar(
title: String,
onNavigateBack: () -> Unit,
onEdit: () -> Unit,
onMove: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -354,6 +374,11 @@ private fun RoomDetailTopAppBar(
leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) },
onClick = { menuExpanded = false; onEdit() },
)
DropdownMenuItem(
text = { Text("Flytta") },
leadingIcon = { Icon(Icons.Filled.DriveFileMove, contentDescription = null) },
onClick = { menuExpanded = false; onMove() },
)
DropdownMenuItem(
text = { Text("Ta bort", color = MaterialTheme.colorScheme.error) },
leadingIcon = {

View file

@ -4,11 +4,16 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.roundingmobile.boi.BuildConfig
import com.roundingmobile.boi.domain.model.Location
import com.roundingmobile.boi.domain.usecase.DeleteRoomUseCase
import com.roundingmobile.boi.domain.usecase.GetBoxesByRoomUseCase
import com.roundingmobile.boi.domain.usecase.GetItemsByRoomDirectUseCase
import com.roundingmobile.boi.domain.usecase.GetLocationsUseCase
import com.roundingmobile.boi.domain.usecase.GetRoomByIdUseCase
import com.roundingmobile.boi.domain.usecase.ObserveRoomByIdUseCase
import com.roundingmobile.boi.domain.usecase.UpdateRoomUseCase
import com.roundingmobile.boi.presentation.common.MoveSheetState
import com.roundingmobile.boi.presentation.common.MoveTarget
import com.roundingmobile.boi.util.PhotoManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@ -16,6 +21,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -27,6 +33,8 @@ class RoomDetailViewModel @Inject constructor(
private val getBoxesByRoomUseCase: GetBoxesByRoomUseCase,
private val getItemsByRoomDirectUseCase: GetItemsByRoomDirectUseCase,
private val deleteRoomUseCase: DeleteRoomUseCase,
private val updateRoomUseCase: UpdateRoomUseCase,
private val getLocationsUseCase: GetLocationsUseCase,
private val photoManager: PhotoManager,
) : ViewModel() {
@ -37,6 +45,9 @@ class RoomDetailViewModel @Inject constructor(
private val _uiState = MutableStateFlow<RoomDetailUiState>(RoomDetailUiState.Loading)
val uiState: StateFlow<RoomDetailUiState> = _uiState.asStateFlow()
private val _moveSheet = MutableStateFlow<MoveSheetState?>(null)
val moveSheet: StateFlow<MoveSheetState?> = _moveSheet.asStateFlow()
init {
loadData()
}
@ -87,4 +98,34 @@ class RoomDetailViewModel @Inject constructor(
loadData()
}
}
// ── Flytt ─────────────────────────────────────────────────────────────────
fun openMoveSheet() {
viewModelScope.launch {
_moveSheet.value = MoveSheetState(isLoading = true)
val locations = getLocationsUseCase().first()
_moveSheet.value = MoveSheetState(locations = locations)
}
}
fun onMoveLocationSelected(location: Location) {
_moveSheet.value = _moveSheet.value?.copy(selectedLocation = location)
}
fun moveRoomTo(target: MoveTarget) {
if (target !is MoveTarget.ToLocation) return
val state = _uiState.value as? RoomDetailUiState.Success ?: return
_moveSheet.value = null
viewModelScope.launch {
updateRoomUseCase(state.room.copy(locationId = target.locationId)).onFailure { e ->
if (BuildConfig.DEBUG) android.util.Log.e("RoomDetailVM", "move failed", e)
_uiState.value = RoomDetailUiState.Error(e.message ?: "Kunde inte flytta rummet")
}
}
}
fun closeMoveSheet() {
_moveSheet.value = null
}
}

View file

@ -3,8 +3,11 @@ package com.roundingmobile.boi.util
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.media.ExifInterface
import android.net.Uri
import androidx.core.content.FileProvider
import com.roundingmobile.boi.BuildConfig
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -12,14 +15,6 @@ import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
/**
* PhotoManager hanterar foto-livscykeln: skapa kamera-URI, komprimera,
* spara till intern lagring och ta bort gamla filer.
*
* Bilder sparas i filesDir/images/ aldrig i extern lagring.
* Max 1080px den längsta sidan, JPEG-kvalitet 85.
* Alla disk-operationer körs Dispatchers.IO.
*/
@Singleton
class PhotoManager @Inject constructor(
@ApplicationContext private val context: Context,
@ -28,49 +23,110 @@ class PhotoManager @Inject constructor(
private val imageDir: File
get() = File(context.filesDir, "images").also { it.mkdirs() }
/**
* Skapar en content://-URI via FileProvider för kamera-capture.
* Filen skapas tomt; kameran skriver bilddata dit.
*/
fun createCameraUri(): Uri {
fun createCameraFile(): Pair<Uri, String> {
val file = File(imageDir, "cam_${System.currentTimeMillis()}.jpg")
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
return uri to file.absolutePath
}
/**
* Komprimerar bilden från [sourceUri] och sparar till intern lagring.
* Returnerar den absoluta filsökvägen eller null vid fel.
* Komprimerar ett galleri-foto från [sourceUri].
* EXIF-orientering läses via stream och pixlarna roteras.
*/
suspend fun compressAndSave(sourceUri: Uri): String? = withContext(Dispatchers.IO) {
runCatching {
val srcOrientation = readOrientationFromStream(sourceUri)
val bitmap = context.contentResolver.openInputStream(sourceUri)?.use { stream ->
BitmapFactory.decodeStream(stream)
} ?: return@withContext null
val scaled = scaleBitmap(bitmap, maxSize = 1080)
val outFile = File(imageDir, "loc_${System.currentTimeMillis()}.jpg")
if (BuildConfig.DEBUG) {
android.util.Log.d("PhotoManager",
"compressAndSave: uri=$sourceUri orientation=$srcOrientation " +
"pixels=${bitmap.width}x${bitmap.height}")
}
val rotated = rotateBitmap(bitmap, srcOrientation)
val scaled = scaleBitmap(rotated, maxSize = 1080)
val outFile = File(imageDir, "loc_${System.currentTimeMillis()}.jpg")
outFile.outputStream().use { out ->
scaled.compress(Bitmap.CompressFormat.JPEG, 85, out)
}
if (scaled !== bitmap) scaled.recycle()
if (scaled !== rotated) scaled.recycle()
if (rotated !== bitmap) rotated.recycle()
bitmap.recycle()
outFile.absolutePath
}.getOrElse {
if (com.roundingmobile.boi.BuildConfig.DEBUG) {
android.util.Log.e("PhotoManager", "compressAndSave failed", it)
}
if (BuildConfig.DEBUG) android.util.Log.e("PhotoManager", "compressAndSave failed", it)
null
}
}
/**
* Komprimerar en kamerafil direkt från disksökvägen.
* EXIF läses direkt från filen (ej via stream) för maximalt tillförlitlig läsning.
* Källfilen raderas efter lyckad komprimering.
*/
suspend fun compressFromPath(sourcePath: String): String? = withContext(Dispatchers.IO) {
val sourceFile = File(sourcePath)
if (!sourceFile.exists()) return@withContext null
runCatching {
// Läs EXIF direkt från sökväg — mer tillförlitligt för lokala filer
val orientation = ExifInterface(sourcePath).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_UNDEFINED,
)
val bitmap = BitmapFactory.decodeFile(sourcePath) ?: return@withContext null
if (BuildConfig.DEBUG) {
android.util.Log.d("PhotoManager",
"compressFromPath: orientation=$orientation " +
"pixels=${bitmap.width}x${bitmap.height} " +
"path=$sourcePath")
}
val rotated = rotateBitmap(bitmap, orientation)
val scaled = scaleBitmap(rotated, maxSize = 1080)
val outFile = File(imageDir, "loc_${System.currentTimeMillis()}.jpg")
outFile.outputStream().use { out ->
scaled.compress(Bitmap.CompressFormat.JPEG, 85, out)
}
if (scaled !== rotated) scaled.recycle()
if (rotated !== bitmap) rotated.recycle()
bitmap.recycle()
sourceFile.delete()
outFile.absolutePath
}.getOrElse {
if (BuildConfig.DEBUG) android.util.Log.e("PhotoManager", "compressFromPath failed", it)
null
}
}
/** Tar bort fotofilen från intern lagring. Tyst om filen inte finns. */
fun deletePhoto(path: String) {
File(path).takeIf { it.exists() }?.delete()
}
private fun readOrientationFromStream(uri: Uri): Int =
context.contentResolver.openInputStream(uri)?.use { stream ->
ExifInterface(stream).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_UNDEFINED,
)
} ?: ExifInterface.ORIENTATION_UNDEFINED
private fun rotateBitmap(bitmap: Bitmap, orientation: Int): Bitmap {
val degrees = when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
else -> return bitmap
}
val matrix = Matrix().apply { postRotate(degrees) }
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
private fun scaleBitmap(bitmap: Bitmap, maxSize: Int): Bitmap {
val w = bitmap.width
val h = bitmap.height