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:
parent
d1d64c3854
commit
9aee8c786c
28 changed files with 2029 additions and 271 deletions
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 på den längsta sidan, JPEG-kvalitet 85.
|
||||
* Alla disk-operationer körs på 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue