Complete Box Organizer Inventory app implementation

- Full Clean Architecture + MVVM with Hilt DI throughout all layers
- Room v6 with SQLCipher encryption and 5 migrations (no destructive)
- Items can be placed directly in a room or location (not just in a box)
- Reactive detail screens: name changes update instantly via ObserveById flows
- Camera permission flow: always-clickable button with proper rationale handling
- Soft keyboard: imePadding on AddEditItemScreen so Notes field stays visible
- Clickable items in BoxDetailScreen navigating to ItemDetailScreen
- FTS4 full-text search, QR code scanning, CameraX photos with UCrop
- Google Drive encrypted backup via WorkManager, Excel/PDF export
- Biometric + PIN app lock, Google Play Billing freemium model
- Home screen widgets: 4x1 search widget and 2x2 recent items widget
- Updated docs/PROJECT_OVERVIEW.md to reflect current codebase state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Quiver 2026-04-05 16:31:47 +02:00
parent 2784315bb4
commit d1d64c3854
217 changed files with 20301 additions and 182 deletions

View file

@ -1,11 +1,17 @@
plugins {
alias(libs.plugins.android.application)
// Compose Compiler Gradle Plugin krävs för @Composable med Kotlin 2.x
alias(libs.plugins.kotlin.compose)
// KSP: Kotlin Symbol Processing för Room och Hilt
alias(libs.plugins.ksp)
// Hilt: genererar DI-kod automatiskt
alias(libs.plugins.hilt.android)
}
android {
namespace = "com.roundingmobile.boxorganizerinventory"
// namespace = paketnamnet som används i genererad kod (R.kt, BuildConfig.kt m.m.)
namespace = "com.roundingmobile.boi"
compileSdk {
version = release(36) {
minorApiLevel = 1
@ -13,8 +19,8 @@ android {
}
defaultConfig {
applicationId = "com.roundingmobile.boxorganizerinventory"
minSdk = 27
applicationId = "com.roundingmobile.boi"
minSdk = 27 // Android 8.1 täcker 95%+ av aktiva enheter
targetSdk = 36
versionCode = 1
versionName = "1.0"
@ -24,44 +30,156 @@ android {
buildTypes {
release {
isMinifyEnabled = false
isMinifyEnabled = true // ProGuard/R8: minskar APK och skyddar koden
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
// Aktiverar Jetpack Compose-stöd
compose = true
// BuildConfig-klass med DEBUG-flagga (används för säker loggning: BuildConfig.DEBUG)
buildConfig = true
}
packaging {
resources {
// Undvik konflikter med duplicerade META-INF-filer från Apache POI och Google
excludes += "/META-INF/{AL2.0,LGPL2.1}"
excludes += "META-INF/DEPENDENCIES"
excludes += "META-INF/LICENSE"
excludes += "META-INF/LICENSE.txt"
excludes += "META-INF/NOTICE"
excludes += "META-INF/NOTICE.txt"
}
}
}
ksp {
// Room genererar schema-JSON hit spårar databasversioner för migrationer
arg("room.schemaLocation", "$projectDir/schemas")
}
dependencies {
// AndroidX Core
// ── AndroidX Core ─────────────────────────────────────────────────────────
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.activity.ktx)
// Lifecycle
// ── Jetpack Compose ───────────────────────────────────────────────────────
// BOM garanterar att alla compose-bibliotek har kompatibla versioner
val composeBom = platform(libs.compose.bom)
implementation(composeBom)
androidTestImplementation(composeBom)
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
// Extended icons: fler ikoner utöver standarduppsättningen
implementation(libs.compose.icons.extended)
// Övergångsanimationer: slideIn/slideOut/fade i AppNavGraph
implementation(libs.compose.animation)
implementation(libs.activity.compose)
implementation(libs.navigation.compose)
// Compose-verktyg för layoutgranskning i Android Studio (bara debug)
debugImplementation(libs.compose.ui.tooling)
// ── Lifecycle ─────────────────────────────────────────────────────────────
implementation(libs.lifecycle.viewmodel.ktx)
implementation(libs.lifecycle.runtime.ktx)
implementation(libs.lifecycle.viewmodel.compose)
// collectAsStateWithLifecycle(): samlar StateFlow livscykelsäkert
implementation(libs.lifecycle.runtime.compose)
// Hilt / Dagger
// ── Hilt / Dagger ─────────────────────────────────────────────────────────
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
// hiltViewModel() i Compose-skärmar
implementation(libs.hilt.navigation.compose)
// Hilt-integration för WorkManager (automatisk backup)
implementation(libs.hilt.work)
ksp(libs.hilt.androidx.compiler)
// Room
// ── Room ──────────────────────────────────────────────────────────────────
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// Testing
// ── SQLCipher ─────────────────────────────────────────────────────────────
// Krypterar databasen på disk ALDRIG okrypterad databas i produktion
implementation(libs.sqlcipher.android)
implementation(libs.sqlite.ktx)
// ── CameraX ───────────────────────────────────────────────────────────────
implementation(libs.camerax.core)
implementation(libs.camerax.camera2)
implementation(libs.camerax.lifecycle)
implementation(libs.camerax.view)
// ── ML Kit ────────────────────────────────────────────────────────────────
implementation(libs.mlkit.labeling) // Auto-taggar från bilder (Fas 11)
implementation(libs.mlkit.barcode) // QR-skanning (Fas 13)
// ── WorkManager ───────────────────────────────────────────────────────────
implementation(libs.workmanager.ktx)
// ── DataStore ─────────────────────────────────────────────────────────────
implementation(libs.datastore.preferences)
// ── Coil ──────────────────────────────────────────────────────────────────
// Bildladdning och cachning använd alltid Coil, aldrig manuell Bitmap
implementation(libs.coil.compose)
// ── Google Play Billing ───────────────────────────────────────────────────
implementation(libs.billing.ktx)
// ── UCrop ─────────────────────────────────────────────────────────────────
implementation(libs.ucrop)
// ── ZXing (QR-kodsgenerering) ─────────────────────────────────────────────
implementation(libs.zxing.core)
// ── Biometri ──────────────────────────────────────────────────────────────
implementation(libs.biometric)
// ── Säkerhet ──────────────────────────────────────────────────────────────
// EncryptedSharedPreferences: lagrar SQLCipher-nyckeln säkert
implementation(libs.security.crypto)
// ── Coroutines ────────────────────────────────────────────────────────────
implementation(libs.coroutines.android)
// Task.await(): krävs för GoogleSignInClient.signOut() med coroutines
implementation(libs.coroutines.play.services)
// ── Apache POI (Excel-export, Fas 16) ─────────────────────────────────────
implementation(libs.poi.ooxml) {
// log4j och stax ingår inte i Android exkludera för att undvika konflikter
exclude(group = "org.apache.logging.log4j")
exclude(group = "stax", module = "stax-api")
}
// ── Google Drive + Auth (Fas 17) ──────────────────────────────────────────
implementation(libs.play.services.auth)
implementation(libs.google.api.client.android) {
exclude(group = "org.apache.httpcomponents")
}
implementation(libs.google.drive) {
exclude(group = "org.apache.httpcomponents")
}
// GsonFactory: JSON-transport för Google Drive API (BackupRepositoryImpl)
implementation(libs.google.http.client.gson) {
exclude(group = "org.apache.httpcomponents")
}
// ── Test ──────────────────────────────────────────────────────────────────
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View file

@ -0,0 +1,49 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "4624eb637452f5c76afafc2ad89b51a8",
"entities": [
{
"tableName": "boxes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` 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": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
}
],
"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, '4624eb637452f5c76afafc2ad89b51a8')"
]
}
}

View file

@ -0,0 +1,583 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "3176ffff0468ec3e0da71191039bbe18",
"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": "items",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `boxId` INTEGER NOT NULL, `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 )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "boxId",
"columnName": "boxId",
"affinity": "INTEGER",
"notNull": true
},
{
"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_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"
]
}
]
},
{
"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, '3176ffff0468ec3e0da71191039bbe18')"
]
}
}

View file

@ -0,0 +1,632 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "ee4715b2872290782dc7641fd1e7b107",
"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": "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, 'ee4715b2872290782dc7641fd1e7b107')"
]
}
}

View file

@ -1,24 +1,16 @@
package com.roundingmobile.boxorganizerinventory
package com.roundingmobile.boi
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.roundingmobile.boxorganizerinventory", appContext.packageName)
assertEquals("com.roundingmobile.boi", appContext.packageName)
}
}
}

View file

@ -2,15 +2,147 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Kamerabehörighet: begärs just-in-time när användaren tar foto -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Biometrisk autentisering: fingeravtryck / ansikts-ID (Fas 19) -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<!-- Internet: Google Drive-backup och Play Billing -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- WorkManager-bakgrundsjobb: backup-schemaläggning -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Notiser för backup-status (Android 13+, begärs just-in-time) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".BoxOrganizerApp"
android:allowBackup="true"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.BoxOrganizerInventory" />
android:theme="@style/Theme.BoxOrganizerInventory"
tools:targetApi="31">
</manifest>
<!--
MainActivity: appens enda Activity (Single-Activity-arkitektur).
android:exported="true" krävs för launcher + widget deep links.
Deep links (boi://search, boi://item/{id}) hanteras av Compose NavController.
-->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.BoxOrganizerInventory">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep link: widget → SearchScreen -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data
android:host="search"
android:scheme="boi" />
</intent-filter>
<!-- Deep link: widget → ItemDetailScreen -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data
android:host="item"
android:scheme="boi" />
</intent-filter>
</activity>
<!--
UCrop Activity: fotobeskärning (Fas 10).
android:exported="false" anropas bara internt, aldrig från externa appar.
-->
<activity
android:name="com.yalantis.ucrop.UCropActivity"
android:exported="false"
android:screenOrientation="portrait"
android:theme="@style/Theme.BoxOrganizerInventory" />
<!--
FileProvider: skapar content://-URIs för kameran att skriva foton till.
Foton sparas i filesDir/images/ — aldrig i extern lagring.
-->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
<!--
BackupRetryReceiver: tar emot "Försök igen"-intentet från backup-felnotisen.
-->
<receiver
android:name=".data.backup.BackupRetryReceiver"
android:exported="false" />
<!--
SearchWidgetProvider 4×1 snabbsökar-widget.
android:exported="true" krävs för att systemet ska kunna skicka APPWIDGET_UPDATE.
-->
<receiver
android:name=".presentation.widget.SearchWidgetProvider"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/search_widget_info" />
</receiver>
<!--
RecentItemsWidgetProvider 2×2 senaste föremål-widget.
-->
<receiver
android:name=".presentation.widget.RecentItemsWidgetProvider"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/recent_items_widget_info" />
</receiver>
<!--
RecentItemsWidgetService RemoteViewsService för 2×2-widgetens ListView.
android:permission: bara systemet kan binda till denna tjänst (säkerhetskrav).
android:exported="false": startas bara internt via widget-ramverket.
-->
<service
android:name=".presentation.widget.RecentItemsWidgetService"
android:exported="false"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<!--
WorkManager: manuell init via BoxOrganizerApp (Configuration.Provider)
för att HiltWorkerFactory ska fungera korrekt.
-->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
</manifest>

View file

@ -0,0 +1,78 @@
package com.roundingmobile.boi
/**
* AppConfig ENDA stället i hela projektet där freemium-gränser definieras.
*
* Varför ett enda ställe?
* - Gör det trivialt att justera gränser inför lansering eller A/B-test
* - Undviker "magic numbers" spridda i hela kodbasen
* - Gör det lätt att hitta och förstå begränsningarna
*
* Regel: INGA hårdkodade siffror någon annanstans. Referera alltid hit.
*/
object AppConfig {
// ── Freemium-gränser ──────────────────────────────────────────────────────
/**
* Maximalt antal föremål för gratis-användare.
* När en användare når detta antal blockeras skapande av nya föremål
* och ProUpgradeScreen visas. Kontrolleras i ItemRepository/UseCase
* INNAN föremålet sparas aldrig i UI-lagret.
*/
const val FREE_ITEM_LIMIT = 500
/**
* Maximalt antal foton per föremål för gratis-användare.
* PRO-användare: obegränsat.
*/
const val FREE_PHOTOS_PER_ITEM = 3
/**
* Platser, rum och lådor är obegränsade i gratis-versionen.
* Endast föremål och foton per föremål är begränsade.
*/
const val FREE_MAX_LOCATIONS = Int.MAX_VALUE
const val FREE_MAX_ROOMS = Int.MAX_VALUE
const val FREE_MAX_BOXES = Int.MAX_VALUE
// ── PRO-funktioner (feature flags) ────────────────────────────────────────
// Dessa strängar används som nycklar i DataStore för att kontrollera om
// en specifik PRO-funktion är tillgänglig. Gör det möjligt att låsa upp
// funktioner granularly om produktstrategin ändras i framtiden.
/** Google Drive-backup (automatisk + manuell) */
const val FEATURE_BACKUP = "feature_backup"
/** Export till Excel (.xlsx) och PDF */
const val FEATURE_EXPORT = "feature_export"
/** Fler än FREE_PHOTOS_PER_ITEM foton per föremål */
const val FEATURE_UNLIMITED_PHOTOS = "feature_unlimited_photos"
/** Filter per tagg, värde och datum i sökning */
const val FEATURE_ADVANCED_SEARCH = "feature_advanced_search"
// ── Google Play Store ─────────────────────────────────────────────────────
/**
* Product ID för PRO-abonnemanget i Google Play Console.
* Måste matcha exakt det ID som skapas i Play Console > Monetization.
*/
const val PRO_PRODUCT_ID = "box_organizer_pro"
// ── Bildkvalitet ──────────────────────────────────────────────────────────
/**
* Maximal sida (bredd eller höjd) för sparade foton i pixlar.
* Bilder skalas ner om de överskrider detta mått (bevarar proportioner).
* 1080px ger bra kvalitet med rimlig filstorlek.
*/
const val MAX_IMAGE_DIMENSION = 1080
/**
* JPEG-komprimeringskvali tet (0100).
* 80 ger bra balans mellan kvalitet och filstorlek.
*/
const val IMAGE_COMPRESSION_QUALITY = 80
}

View file

@ -0,0 +1,63 @@
package com.roundingmobile.boi
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.roundingmobile.boi.data.backup.BackupWorker
import dagger.hilt.android.HiltAndroidApp
import java.util.concurrent.TimeUnit
import javax.inject.Inject
/**
* BoxOrganizerApp appens Application-klass.
*
* @HiltAndroidApp: triggar Hilt:s kodgenerering och skapar en Application-level
* DI-container. ALLA Hilt-injektioner i appen beror att denna annotation finns.
*
* Configuration.Provider: krävs för att HiltWorkerFactory ska kunna injicera
* beroenden i @HiltWorker-klasser (BackupWorker). Utan detta fungerar
* WorkManager-injektionen inte med Hilt.
*/
@HiltAndroidApp
class BoxOrganizerApp : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
override fun onCreate() {
super.onCreate()
schedulePeriodicBackup()
}
/**
* Schemalägger den periodiska backup-jobbet.
* KEEP-policy: om jobbet redan är schemalagt ändras det inte.
* Kräver WiFi (UNMETERED) och att batteriet inte är lågt.
*/
private fun schedulePeriodicBackup() {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresBatteryNotLow(true)
.build()
val request = PeriodicWorkRequestBuilder<BackupWorker>(24, TimeUnit.HOURS)
.setConstraints(constraints)
.build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
BackupWorker.WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request,
)
}
}

View file

@ -0,0 +1,214 @@
package com.roundingmobile.boi
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.roundingmobile.boi.data.billing.BillingManager
import com.roundingmobile.boi.navigation.AppNavGraph
import com.roundingmobile.boi.navigation.Screen
import com.roundingmobile.boi.presentation.lock.AppLockManager
import com.roundingmobile.boi.presentation.lock.LockScreen
import com.roundingmobile.boi.ui.theme.BoxOrganizerTheme
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/**
* MainActivity appens enda Activity (Single-Activity-arkitektur).
*
* Ansvarar för:
* 1. Skapa NavController och koppla den till AppNavGraph.
* 2. Visa NavigationBar (BottomNav) med 4 flikar.
* 3. Dölja NavigationBar automatiskt när användaren navigerar djupare
* (t.ex. in till LocationDetailScreen) ren, kontextberoende navigation.
* 4. Visa LockScreen-överlager när AppLockManager rapporterar att appen är låst.
*
* NavigationBar visas ENBART för de 4 toppnivå-destinationerna.
* Alla djupa skärmar (LocationDetail, BoxDetail osv.) använder TopAppBar
* med tillbaka-knapp istället.
*
* Ärver FragmentActivity (inte ComponentActivity direkt) för att stödja
* BiometricPrompt som kräver en FragmentActivity-referens.
*/
@AndroidEntryPoint
class MainActivity : FragmentActivity() {
// Injiceras vid Activity-skapande för att säkerställa att BillingClient
// ansluter tidigt och kontrollerar väntande köp direkt vid appstart.
@Inject
lateinit var billingManager: BillingManager
@Inject
lateinit var appLockManager: AppLockManager
/**
* Referens till NavController för att hantera deep links från widgets via onNewIntent.
* Sätts av LaunchedEffect i setContent kan vara null tills Compose komponerats.
*/
private var navControllerRef: NavHostController? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
appLockManager.onAppStart()
setContent {
BoxOrganizerTheme {
val isLocked by appLockManager.isLocked.collectAsStateWithLifecycle()
val navController = rememberNavController()
// Registrera referens för onNewIntent-hantering
LaunchedEffect(navController) {
navControllerRef = navController
}
Box(modifier = Modifier.fillMaxSize()) {
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = { AppBottomNavigationBar(navController = navController) },
) { innerPadding ->
AppNavGraph(
navController = navController,
modifier = Modifier.padding(innerPadding),
)
}
if (isLocked) {
LockScreen(onAuthenticated = {})
}
}
}
}
}
/**
* Hanterar deep links när appen redan är öppen (launchMode="singleTop").
* Kallas t.ex. när användaren trycker widgeten medan appen är igång.
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent) // Uppdaterar aktivitetens current intent
navControllerRef?.handleDeepLink(intent)
}
override fun onResume() {
super.onResume()
appLockManager.onAppForeground()
}
override fun onPause() {
super.onPause()
appLockManager.onAppBackground()
}
}
// ── BottomNav ─────────────────────────────────────────────────────────────────
private data class BottomNavItem(
val route: String,
val label: String,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
val contentDescription: String,
)
private val bottomNavItems = listOf(
BottomNavItem(
route = Screen.Home.route,
label = "Hem",
selectedIcon = Icons.Filled.Home,
unselectedIcon = Icons.Outlined.Home,
contentDescription = "Hem",
),
BottomNavItem(
route = Screen.Search.route,
label = "Sök",
selectedIcon = Icons.Filled.Search,
unselectedIcon = Icons.Outlined.Search,
contentDescription = "Sök",
),
BottomNavItem(
route = Screen.QrScanner.route,
label = "Skanna",
selectedIcon = Icons.Filled.QrCodeScanner,
unselectedIcon = Icons.Filled.QrCodeScanner,
contentDescription = "Skanna QR-kod",
),
BottomNavItem(
route = Screen.Settings.route,
label = "Inställningar",
selectedIcon = Icons.Filled.Settings,
unselectedIcon = Icons.Outlined.Settings,
contentDescription = "Inställningar",
),
)
/** Rutter där NavigationBar ska visas. */
private val topLevelRoutes = setOf(
Screen.Home.route,
Screen.Search.route,
Screen.QrScanner.route,
Screen.Settings.route,
)
@Composable
private fun AppBottomNavigationBar(navController: NavHostController) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
// Dölj NavigationBar på djupa skärmar (LocationDetail, BoxDetail osv.)
if (currentRoute !in topLevelRoutes) return
NavigationBar {
bottomNavItems.forEach { item ->
val selected = currentRoute == item.route
NavigationBarItem(
selected = selected,
onClick = {
navController.navigate(item.route) {
// Poppa upp till startdestination för att undvika
// stor backstack vid flikbyten
popUpTo(Screen.Home.route) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = {
Icon(
imageVector = if (selected) item.selectedIcon else item.unselectedIcon,
contentDescription = item.contentDescription,
)
},
label = { Text(item.label) },
)
}
}
}

View file

@ -0,0 +1,69 @@
package com.roundingmobile.boi.data.backup
import android.util.Base64
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
import javax.inject.Inject
import javax.inject.Singleton
/**
* BackupCrypto AES-256-CBC-kryptering för backup-filer.
*
* Nyckelderivering: PBKDF2WithHmacSHA256(email, SALT, 65536 iterationer, 256 bitar).
* Nyckeln är deterministisk samma Google-e-post ger alltid samma nyckel,
* vilket möjliggör återställning en ny enhet med samma konto.
*
* Utdataformat: IV (16 bytes) | krypterad data (resten).
* IV är slumpmässig per krypteringsanrop ger semantisk säkerhet.
*
* SALT är app-specifikt och inte en hemlighet. Det förhindrar
* pre-computed dictionary attacks men ger inte konfidentialitet
* utöver vad Google Drive-åtkomsten redan ger.
*/
@Singleton
class BackupCrypto @Inject constructor() {
companion object {
private const val ALGORITHM = "AES/CBC/PKCS5Padding"
private const val KDF = "PBKDF2WithHmacSHA256"
private const val KDF_ITERATIONS = 65536
private const val KEY_BITS = 256
private const val IV_SIZE = 16
// App-specifikt salt — base64("BoxOrganizerBackupSalt2024")
private val SALT: ByteArray = Base64.decode(
"Qm94T3JnYW5pemVyQmFja3VwU2FsdDIwMjQ=",
Base64.DEFAULT,
)
}
fun encrypt(data: ByteArray, accountEmail: String): ByteArray {
val key = deriveKey(accountEmail)
val iv = ByteArray(IV_SIZE).also { SecureRandom().nextBytes(it) }
val cipher = Cipher.getInstance(ALGORITHM)
cipher.init(Cipher.ENCRYPT_MODE, key, IvParameterSpec(iv))
val encrypted = cipher.doFinal(data)
return iv + encrypted
}
fun decrypt(data: ByteArray, accountEmail: String): ByteArray {
require(data.size > IV_SIZE) { "Ogiltig backupdata: för kort" }
val key = deriveKey(accountEmail)
val iv = data.copyOfRange(0, IV_SIZE)
val ciphertext = data.copyOfRange(IV_SIZE, data.size)
val cipher = Cipher.getInstance(ALGORITHM)
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
return cipher.doFinal(ciphertext)
}
private fun deriveKey(email: String): SecretKeySpec {
val factory = SecretKeyFactory.getInstance(KDF)
val spec = PBEKeySpec(email.toCharArray(), SALT, KDF_ITERATIONS, KEY_BITS)
val secret = factory.generateSecret(spec)
return SecretKeySpec(secret.encoded, "AES")
}
}

View file

@ -0,0 +1,39 @@
package com.roundingmobile.boi.data.backup
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.work.Constraints
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import dagger.hilt.android.AndroidEntryPoint
/**
* BackupRetryReceiver BroadcastReceiver för "Försök igen"-knappen i felnotisen.
*
* Schemalägger en engångsbackup med WorkManager.
* NetworkType.CONNECTED (inte UNMETERED) för att retry-backup ska köra
* även mobilt data användaren valde aktivt att försöka igen.
*/
@AndroidEntryPoint
class BackupRetryReceiver : BroadcastReceiver() {
companion object {
const val ACTION_RETRY_BACKUP = "com.roundingmobile.boi.ACTION_RETRY_BACKUP"
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_RETRY_BACKUP) return
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<BackupWorker>()
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueue(request)
}
}

View file

@ -0,0 +1,303 @@
package com.roundingmobile.boi.data.backup
import com.roundingmobile.boi.data.local.dao.BackupDao
import com.roundingmobile.boi.data.local.entity.BoxEntity
import com.roundingmobile.boi.data.local.entity.ItemEntity
import com.roundingmobile.boi.data.local.entity.ItemPhotoEntity
import com.roundingmobile.boi.data.local.entity.ItemTagEntity
import com.roundingmobile.boi.data.local.entity.LocationEntity
import com.roundingmobile.boi.data.local.entity.StorageRoomEntity
import com.roundingmobile.boi.data.local.entity.TagEntity
import com.roundingmobile.boi.util.CoroutineDispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import javax.inject.Inject
import javax.inject.Singleton
/**
* BackupSerializer serialiserar och deserialiserar hela databasen som JSON.
*
* Format:
* {
* "version": 1,
* "exportedAt": <epoch-millis>,
* "locations": [...],
* "rooms": [...],
* "boxes": [...],
* "items": [...],
* "photos": [...],
* "tags": [...],
* "itemTags": [...]
* }
*
* Alla entity-ID:n bevaras för att möjliggöra korrekt återställning
* med REPLACE-strategi i BackupDao.
*/
@Singleton
class BackupSerializer @Inject constructor(
private val backupDao: BackupDao,
private val dispatchers: CoroutineDispatchers,
) {
// ── Serialisering ──────────────────────────────────────────────────────────
suspend fun serialize(): ByteArray = withContext(dispatchers.io) {
val locations = backupDao.getAllLocations()
val rooms = backupDao.getAllRooms()
val boxes = backupDao.getAllBoxes()
val items = backupDao.getAllItems()
val photos = backupDao.getAllPhotos()
val tags = backupDao.getAllTags()
val itemTags = backupDao.getAllItemTags()
val root = JSONObject().apply {
put("version", BACKUP_VERSION)
put("exportedAt", System.currentTimeMillis())
put("locations", serializeLocations(locations))
put("rooms", serializeRooms(rooms))
put("boxes", serializeBoxes(boxes))
put("items", serializeItems(items))
put("photos", serializePhotos(photos))
put("tags", serializeTags(tags))
put("itemTags", serializeItemTags(itemTags))
}
root.toString().toByteArray(Charsets.UTF_8)
}
private fun serializeLocations(list: List<LocationEntity>): JSONArray =
JSONArray().also { arr ->
list.forEach { e ->
arr.put(JSONObject().apply {
put("id", e.id)
put("name", e.name)
put("description", e.description)
putOpt("photoPath", e.photoPath)
put("createdAt", e.createdAt)
put("updatedAt", e.updatedAt)
})
}
}
private fun serializeRooms(list: List<StorageRoomEntity>): JSONArray =
JSONArray().also { arr ->
list.forEach { e ->
arr.put(JSONObject().apply {
put("id", e.id)
put("locationId", e.locationId)
put("name", e.name)
put("description", e.description)
putOpt("photoPath", e.photoPath)
put("createdAt", e.createdAt)
put("updatedAt", e.updatedAt)
})
}
}
private fun serializeBoxes(list: List<BoxEntity>): JSONArray =
JSONArray().also { arr ->
list.forEach { e ->
arr.put(JSONObject().apply {
put("id", e.id)
put("roomId", e.roomId)
put("name", e.name)
put("description", e.description)
putOpt("qrCode", e.qrCode)
putOpt("color", e.color)
putOpt("photoPath", e.photoPath)
put("createdAt", e.createdAt)
put("updatedAt", e.updatedAt)
})
}
}
private fun serializeItems(list: List<ItemEntity>): JSONArray =
JSONArray().also { arr ->
list.forEach { e ->
arr.put(JSONObject().apply {
put("id", e.id)
put("boxId", e.boxId)
put("name", e.name)
put("description", e.description)
put("quantity", e.quantity)
if (e.value != null) put("value", e.value) else put("value", JSONObject.NULL)
putOpt("unit", e.unit)
put("notes", e.notes)
put("createdAt", e.createdAt)
put("updatedAt", e.updatedAt)
})
}
}
private fun serializePhotos(list: List<ItemPhotoEntity>): JSONArray =
JSONArray().also { arr ->
list.forEach { e ->
arr.put(JSONObject().apply {
put("id", e.id)
put("itemId", e.itemId)
put("filePath", e.filePath)
put("sortOrder", e.sortOrder)
put("createdAt", e.createdAt)
})
}
}
private fun serializeTags(list: List<TagEntity>): JSONArray =
JSONArray().also { arr ->
list.forEach { e ->
arr.put(JSONObject().apply {
put("id", e.id)
put("name", e.name)
put("colorHex", e.colorHex)
put("createdAt", e.createdAt)
})
}
}
private fun serializeItemTags(list: List<ItemTagEntity>): JSONArray =
JSONArray().also { arr ->
list.forEach { e ->
arr.put(JSONObject().apply {
put("itemId", e.itemId)
put("tagId", e.tagId)
})
}
}
// ── Deserialisering ────────────────────────────────────────────────────────
fun deserialize(data: ByteArray): BackupEntities {
val root = JSONObject(String(data, Charsets.UTF_8))
// version-kontroll för framtida migration
val version = root.optInt("version", 1)
require(version <= BACKUP_VERSION) {
"Okänd backup-version: $version (app stöder max $BACKUP_VERSION)"
}
return BackupEntities(
locations = deserializeLocations(root.getJSONArray("locations")),
rooms = deserializeRooms(root.getJSONArray("rooms")),
boxes = deserializeBoxes(root.getJSONArray("boxes")),
items = deserializeItems(root.getJSONArray("items")),
photos = deserializePhotos(root.getJSONArray("photos")),
tags = deserializeTags(root.getJSONArray("tags")),
itemTags = deserializeItemTags(root.getJSONArray("itemTags")),
)
}
private fun deserializeLocations(arr: JSONArray): List<LocationEntity> =
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
LocationEntity(
id = o.getLong("id"),
name = o.getString("name"),
description = o.optString("description", ""),
photoPath = o.optNullableString("photoPath"),
createdAt = o.getLong("createdAt"),
updatedAt = o.getLong("updatedAt"),
)
}
private fun deserializeRooms(arr: JSONArray): List<StorageRoomEntity> =
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
StorageRoomEntity(
id = o.getLong("id"),
locationId = o.getLong("locationId"),
name = o.getString("name"),
description = o.optString("description", ""),
photoPath = o.optNullableString("photoPath"),
createdAt = o.getLong("createdAt"),
updatedAt = o.getLong("updatedAt"),
)
}
private fun deserializeBoxes(arr: JSONArray): List<BoxEntity> =
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
BoxEntity(
id = o.getLong("id"),
roomId = o.getLong("roomId"),
name = o.getString("name"),
description = o.optString("description", ""),
qrCode = o.optNullableString("qrCode"),
color = o.optNullableString("color"),
photoPath = o.optNullableString("photoPath"),
createdAt = o.getLong("createdAt"),
updatedAt = o.getLong("updatedAt"),
)
}
private fun deserializeItems(arr: JSONArray): List<ItemEntity> =
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
ItemEntity(
id = o.getLong("id"),
boxId = o.getLong("boxId"),
name = o.getString("name"),
description = o.optString("description", ""),
quantity = o.optInt("quantity", 1),
value = if (o.isNull("value")) null else o.optDouble("value").takeIf { !it.isNaN() },
unit = o.optNullableString("unit"),
notes = o.optString("notes", ""),
createdAt = o.getLong("createdAt"),
updatedAt = o.getLong("updatedAt"),
)
}
private fun deserializePhotos(arr: JSONArray): List<ItemPhotoEntity> =
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
ItemPhotoEntity(
id = o.getLong("id"),
itemId = o.getLong("itemId"),
filePath = o.getString("filePath"),
sortOrder = o.optInt("sortOrder", 0),
createdAt = o.getLong("createdAt"),
)
}
private fun deserializeTags(arr: JSONArray): List<TagEntity> =
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
TagEntity(
id = o.getLong("id"),
name = o.getString("name"),
colorHex = o.optString("colorHex", "#6650A4"),
createdAt = o.getLong("createdAt"),
)
}
private fun deserializeItemTags(arr: JSONArray): List<ItemTagEntity> =
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
ItemTagEntity(
itemId = o.getLong("itemId"),
tagId = o.getLong("tagId"),
)
}
// ── Hjälpfunktion ──────────────────────────────────────────────────────────
/** Returnerar null om fältet saknas eller är JSON null, annars strängens värde. */
private fun JSONObject.optNullableString(key: String): String? =
if (has(key) && !isNull(key)) getString(key) else null
companion object {
private const val BACKUP_VERSION = 1
}
}
/**
* BackupEntities behållare för alla entity-listor som deserialiserats från en backup.
* Alla listor har sina original-ID:n bevarade för REPLACE-insert.
*/
data class BackupEntities(
val locations: List<LocationEntity>,
val rooms: List<StorageRoomEntity>,
val boxes: List<BoxEntity>,
val items: List<ItemEntity>,
val photos: List<ItemPhotoEntity>,
val tags: List<TagEntity>,
val itemTags: List<ItemTagEntity>,
)

View file

@ -0,0 +1,123 @@
package com.roundingmobile.boi.data.backup
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.roundingmobile.boi.BuildConfig
import com.roundingmobile.boi.MainActivity
import com.roundingmobile.boi.R
import com.roundingmobile.boi.domain.usecase.BackupNowUseCase
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
/**
* BackupWorker CoroutineWorker som kör automatisk backup var 24:e timme.
*
* Schemaläggning i BackupModule:
* PeriodicWorkRequest (24h), WIFI + batteryNotLow constraints.
*
* Notiser:
* - Lyckad backup: "Säkerhetskopia klar" med datum.
* - Misslyckad: "Backup misslyckades" med retry-knapp via BackupRetryReceiver.
*/
@HiltWorker
class BackupWorker @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted params: WorkerParameters,
private val backupNowUseCase: BackupNowUseCase,
) : CoroutineWorker(context, params) {
companion object {
const val WORK_NAME = "backup_periodic"
private const val CHANNEL_ID = "backup_channel"
private const val NOTIFICATION_SUCCESS_ID = 1001
private const val NOTIFICATION_ERROR_ID = 1002
}
override suspend fun doWork(): Result {
ensureNotificationChannel()
return backupNowUseCase().fold(
onSuccess = {
showSuccessNotification()
Result.success()
},
onFailure = { e ->
if (BuildConfig.DEBUG) android.util.Log.e("BackupWorker", "Backup failed", e)
showErrorNotification()
Result.retry()
},
)
}
// ── Notiser ───────────────────────────────────────────────────────────────
private fun ensureNotificationChannel() {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
val channel = NotificationChannel(
CHANNEL_ID,
"Säkerhetskopiering",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Status för automatisk Google Drive-backup"
}
nm.createNotificationChannel(channel)
}
private fun showSuccessNotification() {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val openAppIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntent = PendingIntent.getActivity(
context, 0, openAppIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val sdf = java.text.SimpleDateFormat("d MMM HH:mm", java.util.Locale.getDefault())
val timeStr = sdf.format(java.util.Date())
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle("Säkerhetskopia klar")
.setContentText("Sparad $timeStr")
.setPriority(NotificationCompat.PRIORITY_LOW)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
nm.notify(NOTIFICATION_SUCCESS_ID, notification)
}
private fun showErrorNotification() {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val retryIntent = Intent(context, BackupRetryReceiver::class.java).apply {
action = BackupRetryReceiver.ACTION_RETRY_BACKUP
}
val retryPendingIntent = PendingIntent.getBroadcast(
context, 0, retryIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle("Backup misslyckades")
.setContentText("Kontrollera internetanslutningen och försök igen.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.addAction(0, "Försök igen", retryPendingIntent)
.setAutoCancel(true)
.build()
nm.notify(NOTIFICATION_ERROR_ID, notification)
}
}

View file

@ -0,0 +1,208 @@
package com.roundingmobile.boi.data.billing
import android.app.Activity
import android.content.Context
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.PendingPurchasesParams
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.acknowledgePurchase
import com.android.billingclient.api.queryProductDetails
import com.android.billingclient.api.queryPurchasesAsync
import com.roundingmobile.boi.AppConfig
import com.roundingmobile.boi.BuildConfig
import com.roundingmobile.boi.domain.repository.ProStatusRepository
import com.roundingmobile.boi.util.CoroutineDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* BillingManager ansvarar för hela Play Billing-livscykeln.
*
* Ansluter till BillingClient vid skapande (@Singleton en gång per app-session).
* Hämtar produktdetaljer och kontrollerar ej kvitterade köp automatiskt vid
* anslutning. Sparar PRO-status i ProStatusRepository efter lyckat köp.
*
* Trådsäkerhet: alla suspend-anrop sker dispatchers.io via appScope.
*/
@Singleton
class BillingManager @Inject constructor(
@ApplicationContext private val context: Context,
private val proStatusRepository: ProStatusRepository,
dispatchers: CoroutineDispatchers,
) {
// Applikations-scoped coroutine scope — lever lika länge som processen.
private val appScope = CoroutineScope(SupervisorJob() + dispatchers.io)
private val _productDetails = MutableStateFlow<ProductDetails?>(null)
val productDetails: StateFlow<ProductDetails?> = _productDetails.asStateFlow()
private val _billingState = MutableStateFlow<BillingState>(BillingState.Idle)
val billingState: StateFlow<BillingState> = _billingState.asStateFlow()
// ── PurchasesUpdatedListener ───────────────────────────────────────────────
private val purchasesUpdatedListener = PurchasesUpdatedListener { result, purchases ->
when (result.responseCode) {
BillingClient.BillingResponseCode.OK -> {
purchases?.forEach { purchase ->
appScope.launch { handlePurchase(purchase) }
}
}
BillingClient.BillingResponseCode.USER_CANCELED -> {
// Användaren avbröt — inget fel att visa.
}
else -> {
if (BuildConfig.DEBUG) {
android.util.Log.e("BillingManager", "Purchase error: ${result.debugMessage}")
}
_billingState.value = BillingState.Error("Köpet misslyckades. Försök igen.")
}
}
}
// ── BillingClient ─────────────────────────────────────────────────────────
private val billingClient: BillingClient = BillingClient.newBuilder(context)
.setListener(purchasesUpdatedListener)
.enablePendingPurchases(
PendingPurchasesParams.newBuilder()
.enableOneTimeProducts()
.build()
)
.build()
init {
connect()
}
// ── Anslutning och återanslutning ─────────────────────────────────────────
private fun connect() {
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
appScope.launch {
queryProductDetails()
queryAndHandlePendingPurchases()
}
} else if (BuildConfig.DEBUG) {
android.util.Log.w("BillingManager", "Setup failed: ${result.debugMessage}")
}
}
override fun onBillingServiceDisconnected() {
// Google Play uppdaterades eller tappade kontakten — återanslut efter 5 s.
appScope.launch {
delay(5_000L)
connect()
}
}
})
}
// ── Produktdetaljer ───────────────────────────────────────────────────────
private suspend fun queryProductDetails() {
val product = QueryProductDetailsParams.Product.newBuilder()
.setProductId(AppConfig.PRO_PRODUCT_ID)
.setProductType(BillingClient.ProductType.INAPP)
.build()
val params = QueryProductDetailsParams.newBuilder()
.setProductList(listOf(product))
.build()
val result = billingClient.queryProductDetails(params)
if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
_productDetails.value = result.productDetailsList?.firstOrNull()
} else if (BuildConfig.DEBUG) {
android.util.Log.w("BillingManager", "queryProductDetails: ${result.billingResult.debugMessage}")
}
}
// ── Kontrollera och kvittera väntande köp vid start ───────────────────────
private suspend fun queryAndHandlePendingPurchases() {
val params = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.INAPP)
.build()
val result = billingClient.queryPurchasesAsync(params)
result.purchasesList.forEach { purchase ->
handlePurchase(purchase)
}
}
// ── Hantera köp: kvittera + spara PRO-status ──────────────────────────────
private suspend fun handlePurchase(purchase: Purchase) {
if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) return
if (!purchase.products.contains(AppConfig.PRO_PRODUCT_ID)) return
if (!purchase.isAcknowledged) {
val ackParams = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
val ackResult = billingClient.acknowledgePurchase(ackParams)
if (ackResult.responseCode != BillingClient.BillingResponseCode.OK) {
if (BuildConfig.DEBUG) {
android.util.Log.e("BillingManager", "Ack failed: ${ackResult.debugMessage}")
}
return
}
}
proStatusRepository.setProStatus(true)
_billingState.value = BillingState.PurchaseSuccess
}
// ── Starta köpflödet ──────────────────────────────────────────────────────
/**
* Startar Google Plays köpdialog. Måste anropas från en Activity-kontext.
* Activity-referensen lagras INTE den används bara för det synkrona anropet
* till launchBillingFlow och kasseras direkt efteråt.
*/
fun startPurchase(activity: Activity) {
val details = _productDetails.value
if (details == null) {
_billingState.value = BillingState.Error("Produktinformation ej tillgänglig, försök igen.")
return
}
if (!billingClient.isReady) {
_billingState.value = BillingState.Error("Anslutning till Play Store saknas, försök igen.")
return
}
val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(details)
.build()
val flowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetailsParams))
.build()
val result = billingClient.launchBillingFlow(activity, flowParams)
if (result.responseCode != BillingClient.BillingResponseCode.OK && BuildConfig.DEBUG) {
android.util.Log.e("BillingManager", "launchBillingFlow: ${result.debugMessage}")
}
}
fun resetBillingState() {
_billingState.value = BillingState.Idle
}
}

View file

@ -0,0 +1,14 @@
package com.roundingmobile.boi.data.billing
/**
* BillingState representerar utfall av ett Play Billing-flöde.
*
* Idle inget pågående köpflöde.
* PurchaseSuccess köpet bekräftat och kvitterat; ProStatusRepository uppdaterat.
* Error ett fel uppstod (användaren visas ett snackbar-meddelande).
*/
sealed class BillingState {
data object Idle : BillingState()
data object PurchaseSuccess : BillingState()
data class Error(val message: String) : BillingState()
}

View file

@ -0,0 +1,150 @@
package com.roundingmobile.boi.data.importing
import com.roundingmobile.boi.data.backup.BackupEntities
import com.roundingmobile.boi.data.local.dao.BoxDao
import com.roundingmobile.boi.data.local.dao.ItemDao
import com.roundingmobile.boi.data.local.dao.ItemPhotoDao
import com.roundingmobile.boi.data.local.dao.LocationDao
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.ItemEntity
import com.roundingmobile.boi.data.local.entity.ItemPhotoEntity
import com.roundingmobile.boi.data.local.entity.ItemTagEntity
import com.roundingmobile.boi.data.local.entity.LocationEntity
import com.roundingmobile.boi.data.local.entity.StorageRoomEntity
import com.roundingmobile.boi.data.local.entity.TagEntity
import com.roundingmobile.boi.util.CoroutineDispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
/**
* InventoryMerger infogar importerade entiteter bredvid befintlig data.
*
* Alla entiteter får nya auto-genererade ID:n (id=0 Room tilldelar nytt ID).
* FK-referenser uppdateras via ID-mappningskartor som byggs sekventiellt.
*
* Tagg-deduplicering: om en tagg med samma namn redan finns återanvänds dess ID.
* Det innebär att importerade items kopplas till befintliga globala taggar när möjligt.
*
* QR-koder nollställs (null) för importerade lådor för att undvika unikhetsbrott
* mot det unika indexet BoxEntity.qrCode.
*/
@Singleton
class InventoryMerger @Inject constructor(
private val locationDao: LocationDao,
private val roomDao: StorageRoomDao,
private val boxDao: BoxDao,
private val itemDao: ItemDao,
private val photoDao: ItemPhotoDao,
private val tagDao: TagDao,
private val dispatchers: CoroutineDispatchers,
) {
suspend fun merge(
entities: BackupEntities,
photoPathMap: Map<String, String>,
) = withContext(dispatchers.io) {
// ── 1. Taggar deduplicera mot befintliga ────────────────────────────
val existingTags = tagDao.getAllTags().first().associateBy { it.name }
val tagIdMap = mutableMapOf<Long, Long>() // oldId → newId
entities.tags.forEach { tag ->
val existing = existingTags[tag.name]
if (existing != null) {
tagIdMap[tag.id] = existing.id
} else {
val newId = tagDao.insertTag(
TagEntity(id = 0, name = tag.name, colorHex = tag.colorHex)
)
tagIdMap[tag.id] = newId
}
}
// ── 2. Platser ────────────────────────────────────────────────────────
val locationIdMap = mutableMapOf<Long, Long>()
entities.locations.forEach { loc ->
val newId = locationDao.insert(
LocationEntity(id = 0, name = loc.name, description = loc.description)
)
locationIdMap[loc.id] = newId
}
// ── 3. Rum ────────────────────────────────────────────────────────────
val roomIdMap = mutableMapOf<Long, Long>()
entities.rooms.forEach { room ->
val newLocId = locationIdMap[room.locationId] ?: return@forEach
val newId = roomDao.insert(
StorageRoomEntity(
id = 0,
locationId = newLocId,
name = room.name,
description = room.description,
)
)
roomIdMap[room.id] = newId
}
// ── 4. Lådor qrCode nollställs för att undvika unikhetsbrott ───────
val boxIdMap = mutableMapOf<Long, Long>()
entities.boxes.forEach { box ->
val newRoomId = roomIdMap[box.roomId] ?: return@forEach
val newId = boxDao.insert(
BoxEntity(
id = 0,
roomId = newRoomId,
name = box.name,
description = box.description,
qrCode = null,
color = box.color,
)
)
boxIdMap[box.id] = newId
}
// ── 5. Föremål ────────────────────────────────────────────────────────
val itemIdMap = mutableMapOf<Long, Long>()
entities.items.forEach { item ->
val newBoxId = boxIdMap[item.boxId] ?: return@forEach
val newId = itemDao.insert(
ItemEntity(
id = 0,
boxId = newBoxId,
name = item.name,
description = item.description,
quantity = item.quantity,
value = item.value,
unit = item.unit,
notes = item.notes,
)
)
itemIdMap[item.id] = newId
}
// ── 6. Foton ──────────────────────────────────────────────────────────
entities.photos.forEach { photo ->
val newItemId = itemIdMap[photo.itemId] ?: return@forEach
val originalBaseName = File(photo.filePath).name
val newPath = photoPathMap[originalBaseName] ?: return@forEach
photoDao.insert(
ItemPhotoEntity(
id = 0,
itemId = newItemId,
filePath = newPath,
sortOrder = photo.sortOrder,
)
)
}
// ── 7. Föremål-tagg-kopplingar ────────────────────────────────────────
entities.itemTags.forEach { itemTag ->
val newItemId = itemIdMap[itemTag.itemId] ?: return@forEach
val newTagId = tagIdMap[itemTag.tagId] ?: return@forEach
tagDao.addTagToItem(ItemTagEntity(itemId = newItemId, tagId = newTagId))
}
}
}

View file

@ -0,0 +1,91 @@
package com.roundingmobile.boi.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.roundingmobile.boi.data.local.entity.BoxEntity
import com.roundingmobile.boi.data.local.entity.ItemEntity
import com.roundingmobile.boi.data.local.entity.ItemPhotoEntity
import com.roundingmobile.boi.data.local.entity.ItemTagEntity
import com.roundingmobile.boi.data.local.entity.LocationEntity
import com.roundingmobile.boi.data.local.entity.StorageRoomEntity
import com.roundingmobile.boi.data.local.entity.TagEntity
/**
* BackupDao samlar alla bulk-operationer för backup och återställning.
*
* Läs-queries: hämtar alla entiteter utan filter (för backup-serialisering).
* Clear-queries: rensar databasen inför återställning.
* - clearLocations() triggar CASCADE DELETE för rum, lådor, föremål, foton och item_tags
* (via FK-kedjorna som definierats i respektive Entity).
* - clearTags() rensar eventuella kvarvarande taggar + item_tags via CASCADE.
* Insert-queries: bulk-insert med REPLACE för att bevara original-ID:n från backup.
*
* Ordning vid återställning:
* 1. clearItemTags() förebyggande, CASCADE borde räcka men vi är explicita
* 2. clearLocations() kaskad rensar allt utom tags
* 3. clearTags()
* 4. insertLocations insertTags insertRooms insertBoxes insertItems
* insertPhotos insertItemTags
*/
@Dao
interface BackupDao {
// ── Läs-queries (backup) ───────────────────────────────────────────────────
@Query("SELECT * FROM locations ORDER BY id ASC")
suspend fun getAllLocations(): List<LocationEntity>
@Query("SELECT * FROM storage_rooms ORDER BY id ASC")
suspend fun getAllRooms(): List<StorageRoomEntity>
@Query("SELECT * FROM boxes ORDER BY id ASC")
suspend fun getAllBoxes(): List<BoxEntity>
@Query("SELECT * FROM items ORDER BY id ASC")
suspend fun getAllItems(): List<ItemEntity>
@Query("SELECT * FROM item_photos ORDER BY id ASC")
suspend fun getAllPhotos(): List<ItemPhotoEntity>
@Query("SELECT * FROM tags ORDER BY id ASC")
suspend fun getAllTags(): List<TagEntity>
@Query("SELECT * FROM item_tags")
suspend fun getAllItemTags(): List<ItemTagEntity>
// ── Clear-queries (återställning) ─────────────────────────────────────────
@Query("DELETE FROM item_tags")
suspend fun clearItemTags()
@Query("DELETE FROM locations")
suspend fun clearLocations()
@Query("DELETE FROM tags")
suspend fun clearTags()
// ── Bulk-insert (återställning, REPLACE bevarar original-ID:n) ────────────
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertLocations(entities: List<LocationEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRooms(entities: List<StorageRoomEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertBoxes(entities: List<BoxEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItems(entities: List<ItemEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPhotos(entities: List<ItemPhotoEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTags(entities: List<TagEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItemTags(entities: List<ItemTagEntity>)
}

View file

@ -0,0 +1,41 @@
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 androidx.room.Update
import com.roundingmobile.boi.data.local.entity.BoxEntity
import kotlinx.coroutines.flow.Flow
/**
* BoxDao CRUD-operationer för Box-entiteten.
*
* getBoxByQrCode: används av QR-skannern i Fas 13 för att direkt
* navigera till rätt box. Unikt index qrCode garanterar max ett resultat.
*/
@Dao
interface BoxDao {
@Query("SELECT * FROM boxes WHERE roomId = :roomId ORDER BY name ASC")
fun getBoxesByRoom(roomId: Long): Flow<List<BoxEntity>>
@Query("SELECT * FROM boxes WHERE id = :id")
suspend fun getBoxById(id: Long): BoxEntity?
@Query("SELECT * FROM boxes WHERE id = :id")
fun observeBoxById(id: Long): Flow<BoxEntity?>
@Query("SELECT * FROM boxes WHERE qrCode = :qrCode LIMIT 1")
suspend fun getBoxByQrCode(qrCode: String): BoxEntity?
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(box: BoxEntity): Long
@Update
suspend fun update(box: BoxEntity)
@Delete
suspend fun delete(box: BoxEntity)
}

View file

@ -0,0 +1,66 @@
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 androidx.room.Update
import com.roundingmobile.boi.data.local.entity.ItemEntity
import com.roundingmobile.boi.data.local.entity.RecentItemWidgetData
import kotlinx.coroutines.flow.Flow
/**
* ItemDao CRUD-operationer för Item-entiteten.
*
* Föremål kan ligga i en låda (boxId), direkt i ett rum (roomId) eller
* direkt en plats (locationId). Varje query filtrerar rätt kolumn.
*
* getTotalItemCount() / getTotalItemCountOnce():
* Räknar ALLA items i hela databasen (oavsett placering).
* Används för freemium-gränskontrollen (AppConfig.FREE_ITEM_LIMIT = 500).
*/
@Dao
interface ItemDao {
@Query("SELECT * FROM items WHERE boxId = :boxId ORDER BY name ASC")
fun getItemsByBox(boxId: Long): Flow<List<ItemEntity>>
@Query("SELECT * FROM items WHERE roomId = :roomId ORDER BY name ASC")
fun getItemsByRoom(roomId: Long): Flow<List<ItemEntity>>
@Query("SELECT * FROM items WHERE locationId = :locationId ORDER BY name ASC")
fun getItemsByLocation(locationId: Long): Flow<List<ItemEntity>>
@Query("SELECT * FROM items WHERE id = :id")
suspend fun getItemById(id: Long): ItemEntity?
@Query("SELECT COUNT(*) FROM items")
fun getTotalItemCount(): Flow<Int>
@Query("SELECT COUNT(*) FROM items")
suspend fun getTotalItemCountOnce(): Int
/**
* Returnerar de [limit] senast tillagda föremålen för hemskärmswidgeten (2x2).
* LEFT JOIN eftersom boxId kan vara null (föremål direkt i rum/plats).
* COALESCE ger "" som fallback när boxName saknas.
*/
@Query("""
SELECT i.id AS id, i.name AS name, COALESCE(b.name, '—') AS boxName
FROM items i
LEFT JOIN boxes b ON i.boxId = b.id
ORDER BY i.createdAt DESC
LIMIT :limit
""")
suspend fun getRecentItemsWithBox(limit: Int): List<RecentItemWidgetData>
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(item: ItemEntity): Long
@Update
suspend fun update(item: ItemEntity)
@Delete
suspend fun delete(item: ItemEntity)
}

View file

@ -0,0 +1,48 @@
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 androidx.room.Update
import com.roundingmobile.boi.data.local.entity.ItemPhotoEntity
import kotlinx.coroutines.flow.Flow
/**
* ItemPhotoDao CRUD-operationer för ItemPhoto-entiteten.
*
* getPhotoCountForItem(): kontrolleras av UseCase innan ett nytt foto sparas.
* Om count >= FREE_PHOTOS_PER_ITEM och !isPro visa PRO-uppgradering.
*
* deleteAllPhotosForItem(): används vid borttagning av ett item
* (komplement till CASCADE DELETE) för att explicit rensa filsystemet
* via RepositoryImpl innan Room-raden raderas.
*/
@Dao
interface ItemPhotoDao {
@Query("SELECT * FROM item_photos WHERE itemId = :itemId ORDER BY sortOrder ASC, createdAt ASC")
fun getPhotosByItem(itemId: Long): Flow<List<ItemPhotoEntity>>
@Query("SELECT COUNT(*) FROM item_photos WHERE itemId = :itemId")
suspend fun getPhotoCountForItem(itemId: Long): Int
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(photo: ItemPhotoEntity): Long
@Update
suspend fun update(photo: ItemPhotoEntity)
@Delete
suspend fun delete(photo: ItemPhotoEntity)
@Query("DELETE FROM item_photos WHERE itemId = :itemId")
suspend fun deleteAllPhotosForItem(itemId: Long)
@Query(
"SELECT * FROM item_photos WHERE itemId = :itemId " +
"ORDER BY sortOrder ASC, createdAt ASC LIMIT 1"
)
suspend fun getFirstPhotoForItem(itemId: Long): ItemPhotoEntity?
}

View file

@ -0,0 +1,58 @@
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 androidx.room.Update
import com.roundingmobile.boi.data.local.entity.LocationEntity
import com.roundingmobile.boi.data.local.entity.LocationWithCounts
import kotlinx.coroutines.flow.Flow
/**
* LocationDao CRUD-operationer för Location-entiteten.
*
* getAllLocations() returnerar Flow: UI uppdateras automatiskt när
* data ändras i databasen utan att ViewModel behöver göra om queryn.
*
* OnConflictStrategy.ABORT (default): kasta SQLiteConstraintException
* om en Location med samma primärnyckel redan finns. Hanteras som
* Result.failure() i RepositoryImpl.
*/
@Dao
interface LocationDao {
@Query("SELECT * FROM locations ORDER BY name ASC")
fun getAllLocations(): Flow<List<LocationEntity>>
@Query("""
SELECT locations.*,
(SELECT COUNT(*) FROM storage_rooms WHERE storage_rooms.locationId = locations.id) AS room_count,
(SELECT COUNT(*) FROM items
INNER JOIN boxes ON items.boxId = boxes.id
INNER JOIN storage_rooms sr ON boxes.roomId = sr.id
WHERE sr.locationId = locations.id) AS item_count
FROM locations
ORDER BY locations.name ASC
""")
fun getLocationsWithCounts(): Flow<List<LocationWithCounts>>
@Query("SELECT * FROM locations WHERE id = :id")
suspend fun getLocationById(id: Long): LocationEntity?
@Query("SELECT * FROM locations WHERE id = :id")
fun observeLocationById(id: Long): Flow<LocationEntity?>
@Query("SELECT COUNT(*) FROM locations")
fun getLocationCount(): Flow<Int>
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(location: LocationEntity): Long
@Update
suspend fun update(location: LocationEntity)
@Delete
suspend fun delete(location: LocationEntity)
}

View file

@ -0,0 +1,52 @@
package com.roundingmobile.boi.data.local.dao
import androidx.room.Dao
import androidx.room.Query
import com.roundingmobile.boi.data.local.entity.BoxEntity
import com.roundingmobile.boi.data.local.entity.ItemEntity
import com.roundingmobile.boi.data.local.entity.LocationEntity
import kotlinx.coroutines.flow.Flow
/**
* SearchDao global sökning via FTS4 (föremål) och LIKE (lådor, platser).
*
* searchItems: subquery-mönster via FTS4-indexets rowid.
* Söktermen ska vara formaterad med trailing "*" för prefix-matching,
* t.ex. "ring*" hittar "ring", "ringen", "ringar".
* Flera ord: "ring* silver*" BÅDA orden måste matcha (AND-semantik).
*
* searchBoxes / searchLocations: LIKE-sökning räcker för de lägre volymerna.
* Parametern ska vara "%sökterm%" (inkl. procent-tecken).
*/
@Dao
interface SearchDao {
@Query(
"""
SELECT * FROM items
WHERE items.rowid IN (
SELECT rowid FROM items_fts WHERE items_fts MATCH :query
)
ORDER BY items.name ASC
"""
)
fun searchItems(query: String): Flow<List<ItemEntity>>
@Query(
"""
SELECT * FROM boxes
WHERE name LIKE :query OR description LIKE :query
ORDER BY name ASC
"""
)
fun searchBoxes(query: String): Flow<List<BoxEntity>>
@Query(
"""
SELECT * FROM locations
WHERE name LIKE :query OR description LIKE :query
ORDER BY name ASC
"""
)
fun searchLocations(query: String): Flow<List<LocationEntity>>
}

View file

@ -0,0 +1,38 @@
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 androidx.room.Update
import com.roundingmobile.boi.data.local.entity.StorageRoomEntity
import kotlinx.coroutines.flow.Flow
/**
* StorageRoomDao CRUD-operationer för StorageRoom-entiteten.
*
* Alla queries filtrerar per locationId rum visas alltid
* i kontexten av sin location, aldrig globalt.
*/
@Dao
interface StorageRoomDao {
@Query("SELECT * FROM storage_rooms WHERE locationId = :locationId ORDER BY name ASC")
fun getRoomsByLocation(locationId: Long): Flow<List<StorageRoomEntity>>
@Query("SELECT * FROM storage_rooms WHERE id = :id")
suspend fun getRoomById(id: Long): StorageRoomEntity?
@Query("SELECT * FROM storage_rooms WHERE id = :id")
fun observeRoomById(id: Long): Flow<StorageRoomEntity?>
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(room: StorageRoomEntity): Long
@Update
suspend fun update(room: StorageRoomEntity)
@Delete
suspend fun delete(room: StorageRoomEntity)
}

View file

@ -0,0 +1,61 @@
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 androidx.room.Update
import com.roundingmobile.boi.data.local.entity.ItemTagEntity
import com.roundingmobile.boi.data.local.entity.TagEntity
import kotlinx.coroutines.flow.Flow
/**
* TagDao CRUD för Tag-entiteten och ItemTag-pivottabellen.
*
* Taggar hanteras i ett enda DAO eftersom pivot-operationerna
* (add/remove tag from item) är tätt kopplade till Tag-domänen.
*
* getTagsForItem(): JOIN-query som returnerar alla taggar för ett specifikt
* item via pivot-tabellen. Används i ItemDetailScreen.
*
* addTagToItem() / removeTagFromItem(): opererar ItemTagEntity direkt.
* IGNORE insert: om kopplingen redan finns, ignoreras det tyst.
*/
@Dao
interface TagDao {
@Query("SELECT * FROM tags ORDER BY name ASC")
fun getAllTags(): Flow<List<TagEntity>>
@Query("SELECT * FROM tags WHERE id = :id")
suspend fun getTagById(id: Long): TagEntity?
@Query(
"""
SELECT tags.* FROM tags
INNER JOIN item_tags ON tags.id = item_tags.tagId
WHERE item_tags.itemId = :itemId
ORDER BY tags.name ASC
"""
)
fun getTagsForItem(itemId: Long): Flow<List<TagEntity>>
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insertTag(tag: TagEntity): Long
@Update
suspend fun updateTag(tag: TagEntity)
@Delete
suspend fun deleteTag(tag: TagEntity)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun addTagToItem(itemTag: ItemTagEntity)
@Delete
suspend fun removeTagFromItem(itemTag: ItemTagEntity)
@Query("DELETE FROM item_tags WHERE itemId = :itemId")
suspend fun removeAllTagsFromItem(itemId: Long)
}

View file

@ -0,0 +1,181 @@
package com.roundingmobile.boi.data.local.database
import androidx.room.Database
import androidx.room.RoomDatabase
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.ItemDao
import com.roundingmobile.boi.data.local.dao.ItemPhotoDao
import com.roundingmobile.boi.data.local.dao.LocationDao
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.ItemEntity
import com.roundingmobile.boi.data.local.entity.ItemFtsEntity
import com.roundingmobile.boi.data.local.entity.ItemPhotoEntity
import com.roundingmobile.boi.data.local.entity.ItemTagEntity
import com.roundingmobile.boi.data.local.entity.LocationEntity
import com.roundingmobile.boi.data.local.entity.StorageRoomEntity
import com.roundingmobile.boi.data.local.entity.TagEntity
/**
* AppDatabase den enda Room-instansen för hela appen.
*
* version = 1: Fas 2-schemat är det definitiva v1-schemat.
* Det minimala BoxEntity-schemat från Fas 1 var enbart en kompilationstest.
* OBS för utveckling: om Fas 1-appen installerats, rensa appdata innan
* Fas 2 körs (adb shell pm clear com.roundingmobile.boi).
*
* exportSchema = true: Room genererar schema-JSON till /schemas/.
* Dessa JSON-filer ska committas till git att migrationer kan verifieras
* mot det faktiska schemat vid kodgranskning.
*
* SQLCipher-kryptering konfigureras i DatabaseModule via SupportFactory.
* AppDatabase känner inte till krypteringsnyckeln separation of concerns.
*/
@Database(
entities = [
LocationEntity::class,
StorageRoomEntity::class,
BoxEntity::class,
ItemEntity::class,
ItemPhotoEntity::class,
TagEntity::class,
ItemTagEntity::class,
ItemFtsEntity::class,
],
version = 6,
exportSchema = true,
)
abstract class AppDatabase : RoomDatabase() {
abstract fun backupDao(): BackupDao
abstract fun locationDao(): LocationDao
abstract fun storageRoomDao(): StorageRoomDao
abstract fun boxDao(): BoxDao
abstract fun itemDao(): ItemDao
abstract fun itemPhotoDao(): ItemPhotoDao
abstract fun tagDao(): TagDao
abstract fun searchDao(): SearchDao
companion object {
/**
* Migreringsplats: lägg alltid till migrering HÄR innan du ökar version i @Database.
*
* Steg för framtida schemaändringar:
* 1. Gör dina ändringar i Entity-filerna
* 2. Öka version i @Database med 1
* 3. Implementera migreringen nedan med rätt SQL
* 4. Lägg till migreringen i addMigrations() i DatabaseModule
* 5. Bygg projektet Room validerar att schemat matchar
*
* ALDRIG fallbackToDestructiveMigration() i produktion det raderar användardata.
*/
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE locations ADD COLUMN photoPath TEXT")
}
}
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE storage_rooms ADD COLUMN photoPath TEXT")
}
}
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE boxes ADD COLUMN color TEXT")
db.execSQL("ALTER TABLE boxes ADD COLUMN photoPath TEXT")
}
}
/**
* Fas 12 Skapar FTS4-virtuell tabell för global fulltextsökning.
*
* content=`items` innebär att Room automatiskt skapar triggers som
* håller FTS-indexet synkroniserat med items-tabellen.
* IF NOT EXISTS skyddar mot dubbel exekvering vid rensad appdata.
*/
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE VIRTUAL TABLE IF NOT EXISTS `items_fts` " +
"USING fts4(content=`items`, `name`, `description`, `notes`)"
)
}
}
/**
* Gör boxId nullable och lägger till roomId + locationId items-tabellen,
* att föremål kan ligga direkt i ett rum eller direkt en plats.
*
* SQLite stöder inte ALTER COLUMN tabellen återskapas med korrekt schema.
* FTS-tabellen och dess sync-triggers återskapas också efter DROP TABLE items.
*/
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
// 1. Skapa ny items-tabell med nullable boxId och nya FK-kolumner
db.execSQL("""
CREATE TABLE `items_new` (
`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
)
""".trimIndent())
// 2. Kopiera befintlig data (alla får roomId=NULL och locationId=NULL)
db.execSQL("""
INSERT INTO `items_new`
(`id`, `boxId`, `roomId`, `locationId`, `name`, `description`,
`quantity`, `value`, `unit`, `notes`, `createdAt`, `updatedAt`)
SELECT `id`, `boxId`, NULL, NULL, `name`, `description`,
`quantity`, `value`, `unit`, `notes`, `createdAt`, `updatedAt`
FROM `items`
""".trimIndent())
// 3. Ta bort gamla tabellen (tar även med gamla FTS sync-triggers)
db.execSQL("DROP TABLE `items`")
// 4. Byt namn
db.execSQL("ALTER TABLE `items_new` RENAME TO `items`")
// 5. Återskapa index
db.execSQL("CREATE INDEX IF NOT EXISTS `index_items_boxId` ON `items` (`boxId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_items_roomId` ON `items` (`roomId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_items_locationId` ON `items` (`locationId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_items_name` ON `items` (`name`)")
// 6. Återskapa FTS-tabell
db.execSQL("DROP TABLE IF EXISTS `items_fts`")
db.execSQL(
"CREATE VIRTUAL TABLE IF NOT EXISTS `items_fts` " +
"USING FTS4(`name` TEXT NOT NULL, `description` TEXT NOT NULL, " +
"`notes` TEXT NOT NULL, content=`items`)"
)
// 7. Återskapa Room:s FTS sync-triggers (exakta namn/SQL från schema v5)
db.execSQL("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")
db.execSQL("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")
db.execSQL("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")
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")
}
}
}
}

View file

@ -0,0 +1,42 @@
package com.roundingmobile.boi.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* BoxEntity en låda eller behållare inuti ett StorageRoom.
*
* qrCode: nullable genereras begäran i Fas 13. Unikt index möjliggör
* snabb uppslag vid QR-skanning utan full table scan.
*
* FK roomId storage_rooms.id med CASCADE DELETE.
*/
@Entity(
tableName = "boxes",
foreignKeys = [
ForeignKey(
entity = StorageRoomEntity::class,
parentColumns = ["id"],
childColumns = ["roomId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [
Index("roomId"),
Index(value = ["qrCode"], unique = true),
],
)
data class BoxEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val roomId: Long,
val name: String,
val description: String = "",
val qrCode: String? = null,
val color: String? = null,
val photoPath: String? = null,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
)

View file

@ -0,0 +1,57 @@
package com.roundingmobile.boi.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* ItemEntity ett föremål som kan ligga i en Box, direkt i ett Rum eller direkt en Plats.
*
* Exakt ett av [boxId], [roomId], [locationId] är non-null:
* boxId != null föremålet är i en specifik låda
* roomId != null föremålet ligger direkt i ett rum (ingen låda)
* locationId != null föremålet ligger direkt en plats (inget rum/låda)
*
* Freemium-gränsen (FREE_ITEM_LIMIT = 500) kontrolleras i UseCase
* via getTotalItemCountOnce() INNAN insert anropas.
*/
@Entity(
tableName = "items",
foreignKeys = [
ForeignKey(
entity = BoxEntity::class,
parentColumns = ["id"],
childColumns = ["boxId"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = StorageRoomEntity::class,
parentColumns = ["id"],
childColumns = ["roomId"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = LocationEntity::class,
parentColumns = ["id"],
childColumns = ["locationId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("boxId"), Index("roomId"), Index("locationId"), Index("name")],
)
data class ItemEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val boxId: Long? = null,
val roomId: Long? = null,
val locationId: Long? = null,
val name: String,
val description: String = "",
val quantity: Int = 1,
val value: Double? = null,
val unit: String? = null,
val notes: String = "",
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
)

View file

@ -0,0 +1,27 @@
package com.roundingmobile.boi.data.local.entity
import androidx.room.Entity
import androidx.room.Fts4
/**
* ItemFtsEntity FTS4-virtuell tabell för global fulltextsökning items.
*
* contentEntity = ItemEntity::class: Room skapar automatiskt triggers som
* håller FTS-indexet synkroniserat med items-tabellen vid INSERT/UPDATE/DELETE.
* Inga manuella synkroniseringsanrop behövs.
*
* Indexerade kolumner: name, description, notes de kolumner användaren
* troligtvis söker i. boxId och andra FK/numeriska fält indexeras inte
* eftersom FTS är optimerat för text, inte för numeriska värden.
*
* Användning i SearchDao:
* SELECT items.* FROM items WHERE rowid IN
* (SELECT rowid FROM items_fts WHERE items_fts MATCH :query)
*/
@Entity(tableName = "items_fts")
@Fts4(contentEntity = ItemEntity::class)
data class ItemFtsEntity(
val name: String,
val description: String,
val notes: String,
)

View file

@ -0,0 +1,40 @@
package com.roundingmobile.boi.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* ItemPhotoEntity en foto kopplad till ett Item.
*
* filePath: absolut sökväg inuti context.filesDir (internt lagring).
* ALDRIG externt lagring se säkerhetsregler i CLAUDE.md.
* Format: "{filesDir}/photos/{itemId}_{timestamp}.jpg"
*
* sortOrder: styr visningsordning i PhotoGrid-komponenten.
* 0 = förstafoto (visas som thumbnail i ItemCard).
*
* Freemium-gränsen (FREE_PHOTOS_PER_ITEM = 3) kontrolleras av
* getPhotoCountForItem() i UseCase INNAN insert anropas.
*/
@Entity(
tableName = "item_photos",
foreignKeys = [
ForeignKey(
entity = ItemEntity::class,
parentColumns = ["id"],
childColumns = ["itemId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("itemId")],
)
data class ItemPhotoEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val itemId: Long,
val filePath: String,
val sortOrder: Int = 0,
val createdAt: Long = System.currentTimeMillis(),
)

View file

@ -0,0 +1,42 @@
package com.roundingmobile.boi.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
/**
* ItemTagEntity many-to-many pivot-tabell mellan Item och Tag.
*
* Sammansatt primärnyckel (itemId, tagId) garanterar att samma tagg
* inte kan kopplas till samma föremål mer än en gång.
*
* Båda foreign keys har CASCADE DELETE:
* - Om ett Item raderas alla dess tag-kopplingar raderas automatiskt.
* - Om en Tag raderas alla kopplingar till den taggen raderas automatiskt.
*
* Separata index itemId och tagId möjliggör effektiv sökning
* i båda riktningarna (items per tag, tags per item).
*/
@Entity(
tableName = "item_tags",
primaryKeys = ["itemId", "tagId"],
foreignKeys = [
ForeignKey(
entity = ItemEntity::class,
parentColumns = ["id"],
childColumns = ["itemId"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = TagEntity::class,
parentColumns = ["id"],
childColumns = ["tagId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("itemId"), Index("tagId")],
)
data class ItemTagEntity(
val itemId: Long,
val tagId: Long,
)

View file

@ -0,0 +1,26 @@
package com.roundingmobile.boi.data.local.entity
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* LocationEntity representerar en fysisk plats (t.ex. "Garage", "Källare", "Sovrum").
*
* Hierarki: Location StorageRoom Box Item
*
* index name: möjliggör snabb sökning och sortering per namn utan full table scan.
*/
@Entity(
tableName = "locations",
indices = [Index(value = ["name"])],
)
data class LocationEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val name: String,
val description: String = "",
val photoPath: String? = null,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
)

View file

@ -0,0 +1,19 @@
package com.roundingmobile.boi.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Embedded
/**
* LocationWithCounts Room-resultatklass för aggregerad location-data.
*
* Används av getLocationsWithCounts()-queryn i LocationDao.
* Inte en @Entity Room skapar ingen tabell för den, enbart ett mappningsobjekt.
*
* @Embedded: Room mappar alla kolumner från locations-tabellen till location-fältet.
* @ColumnInfo: kolumnnamnen måste matcha SQL-aliaserna exakt.
*/
data class LocationWithCounts(
@Embedded val location: LocationEntity,
@ColumnInfo(name = "room_count") val roomCount: Int,
@ColumnInfo(name = "item_count") val itemCount: Int,
)

View file

@ -0,0 +1,15 @@
package com.roundingmobile.boi.data.local.entity
/**
* RecentItemWidgetData projektion för hemskärmswidgeten (2x2 senaste föremål).
*
* Returneras av ItemDao.getRecentItemsWithBox() via en JOIN-fråga.
* Innehåller precis det widgeten behöver inget mer.
*
* Room mappar kolumnnamnen från SQL-aliasen direkt till datafältens namn.
*/
data class RecentItemWidgetData(
val id: Long,
val name: String,
val boxName: String,
)

View file

@ -0,0 +1,42 @@
package com.roundingmobile.boi.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* StorageRoomEntity ett rum eller område inom en Location
* (t.ex. "Vänster hylla", "Frys", "Hyllenhet A").
*
* Namnkonvention: "StorageRoom" (inte "Room") för att undvika kollision med
* Android-systemklassen android.room och Kotlin-keywordet.
*
* FK locationId locations.id med CASCADE DELETE:
* Om en Location raderas, raderas alla dess rum automatiskt.
*
* Index locationId: krävs av Room för foreign keys och möjliggör
* effektiv filtrering per location.
*/
@Entity(
tableName = "storage_rooms",
foreignKeys = [
ForeignKey(
entity = LocationEntity::class,
parentColumns = ["id"],
childColumns = ["locationId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("locationId"), Index("name")],
)
data class StorageRoomEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val locationId: Long,
val name: String,
val description: String = "",
val photoPath: String? = null,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
)

View file

@ -0,0 +1,28 @@
package com.roundingmobile.boi.data.local.entity
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* TagEntity en global tagg som kan kopplas till valfritt antal Items.
*
* Taggar är globala (inte per box eller location) och kopplas via
* ItemTagEntity (many-to-many pivot-tabell).
*
* colorHex: visuell färgmarkering i UI, lagras som "#RRGGBB".
* Standardvärde: Material3 primary purple.
*
* unique index name: förhindrar duplicerade taggar databasnivå.
*/
@Entity(
tableName = "tags",
indices = [Index(value = ["name"], unique = true)],
)
data class TagEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val name: String,
val colorHex: String = "#6650A4",
val createdAt: Long = System.currentTimeMillis(),
)

View file

@ -0,0 +1,199 @@
package com.roundingmobile.boi.data.repository
import android.content.Intent
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInClient
import com.google.android.gms.common.api.ApiException
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
import com.google.api.client.http.ByteArrayContent
import com.google.api.client.http.javanet.NetHttpTransport
import com.google.api.client.json.gson.GsonFactory
import com.google.api.services.drive.Drive
import com.google.api.services.drive.model.File
import com.google.api.services.drive.model.FileList
import android.content.Context
import com.roundingmobile.boi.BuildConfig
import com.roundingmobile.boi.domain.model.BackupInfo
import com.roundingmobile.boi.domain.repository.BackupRepository
import com.roundingmobile.boi.util.CoroutineDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* BackupRepositoryImpl Google Drive-backup via Drive API v3.
*
* Sign-In: GoogleSignInClient (play-services-auth) med email + DRIVE_FILE-scope.
* Drive-åtkomst: GoogleAccountCredential Drive.Builder (NetHttpTransport, GsonFactory).
* Alla Drive-anrop görs Dispatchers.IO för att undvika UI-trådsblockeringar.
*
* Backupfiler sparas i mappen "BoxOrganizerBackup" i användarens Drive.
* DRIVE_FILE-scope begränsar åtkomsten till enbart filer skapade av denna app.
*/
@Singleton
class BackupRepositoryImpl @Inject constructor(
@ApplicationContext private val context: Context,
private val googleSignInClient: GoogleSignInClient,
private val dataStore: DataStore<Preferences>,
private val dispatchers: CoroutineDispatchers,
) : BackupRepository {
private companion object {
val KEY_SIGNED_IN_EMAIL = stringPreferencesKey("backup_google_email")
val KEY_LAST_BACKUP_TIME = longPreferencesKey("last_backup_time")
const val DRIVE_FOLDER_NAME = "BoxOrganizerBackup"
const val BACKUP_MIME_TYPE = "application/octet-stream"
const val FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"
const val APP_NAME = "Box Organizer Inventory"
}
override val signedInEmail: Flow<String?> = dataStore.data
.map { prefs -> prefs[KEY_SIGNED_IN_EMAIL] }
.catch { emit(null) }
override val lastBackupTime: Flow<Long?> = dataStore.data
.map { prefs -> prefs[KEY_LAST_BACKUP_TIME] }
.catch { emit(null) }
// ── Google Sign-In ────────────────────────────────────────────────────────
override fun getSignInIntent(): Intent = googleSignInClient.signInIntent
override suspend fun handleSignInResult(data: Intent?): Result<String> =
withContext(dispatchers.io) {
runCatching {
val task = GoogleSignIn.getSignedInAccountFromIntent(data)
val account = task.getResult(ApiException::class.java)
val email = account.email ?: error("Inget e-postkonto hittades")
dataStore.edit { prefs -> prefs[KEY_SIGNED_IN_EMAIL] = email }
email
}
}
override suspend fun signOut() {
withContext(dispatchers.io) {
runCatching { googleSignInClient.signOut().await() }
}
dataStore.edit { prefs ->
prefs.remove(KEY_SIGNED_IN_EMAIL)
}
}
// ── Drive-operationer ──────────────────────────────────────────────────────
override suspend fun uploadBackup(
encryptedData: ByteArray,
fileName: String,
): Result<Unit> = withContext(dispatchers.io) {
runCatching {
val drive = buildDriveService() ?: error("Ej inloggad på Google")
val folderId = getOrCreateFolder(drive)
val fileMetadata = File().apply {
name = fileName
parents = listOf(folderId)
}
val content = ByteArrayContent(BACKUP_MIME_TYPE, encryptedData)
drive.files().create(fileMetadata, content)
.setFields("id")
.execute()
Unit
}
}
override suspend fun listBackups(): Result<List<BackupInfo>> =
withContext(dispatchers.io) {
runCatching {
val drive = buildDriveService() ?: error("Ej inloggad på Google")
val folderId = getOrCreateFolder(drive)
val result: FileList = drive.files().list()
.setQ("'$folderId' in parents and trashed = false")
.setFields("files(id,name,size,createdTime)")
.setOrderBy("createdTime desc")
.execute()
result.files.map { f ->
BackupInfo(
id = f.id,
fileName = f.name,
sizeBytes = f.getSize() ?: 0L,
createdAt = f.createdTime?.value ?: 0L,
)
}
}
}
override suspend fun downloadBackup(fileId: String): Result<ByteArray> =
withContext(dispatchers.io) {
runCatching {
val drive = buildDriveService() ?: error("Ej inloggad på Google")
drive.files().get(fileId).executeMediaAsInputStream().use { stream ->
stream.readBytes()
}
}
}
override suspend fun saveLastBackupTime(timestamp: Long) {
dataStore.edit { prefs -> prefs[KEY_LAST_BACKUP_TIME] = timestamp }
}
override suspend fun pruneOldBackups(keepCount: Int): Result<Unit> =
withContext(dispatchers.io) {
runCatching {
val drive = buildDriveService() ?: error("Ej inloggad på Google")
val folderId = getOrCreateFolder(drive)
val result: FileList = drive.files().list()
.setQ("'$folderId' in parents and trashed = false")
.setFields("files(id,createdTime)")
.setOrderBy("createdTime desc")
.execute()
val toDelete = result.files.drop(keepCount)
toDelete.forEach { f ->
drive.files().delete(f.id).execute()
}
}
}
// ── Interna hjälpfunktioner ────────────────────────────────────────────────
private suspend fun buildDriveService(): Drive? {
val account = GoogleSignIn.getLastSignedInAccount(context) ?: return null
val credential = GoogleAccountCredential.usingOAuth2(
context,
listOf("https://www.googleapis.com/auth/drive.file"),
)
credential.selectedAccount = account.account
return Drive.Builder(NetHttpTransport(), GsonFactory.getDefaultInstance(), credential)
.setApplicationName(APP_NAME)
.build()
}
private suspend fun getOrCreateFolder(drive: Drive): String {
val existing: FileList = drive.files().list()
.setQ("name = '$DRIVE_FOLDER_NAME' and mimeType = '$FOLDER_MIME_TYPE' and trashed = false")
.setFields("files(id)")
.execute()
existing.files.firstOrNull()?.id?.let { return it }
val folderMetadata = File().apply {
name = DRIVE_FOLDER_NAME
mimeType = FOLDER_MIME_TYPE
}
val created = drive.files().create(folderMetadata).setFields("id").execute()
return created.id
}
}

View file

@ -0,0 +1,105 @@
package com.roundingmobile.boi.data.repository
import android.content.Context
import android.util.Base64
import androidx.biometric.BiometricManager
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.roundingmobile.boi.domain.repository.BiometricRepository
import com.roundingmobile.boi.util.CoroutineDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.security.MessageDigest
import java.security.SecureRandom
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
import javax.inject.Inject
import javax.inject.Singleton
/**
* BiometricRepositoryImpl lagrar PIN-hash och biometrisk aktiverad-flagga.
*
* PIN-derivering: PBKDF2WithHmacSHA256(pin, randomSalt, 10000 iter, 256 bitar).
* Varje ny PIN får ett unikt 16-byte salt som genereras via SecureRandom.
* Hash och salt lagras i egna EncryptedSharedPreferences (separat från DB-nyckeln).
*
* Aktiverad-flaggan lagras i appens gemensamma DataStore.
*/
@Singleton
class BiometricRepositoryImpl @Inject constructor(
@ApplicationContext private val context: Context,
private val dataStore: DataStore<Preferences>,
private val dispatchers: CoroutineDispatchers,
) : BiometricRepository {
private companion object {
val KEY_IS_ENABLED = booleanPreferencesKey("biometric_enabled")
const val PREFS_FILE = "boi_biometric_prefs"
const val KEY_PIN_HASH = "pin_hash"
const val KEY_PIN_SALT = "pin_salt"
const val KDF = "PBKDF2WithHmacSHA256"
const val ITERATIONS = 10_000
const val KEY_BITS = 256
const val SALT_SIZE = 16
}
private val encryptedPrefs by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
PREFS_FILE,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
override val isEnabled: Flow<Boolean> = dataStore.data
.map { prefs -> prefs[KEY_IS_ENABLED] ?: false }
.catch { emit(false) }
override suspend fun setEnabled(enabled: Boolean) {
dataStore.edit { prefs -> prefs[KEY_IS_ENABLED] = enabled }
}
override suspend fun setupPin(pin: String) = withContext(dispatchers.default) {
val salt = ByteArray(SALT_SIZE).also { SecureRandom().nextBytes(it) }
val hash = deriveKey(pin, salt)
encryptedPrefs.edit()
.putString(KEY_PIN_HASH, Base64.encodeToString(hash, Base64.DEFAULT))
.putString(KEY_PIN_SALT, Base64.encodeToString(salt, Base64.DEFAULT))
.apply()
}
override suspend fun verifyPin(pin: String): Boolean = withContext(dispatchers.default) {
val storedHash = encryptedPrefs.getString(KEY_PIN_HASH, null) ?: return@withContext false
val storedSalt = encryptedPrefs.getString(KEY_PIN_SALT, null) ?: return@withContext false
val salt = Base64.decode(storedSalt, Base64.DEFAULT)
val candidate = deriveKey(pin, salt)
MessageDigest.isEqual(candidate, Base64.decode(storedHash, Base64.DEFAULT))
}
override suspend fun clearPin() = withContext(dispatchers.io) {
encryptedPrefs.edit().remove(KEY_PIN_HASH).remove(KEY_PIN_SALT).apply()
}
override fun isHardwareAvailable(): Boolean =
BiometricManager.from(context).canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_STRONG,
) == BiometricManager.BIOMETRIC_SUCCESS
private fun deriveKey(pin: String, salt: ByteArray): ByteArray {
val factory = SecretKeyFactory.getInstance(KDF)
val spec = PBEKeySpec(pin.toCharArray(), salt, ITERATIONS, KEY_BITS)
return factory.generateSecret(spec).encoded
}
}

View file

@ -0,0 +1,52 @@
package com.roundingmobile.boi.data.repository
import com.roundingmobile.boi.data.local.dao.BoxDao
import com.roundingmobile.boi.domain.model.Box
import com.roundingmobile.boi.domain.repository.BoxRepository
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 BoxRepositoryImpl @Inject constructor(
private val boxDao: BoxDao,
private val dispatchers: CoroutineDispatchers,
) : BoxRepository {
override fun getBoxesByRoom(roomId: Long): Flow<List<Box>> =
boxDao.getBoxesByRoom(roomId)
.map { entities -> entities.map { it.toDomain() } }
.flowOn(dispatchers.io)
override suspend fun getBoxById(id: Long): Box? =
withContext(dispatchers.io) {
boxDao.getBoxById(id)?.toDomain()
}
override fun observeBoxById(id: Long): Flow<Box?> =
boxDao.observeBoxById(id)
.map { it?.toDomain() }
.flowOn(dispatchers.io)
override suspend fun getBoxByQrCode(qrCode: String): Box? =
withContext(dispatchers.io) {
boxDao.getBoxByQrCode(qrCode)?.toDomain()
}
override suspend fun addBox(box: Box): Result<Long> =
withContext(dispatchers.io) {
runCatching { boxDao.insert(box.toEntity()) }
}
override suspend fun updateBox(box: Box): Result<Unit> =
withContext(dispatchers.io) {
runCatching { boxDao.update(box.toEntity()) }
}
override suspend fun deleteBox(box: Box): Result<Unit> =
withContext(dispatchers.io) {
runCatching { boxDao.delete(box.toEntity()) }
}
}

View file

@ -0,0 +1,179 @@
package com.roundingmobile.boi.data.repository
import com.roundingmobile.boi.data.local.entity.BoxEntity
import com.roundingmobile.boi.data.local.entity.ItemEntity
import com.roundingmobile.boi.data.local.entity.ItemPhotoEntity
import com.roundingmobile.boi.data.local.entity.LocationEntity
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.Item
import com.roundingmobile.boi.domain.model.ItemPhoto
import com.roundingmobile.boi.domain.model.Location
import com.roundingmobile.boi.domain.model.LocationSummary
import com.roundingmobile.boi.domain.model.StorageRoom
import com.roundingmobile.boi.domain.model.Tag
// ── Location ──────────────────────────────────────────────────────────────────
internal fun LocationEntity.toDomain() = Location(
id = id,
name = name,
description = description,
photoPath = photoPath,
createdAt = createdAt,
updatedAt = updatedAt,
)
internal fun LocationWithCounts.toSummary() = LocationSummary(
id = location.id,
name = location.name,
description = location.description,
photoPath = location.photoPath,
roomCount = roomCount,
itemCount = itemCount,
createdAt = location.createdAt,
updatedAt = location.updatedAt,
)
internal fun Location.toEntity(): LocationEntity {
val now = System.currentTimeMillis()
return LocationEntity(
id = id,
name = name,
description = description,
photoPath = photoPath,
createdAt = if (createdAt == 0L) now else createdAt,
updatedAt = now,
)
}
// ── StorageRoom ───────────────────────────────────────────────────────────────
internal fun StorageRoomEntity.toDomain() = StorageRoom(
id = id,
locationId = locationId,
name = name,
description = description,
photoPath = photoPath,
createdAt = createdAt,
updatedAt = updatedAt,
)
internal fun StorageRoom.toEntity(): StorageRoomEntity {
val now = System.currentTimeMillis()
return StorageRoomEntity(
id = id,
locationId = locationId,
name = name,
description = description,
photoPath = photoPath,
createdAt = if (createdAt == 0L) now else createdAt,
updatedAt = now,
)
}
// ── Box ───────────────────────────────────────────────────────────────────────
internal fun BoxEntity.toDomain() = Box(
id = id,
roomId = roomId,
name = name,
description = description,
qrCode = qrCode,
color = color,
photoPath = photoPath,
createdAt = createdAt,
updatedAt = updatedAt,
)
internal fun Box.toEntity(): BoxEntity {
val now = System.currentTimeMillis()
return BoxEntity(
id = id,
roomId = roomId,
name = name,
description = description,
qrCode = qrCode,
color = color,
photoPath = photoPath,
createdAt = if (createdAt == 0L) now else createdAt,
updatedAt = now,
)
}
// ── Item ──────────────────────────────────────────────────────────────────────
internal fun ItemEntity.toDomain() = Item(
id = id,
boxId = boxId,
roomId = roomId,
locationId = locationId,
name = name,
description = description,
quantity = quantity,
value = value,
unit = unit,
notes = notes,
createdAt = createdAt,
updatedAt = updatedAt,
)
internal fun Item.toEntity(): ItemEntity {
val now = System.currentTimeMillis()
return ItemEntity(
id = id,
boxId = boxId,
roomId = roomId,
locationId = locationId,
name = name,
description = description,
quantity = quantity,
value = value,
unit = unit,
notes = notes,
createdAt = if (createdAt == 0L) now else createdAt,
updatedAt = now,
)
}
// ── ItemPhoto ─────────────────────────────────────────────────────────────────
internal fun ItemPhotoEntity.toDomain() = ItemPhoto(
id = id,
itemId = itemId,
filePath = filePath,
sortOrder = sortOrder,
createdAt = createdAt,
)
internal fun ItemPhoto.toEntity(): ItemPhotoEntity {
val now = System.currentTimeMillis()
return ItemPhotoEntity(
id = id,
itemId = itemId,
filePath = filePath,
sortOrder = sortOrder,
createdAt = if (createdAt == 0L) now else createdAt,
)
}
// ── Tag ───────────────────────────────────────────────────────────────────────
internal fun TagEntity.toDomain() = Tag(
id = id,
name = name,
colorHex = colorHex,
createdAt = createdAt,
)
internal fun Tag.toEntity(): TagEntity {
val now = System.currentTimeMillis()
return TagEntity(
id = id,
name = name,
colorHex = colorHex,
createdAt = if (createdAt == 0L) now else createdAt,
)
}

View file

@ -0,0 +1,47 @@
package com.roundingmobile.boi.data.repository
import com.roundingmobile.boi.data.local.dao.ItemPhotoDao
import com.roundingmobile.boi.domain.model.ItemPhoto
import com.roundingmobile.boi.domain.repository.ItemPhotoRepository
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 ItemPhotoRepositoryImpl @Inject constructor(
private val itemPhotoDao: ItemPhotoDao,
private val dispatchers: CoroutineDispatchers,
) : ItemPhotoRepository {
override fun getPhotosByItem(itemId: Long): Flow<List<ItemPhoto>> =
itemPhotoDao.getPhotosByItem(itemId)
.map { entities -> entities.map { it.toDomain() } }
.flowOn(dispatchers.io)
override suspend fun getPhotoCountForItem(itemId: Long): Int =
withContext(dispatchers.io) {
itemPhotoDao.getPhotoCountForItem(itemId)
}
override suspend fun addPhoto(photo: ItemPhoto): Result<Long> =
withContext(dispatchers.io) {
runCatching { itemPhotoDao.insert(photo.toEntity()) }
}
override suspend fun updatePhoto(photo: ItemPhoto): Result<Unit> =
withContext(dispatchers.io) {
runCatching { itemPhotoDao.update(photo.toEntity()) }
}
override suspend fun deletePhoto(photo: ItemPhoto): Result<Unit> =
withContext(dispatchers.io) {
runCatching { itemPhotoDao.delete(photo.toEntity()) }
}
override suspend fun deleteAllPhotosForItem(itemId: Long): Result<Unit> =
withContext(dispatchers.io) {
runCatching { itemPhotoDao.deleteAllPhotosForItem(itemId) }
}
}

View file

@ -0,0 +1,61 @@
package com.roundingmobile.boi.data.repository
import com.roundingmobile.boi.data.local.dao.ItemDao
import com.roundingmobile.boi.domain.model.Item
import com.roundingmobile.boi.domain.repository.ItemRepository
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 ItemRepositoryImpl @Inject constructor(
private val itemDao: ItemDao,
private val dispatchers: CoroutineDispatchers,
) : ItemRepository {
override fun getItemsByBox(boxId: Long): Flow<List<Item>> =
itemDao.getItemsByBox(boxId)
.map { entities -> entities.map { it.toDomain() } }
.flowOn(dispatchers.io)
override fun getItemsByRoom(roomId: Long): Flow<List<Item>> =
itemDao.getItemsByRoom(roomId)
.map { entities -> entities.map { it.toDomain() } }
.flowOn(dispatchers.io)
override fun getItemsByLocation(locationId: Long): Flow<List<Item>> =
itemDao.getItemsByLocation(locationId)
.map { entities -> entities.map { it.toDomain() } }
.flowOn(dispatchers.io)
override fun getTotalItemCount(): Flow<Int> =
itemDao.getTotalItemCount()
.flowOn(dispatchers.io)
override suspend fun getTotalItemCountOnce(): Int =
withContext(dispatchers.io) {
itemDao.getTotalItemCountOnce()
}
override suspend fun getItemById(id: Long): Item? =
withContext(dispatchers.io) {
itemDao.getItemById(id)?.toDomain()
}
override suspend fun addItem(item: Item): Result<Long> =
withContext(dispatchers.io) {
runCatching { itemDao.insert(item.toEntity()) }
}
override suspend fun updateItem(item: Item): Result<Unit> =
withContext(dispatchers.io) {
runCatching { itemDao.update(item.toEntity()) }
}
override suspend fun deleteItem(item: Item): Result<Unit> =
withContext(dispatchers.io) {
runCatching { itemDao.delete(item.toEntity()) }
}
}

View file

@ -0,0 +1,53 @@
package com.roundingmobile.boi.data.repository
import com.roundingmobile.boi.data.local.dao.LocationDao
import com.roundingmobile.boi.domain.model.Location
import com.roundingmobile.boi.domain.model.LocationSummary
import com.roundingmobile.boi.domain.repository.LocationRepository
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 LocationRepositoryImpl @Inject constructor(
private val locationDao: LocationDao,
private val dispatchers: CoroutineDispatchers,
) : LocationRepository {
override fun getAllLocations(): Flow<List<Location>> =
locationDao.getAllLocations()
.map { entities -> entities.map { it.toDomain() } }
.flowOn(dispatchers.io)
override fun getLocationSummaries(): Flow<List<LocationSummary>> =
locationDao.getLocationsWithCounts()
.map { rows -> rows.map { it.toSummary() } }
.flowOn(dispatchers.io)
override suspend fun getLocationById(id: Long): Location? =
withContext(dispatchers.io) {
locationDao.getLocationById(id)?.toDomain()
}
override fun observeLocationById(id: Long): Flow<Location?> =
locationDao.observeLocationById(id)
.map { it?.toDomain() }
.flowOn(dispatchers.io)
override suspend fun addLocation(location: Location): Result<Long> =
withContext(dispatchers.io) {
runCatching { locationDao.insert(location.toEntity()) }
}
override suspend fun updateLocation(location: Location): Result<Unit> =
withContext(dispatchers.io) {
runCatching { locationDao.update(location.toEntity()) }
}
override suspend fun deleteLocation(location: Location): Result<Unit> =
withContext(dispatchers.io) {
runCatching { locationDao.delete(location.toEntity()) }
}
}

View file

@ -0,0 +1,28 @@
package com.roundingmobile.boi.data.repository
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import com.roundingmobile.boi.domain.repository.ProStatusRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class ProStatusRepositoryImpl @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : ProStatusRepository {
private companion object {
val KEY_IS_PRO = booleanPreferencesKey("is_pro")
}
override val isPro: Flow<Boolean> = dataStore.data
.map { prefs -> prefs[KEY_IS_PRO] ?: false }
.catch { emit(false) }
override suspend fun setProStatus(isPro: Boolean) {
dataStore.edit { prefs -> prefs[KEY_IS_PRO] = isPro }
}
}

View file

@ -0,0 +1,132 @@
package com.roundingmobile.boi.data.repository
import com.roundingmobile.boi.data.local.dao.BoxDao
import com.roundingmobile.boi.data.local.dao.ItemPhotoDao
import com.roundingmobile.boi.data.local.dao.LocationDao
import com.roundingmobile.boi.data.local.dao.SearchDao
import com.roundingmobile.boi.data.local.dao.StorageRoomDao
import com.roundingmobile.boi.domain.model.SearchResult
import com.roundingmobile.boi.domain.model.SearchResults
import com.roundingmobile.boi.domain.repository.SearchRepository
import com.roundingmobile.boi.util.CoroutineDispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import javax.inject.Inject
/**
* SearchRepositoryImpl kombinerar FTS4 (items) och LIKE (boxes, locations)
* till ett sammanhållet sökresultat med fullständig platsinformation.
*
* Items-sökning: FTS4 MATCH med prefix-wildcard ("ring*") snabb och
* heltextindexerad. Söktermen formateras i [formatFtsQuery].
*
* Boxes/Locations: LIKE-sökning. Volymerna är lägre och dessa tabeller
* saknar FTS-index, men prestandan är acceptabel för appens storlek.
*
* N+1-lookup för kontext (box room location per item-resultat):
* Acceptabelt för FREE-tier (max 500 items). suspend-anrop inuti
* combine-lambdan är tillåtet eftersom lambdan är suspending.
*/
class SearchRepositoryImpl @Inject constructor(
private val searchDao: SearchDao,
private val boxDao: BoxDao,
private val storageRoomDao: StorageRoomDao,
private val locationDao: LocationDao,
private val itemPhotoDao: ItemPhotoDao,
private val dispatchers: CoroutineDispatchers,
) : SearchRepository {
override fun search(query: String): Flow<SearchResults> {
val trimmed = query.trim()
if (trimmed.isBlank()) return flowOf(SearchResults())
val ftsQuery = formatFtsQuery(trimmed)
val likeQuery = "%$trimmed%"
return combine(
searchDao.searchItems(ftsQuery),
searchDao.searchBoxes(likeQuery),
searchDao.searchLocations(likeQuery),
) { itemEntities, boxEntities, locationEntities ->
val items = itemEntities.mapNotNull { entity ->
val photo = itemPhotoDao.getFirstPhotoForItem(entity.id)
when {
entity.boxId != null -> {
val box = boxDao.getBoxById(entity.boxId) ?: return@mapNotNull null
val room = storageRoomDao.getRoomById(box.roomId) ?: return@mapNotNull null
val location = locationDao.getLocationById(room.locationId) ?: return@mapNotNull null
SearchResult.ItemResult(
itemId = entity.id,
name = entity.name,
thumbnailPath = photo?.filePath,
boxName = box.name,
roomName = room.name,
locationName = location.name,
)
}
entity.roomId != null -> {
val room = storageRoomDao.getRoomById(entity.roomId) ?: return@mapNotNull null
val location = locationDao.getLocationById(room.locationId) ?: return@mapNotNull null
SearchResult.ItemResult(
itemId = entity.id,
name = entity.name,
thumbnailPath = photo?.filePath,
boxName = null,
roomName = room.name,
locationName = location.name,
)
}
entity.locationId != null -> {
val location = locationDao.getLocationById(entity.locationId) ?: return@mapNotNull null
SearchResult.ItemResult(
itemId = entity.id,
name = entity.name,
thumbnailPath = photo?.filePath,
boxName = null,
roomName = null,
locationName = location.name,
)
}
else -> null
}
}
val boxes = boxEntities.mapNotNull { entity ->
val room = storageRoomDao.getRoomById(entity.roomId) ?: return@mapNotNull null
val location = locationDao.getLocationById(room.locationId) ?: return@mapNotNull null
SearchResult.BoxResult(
boxId = entity.id,
name = entity.name,
thumbnailPath = entity.photoPath,
roomName = room.name,
locationName = location.name,
)
}
val locations = locationEntities.map { entity ->
SearchResult.LocationResult(
locationId = entity.id,
name = entity.name,
thumbnailPath = entity.photoPath,
)
}
SearchResults(items = items, boxes = boxes, locations = locations)
}.flowOn(dispatchers.io)
}
/**
* Formaterar en sökterm till FTS4 MATCH-syntax.
*
* Varje ord får ett trailing "*" för prefix-matching att t.ex.
* "ring" matchar "ringen", "ringar" och "ringklocka".
* Citationstecken tas bort för att undvika syntaxfel i FTS-queryn.
*/
private fun formatFtsQuery(raw: String): String =
raw.split(Regex("\\s+"))
.filter { it.isNotBlank() }
.joinToString(" ") { "${it.replace("\"", "")}*" }
}

View file

@ -0,0 +1,47 @@
package com.roundingmobile.boi.data.repository
import com.roundingmobile.boi.data.local.dao.StorageRoomDao
import com.roundingmobile.boi.domain.model.StorageRoom
import com.roundingmobile.boi.domain.repository.StorageRoomRepository
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 StorageRoomRepositoryImpl @Inject constructor(
private val storageRoomDao: StorageRoomDao,
private val dispatchers: CoroutineDispatchers,
) : StorageRoomRepository {
override fun getRoomsByLocation(locationId: Long): Flow<List<StorageRoom>> =
storageRoomDao.getRoomsByLocation(locationId)
.map { entities -> entities.map { it.toDomain() } }
.flowOn(dispatchers.io)
override suspend fun getRoomById(id: Long): StorageRoom? =
withContext(dispatchers.io) {
storageRoomDao.getRoomById(id)?.toDomain()
}
override fun observeRoomById(id: Long): Flow<StorageRoom?> =
storageRoomDao.observeRoomById(id)
.map { it?.toDomain() }
.flowOn(dispatchers.io)
override suspend fun addRoom(room: StorageRoom): Result<Long> =
withContext(dispatchers.io) {
runCatching { storageRoomDao.insert(room.toEntity()) }
}
override suspend fun updateRoom(room: StorageRoom): Result<Unit> =
withContext(dispatchers.io) {
runCatching { storageRoomDao.update(room.toEntity()) }
}
override suspend fun deleteRoom(room: StorageRoom): Result<Unit> =
withContext(dispatchers.io) {
runCatching { storageRoomDao.delete(room.toEntity()) }
}
}

View file

@ -0,0 +1,63 @@
package com.roundingmobile.boi.data.repository
import com.roundingmobile.boi.data.local.dao.TagDao
import com.roundingmobile.boi.data.local.entity.ItemTagEntity
import com.roundingmobile.boi.domain.model.Tag
import com.roundingmobile.boi.domain.repository.TagRepository
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 TagRepositoryImpl @Inject constructor(
private val tagDao: TagDao,
private val dispatchers: CoroutineDispatchers,
) : TagRepository {
override fun getAllTags(): Flow<List<Tag>> =
tagDao.getAllTags()
.map { entities -> entities.map { it.toDomain() } }
.flowOn(dispatchers.io)
override fun getTagsForItem(itemId: Long): Flow<List<Tag>> =
tagDao.getTagsForItem(itemId)
.map { entities -> entities.map { it.toDomain() } }
.flowOn(dispatchers.io)
override suspend fun getTagById(id: Long): Tag? =
withContext(dispatchers.io) {
tagDao.getTagById(id)?.toDomain()
}
override suspend fun addTag(tag: Tag): Result<Long> =
withContext(dispatchers.io) {
runCatching { tagDao.insertTag(tag.toEntity()) }
}
override suspend fun updateTag(tag: Tag): Result<Unit> =
withContext(dispatchers.io) {
runCatching { tagDao.updateTag(tag.toEntity()) }
}
override suspend fun deleteTag(tag: Tag): Result<Unit> =
withContext(dispatchers.io) {
runCatching { tagDao.deleteTag(tag.toEntity()) }
}
override suspend fun addTagToItem(itemId: Long, tagId: Long): Result<Unit> =
withContext(dispatchers.io) {
runCatching { tagDao.addTagToItem(ItemTagEntity(itemId = itemId, tagId = tagId)) }
}
override suspend fun removeTagFromItem(itemId: Long, tagId: Long): Result<Unit> =
withContext(dispatchers.io) {
runCatching { tagDao.removeTagFromItem(ItemTagEntity(itemId = itemId, tagId = tagId)) }
}
override suspend fun removeAllTagsFromItem(itemId: Long): Result<Unit> =
withContext(dispatchers.io) {
runCatching { tagDao.removeAllTagsFromItem(itemId) }
}
}

View file

@ -0,0 +1,30 @@
package com.roundingmobile.boi.di
import com.roundingmobile.boi.util.CoroutineDispatchers
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* AppModule Hilt-modul för appövergripande beroenden.
*
* Tillhandahåller saker som är globala, inte kopplade till databas,
* DataStore eller ett specifikt lager.
*/
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
/**
* Tillhandahåller CoroutineDispatchers som en singleton.
*
* Singleton-scope motiveras av att instansen är stateless och
* att alla repositories delar samma dispatcher-konfiguration.
* I tester ersätts denna med TestCoroutineDispatchers via Hilt-testmoduler.
*/
@Provides
@Singleton
fun provideCoroutineDispatchers(): CoroutineDispatchers = CoroutineDispatchers()
}

View file

@ -0,0 +1,55 @@
package com.roundingmobile.boi.di
import android.content.Context
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInClient
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.roundingmobile.boi.data.repository.BackupRepositoryImpl
import com.roundingmobile.boi.domain.repository.BackupRepository
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* BackupModule Hilt-modul för Google Sign-In och Drive-klienten.
*
* GoogleSignInOptions: requestEmail + DRIVE_FILE (åtkomst enbart till egna filer).
* WorkManager-schemaläggning görs i BoxOrganizerApp.scheduleBackup() för att
* undvika Hilt-bindningskonflikter med Unit-returtyper.
*/
@Module
@InstallIn(SingletonComponent::class)
abstract class BackupModule {
@Binds
@Singleton
abstract fun bindBackupRepository(
impl: BackupRepositoryImpl,
): BackupRepository
companion object {
@Provides
@Singleton
fun provideGoogleSignInOptions(): GoogleSignInOptions =
GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestEmail()
.requestScopes(
com.google.android.gms.common.api.Scope(
"https://www.googleapis.com/auth/drive.file"
)
)
.build()
@Provides
@Singleton
fun provideGoogleSignInClient(
@ApplicationContext context: Context,
options: GoogleSignInOptions,
): GoogleSignInClient = GoogleSignIn.getClient(context, options)
}
}

View file

@ -0,0 +1,136 @@
package com.roundingmobile.boi.di
import android.content.Context
import androidx.room.Room
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.ItemDao
import com.roundingmobile.boi.data.local.dao.ItemPhotoDao
import com.roundingmobile.boi.data.local.dao.LocationDao
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.database.AppDatabase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import net.sqlcipher.database.SQLiteDatabase
import net.sqlcipher.database.SupportFactory
import java.security.SecureRandom
import javax.inject.Singleton
/**
* DatabaseModule Hilt-modul som tillhandahåller den krypterade databasen och alla DAOs.
*
* Nyckelhantering (SQLCipher + EncryptedSharedPreferences)
*
* Strategi:
* 1. Vid första appstart genereras 32 slumpmässiga bytes via SecureRandom.
* 2. Dessa konverteras till en 64-teckens hex-sträng och sparas i
* EncryptedSharedPreferences (AES256-GCM, skyddad av Android Keystore).
* 3. Vid varje efterföljande start läses nyckeln från EncryptedSharedPreferences.
* 4. Nyckeln konverteras till ByteArray via SQLiteDatabase.getBytes() och
* skickas till SupportFactory som öppnar Room med SQLCipher.
*
* Varför EncryptedSharedPreferences?
* - Nyckeln lagras aldrig i klartext disken.
* - Android Keystore skyddar krypteringsnyckeln för SharedPreferences.
* - Nyckeln är enhetsunik och kan inte extraheras utan fysisk access till enheten.
*
* Varför inte hårdkoda nyckeln?
* - En hårdkodad nyckel i koden ger noll säkerhet alla med APKn kan läsa BD:n.
* - Slumpmässig nyckel per installation innebär att varje användares data
* är oberoende krypterad.
*/
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
val passphrase = getOrCreatePassphrase(context)
val factory = SupportFactory(passphrase)
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"box_organizer.db",
)
.openHelperFactory(factory)
.addMigrations(
AppDatabase.MIGRATION_1_2,
AppDatabase.MIGRATION_2_3,
AppDatabase.MIGRATION_3_4,
AppDatabase.MIGRATION_4_5,
AppDatabase.MIGRATION_5_6,
)
.build()
}
/**
* Hämtar eller skapar en slumpmässig databaskrypteringsnyckel.
*
* Flöde:
* - Om nyckeln finns i EncryptedSharedPreferences returnera den.
* - Om inte generera ny 32-byte nyckel, lagra och returnera den.
*
* MasterKey använder AES256_GCM med Android Keystore som backend.
* Nyckeln roteras aldrig automatiskt det kräver databasmigrering + omdekryptering.
*/
private fun getOrCreatePassphrase(context: Context): ByteArray {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val prefs = EncryptedSharedPreferences.create(
context,
"boi_db_key_store",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
val existingKey = prefs.getString("db_passphrase", null)
if (existingKey != null) {
return SQLiteDatabase.getBytes(existingKey.toCharArray())
}
val keyBytes = ByteArray(32)
SecureRandom().nextBytes(keyBytes)
val newKey = keyBytes.joinToString("") { byte -> "%02x".format(byte) }
prefs.edit().putString("db_passphrase", newKey).apply()
return SQLiteDatabase.getBytes(newKey.toCharArray())
}
// ── DAO-providers ──────────────────────────────────────────────────────────────────
// Scope ärvs implicit från AppDatabase (@Singleton) behöver inte deklareras igen.
@Provides
fun provideBackupDao(db: AppDatabase): BackupDao = db.backupDao()
@Provides
fun provideLocationDao(db: AppDatabase): LocationDao = db.locationDao()
@Provides
fun provideStorageRoomDao(db: AppDatabase): StorageRoomDao = db.storageRoomDao()
@Provides
fun provideBoxDao(db: AppDatabase): BoxDao = db.boxDao()
@Provides
fun provideItemDao(db: AppDatabase): ItemDao = db.itemDao()
@Provides
fun provideItemPhotoDao(db: AppDatabase): ItemPhotoDao = db.itemPhotoDao()
@Provides
fun provideTagDao(db: AppDatabase): TagDao = db.tagDao()
@Provides
fun provideSearchDao(db: AppDatabase): SearchDao = db.searchDao()
}

View file

@ -0,0 +1,44 @@
package com.roundingmobile.boi.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.io.File
import javax.inject.Singleton
/**
* PreferencesModule Hilt-modul som tillhandahåller DataStore<Preferences>.
*
* En enda DataStore-instans används för all icke-känslig användardata:
* - PRO-status (is_pro)
* - Framtida preferenser (tema, språk, notifikationsinställningar m.m.)
*
* Varför DataStore och inte SharedPreferences?
* - DataStore är asynkront (coroutines/Flow) aldrig blockerar UI-tråden.
* - SharedPreferences är synkront och kan orsaka ANR långsamma enheter.
* - DataStore hanterar läs/skriv-konflikter atomärt.
*
* Filen sparas i: {filesDir}/datastore/user_preferences.preferences_pb
*
* OBS: Aldrig mer än EN DataStore-instans per fil Hilt garanterar detta
* via @Singleton. Dubbla instanser samma fil orsakar runtime-krasch.
*/
@Module
@InstallIn(SingletonComponent::class)
object PreferencesModule {
@Provides
@Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
produceFile = {
File(context.filesDir, "datastore/user_preferences.preferences_pb")
},
)
}

View file

@ -0,0 +1,108 @@
package com.roundingmobile.boi.di
import com.roundingmobile.boi.data.repository.BiometricRepositoryImpl
import com.roundingmobile.boi.data.repository.BoxRepositoryImpl
import com.roundingmobile.boi.data.repository.ItemPhotoRepositoryImpl
import com.roundingmobile.boi.data.repository.ItemRepositoryImpl
import com.roundingmobile.boi.data.repository.LocationRepositoryImpl
import com.roundingmobile.boi.data.repository.ProStatusRepositoryImpl
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.BoxRepository
import com.roundingmobile.boi.domain.repository.ItemPhotoRepository
import com.roundingmobile.boi.domain.repository.ItemRepository
import com.roundingmobile.boi.domain.repository.LocationRepository
import com.roundingmobile.boi.domain.repository.ProStatusRepository
import com.roundingmobile.boi.domain.repository.SearchRepository
import com.roundingmobile.boi.domain.repository.StorageRoomRepository
import com.roundingmobile.boi.domain.repository.TagRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* RepositoryModule kopplar domängränssnitt till datalagerimplementationer.
*
* Varför @Binds istället för @Provides?
* - @Binds genererar mindre kod och är snabbare att kompilera.
* - Tydligare intention: "detta gränssnitt implementeras av den här klassen".
* - Kräver abstract class (inte object) Hilt-begränsning.
*
* @Singleton: en enda instans per app-livscykel.
* Repositories är stateless (all state lever i databasen/DataStore),
* singleton är korrekt scope och sparar minnesallokeringar.
*
* Cirkulärberoende-kontroll:
* LocationRepositoryImpl LocationDao, CoroutineDispatchers
* StorageRoomRepositoryImpl StorageRoomDao, CoroutineDispatchers
* BoxRepositoryImpl BoxDao, CoroutineDispatchers
* ItemRepositoryImpl ItemDao, CoroutineDispatchers
* ItemPhotoRepositoryImpl ItemPhotoDao, CoroutineDispatchers
* TagRepositoryImpl TagDao, CoroutineDispatchers
* SearchRepositoryImpl SearchDao, CoroutineDispatchers
* ProStatusRepositoryImpl DataStore<Preferences>
* BiometricRepositoryImpl Context, DataStore<Preferences>
* Inga cirkulära beroenden.
*/
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindBiometricRepository(
impl: BiometricRepositoryImpl,
): BiometricRepository
@Binds
@Singleton
abstract fun bindLocationRepository(
impl: LocationRepositoryImpl,
): LocationRepository
@Binds
@Singleton
abstract fun bindStorageRoomRepository(
impl: StorageRoomRepositoryImpl,
): StorageRoomRepository
@Binds
@Singleton
abstract fun bindBoxRepository(
impl: BoxRepositoryImpl,
): BoxRepository
@Binds
@Singleton
abstract fun bindItemRepository(
impl: ItemRepositoryImpl,
): ItemRepository
@Binds
@Singleton
abstract fun bindItemPhotoRepository(
impl: ItemPhotoRepositoryImpl,
): ItemPhotoRepository
@Binds
@Singleton
abstract fun bindTagRepository(
impl: TagRepositoryImpl,
): TagRepository
@Binds
@Singleton
abstract fun bindSearchRepository(
impl: SearchRepositoryImpl,
): SearchRepository
@Binds
@Singleton
abstract fun bindProStatusRepository(
impl: ProStatusRepositoryImpl,
): ProStatusRepository
}

View file

@ -0,0 +1,23 @@
package com.roundingmobile.boi.di
import com.roundingmobile.boi.data.local.dao.ItemDao
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
/**
* WidgetEntryPoint Hilt-ingångspunkt för Android-widgetklasser.
*
* AppWidgetProvider och RemoteViewsFactory är inte standard Android-komponenter
* som Hilt kan injicera direkt. Istället används EntryPointAccessors för att
* hämta beroenden från SingletonComponent.
*
* Användning:
* val ep = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java)
* val dao = ep.itemDao()
*/
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun itemDao(): ItemDao
}

View file

@ -0,0 +1,16 @@
package com.roundingmobile.boi.domain.model
/**
* BackupInfo metadata för en backupfil lagrad Google Drive.
*
* id Drive-filens unika ID, används för nedladdning och radering.
* fileName t.ex. "backup_20260329_143052.boi"
* sizeBytes filstorlek i bytes
* createdAt epoch-millis, visas som "för 2 timmar sedan" i UI
*/
data class BackupInfo(
val id: String,
val fileName: String,
val sizeBytes: Long,
val createdAt: Long,
)

View file

@ -0,0 +1,19 @@
package com.roundingmobile.boi.domain.model
/**
* Box domänmodell för en låda eller behållare inuti ett StorageRoom.
*
* qrCode: nullable genereras och kopplas i Fas 13 (QR-skanning).
* Null = ingen QR-kod skapad ännu.
*/
data class Box(
val id: Long = 0,
val roomId: Long,
val name: String,
val description: String = "",
val qrCode: String? = null,
val color: String? = null,
val photoPath: String? = null,
val createdAt: Long = 0,
val updatedAt: Long = 0,
)

View file

@ -0,0 +1,43 @@
package com.roundingmobile.boi.domain.model
/**
* ExportData dataträdstruktur som representerar hela inventeringen vid exporttillfället.
*
* Hierarki: ExportData ExportLocation ExportRoom ExportBox ExportItem
*
* Skapas av ExportInventoryUseCase och konsumeras av ExcelExporter och PdfExporter.
* Är ett rent ögonblicksfoto förändringar i databasen efteråt påverkar inte exporten.
*/
data class ExportData(
val locations: List<ExportLocation>,
val generatedAt: Long = System.currentTimeMillis(),
)
data class ExportLocation(
val name: String,
val description: String,
val rooms: List<ExportRoom>,
)
data class ExportRoom(
val name: String,
val description: String,
val boxes: List<ExportBox>,
)
data class ExportBox(
val name: String,
val description: String,
val items: List<ExportItem>,
)
data class ExportItem(
val name: String,
val description: String,
val quantity: Int,
val value: Double?,
val unit: String?,
val notes: String,
val tags: List<String>,
val firstPhotoPath: String?,
)

View file

@ -0,0 +1,36 @@
package com.roundingmobile.boi.domain.model
import com.roundingmobile.boi.AppConfig
/**
* FreemiumException domänfel som kastas när en freemium-gräns uppnåtts.
*
* sealed class: UI-lagret kan exhaustivt hantera alla fall med `when`:
* is FreemiumException.ItemLimitReached navigera till ProUpgradeScreen
* is FreemiumException.PhotoLimitReached visa PRO-dialog
*
* Kastas av UseCases (AddItemUseCase, AddPhotoUseCase) och returneras
* inkapslad i Result.failure(). Repositories kastar den aldrig direkt.
*
* Meddelanden svenska aldrig stack traces till användaren (CLAUDE.md §9).
*/
sealed class FreemiumException(message: String) : Exception(message) {
/**
* Kastas när totalt antal items i hela databasen har nått FREE_ITEM_LIMIT.
* ViewModel ska navigera till ProUpgradeScreen med animation.
*/
class ItemLimitReached : FreemiumException(
"Du har nått gränsen på ${AppConfig.FREE_ITEM_LIMIT} föremål. " +
"Uppgradera till PRO för obegränsade föremål.",
)
/**
* Kastas när ett specifikt items fototal har nått FREE_PHOTOS_PER_ITEM.
* ViewModel ska visa PRO-uppgraderingsdialog.
*/
class PhotoLimitReached : FreemiumException(
"Du kan ha högst ${AppConfig.FREE_PHOTOS_PER_ITEM} foton per föremål i gratisversionen. " +
"Uppgradera till PRO för obegränsade foton.",
)
}

View file

@ -0,0 +1,16 @@
package com.roundingmobile.boi.domain.model
/**
* ImportMode styr hur importerad data fogas in i databasen.
*
* REPLACE: rensar hela databasen och ersätter med importerat innehåll.
* Original-ID:n bevaras. Lämpligt när man byter enhet.
*
* MERGE: lägger till importerat innehåll bredvid befintlig data.
* Nya IDs tilldelas för att undvika kollisioner.
* QR-koder nollställs (kan regenereras) för att undvika unikhetsbrott.
*/
enum class ImportMode {
REPLACE,
MERGE,
}

View file

@ -0,0 +1,27 @@
package com.roundingmobile.boi.domain.model
/**
* Item domänmodell för ett föremål.
*
* Exakt ett av [boxId], [roomId], [locationId] är non-null:
* boxId != null föremålet är i en specifik låda
* roomId != null föremålet ligger direkt i ett rum
* locationId != null föremålet ligger direkt en plats
*
* Freemium: maximalt 500 items totalt (AppConfig.FREE_ITEM_LIMIT).
* Gränsen kontrolleras i AddItemUseCase INNAN item sparas.
*/
data class Item(
val id: Long = 0,
val boxId: Long? = null,
val roomId: Long? = null,
val locationId: Long? = null,
val name: String,
val description: String = "",
val quantity: Int = 1,
val value: Double? = null,
val unit: String? = null,
val notes: String = "",
val createdAt: Long = 0,
val updatedAt: Long = 0,
)

View file

@ -0,0 +1,20 @@
package com.roundingmobile.boi.domain.model
/**
* ItemPhoto domänmodell för ett foto kopplat till ett Item.
*
* filePath: absolut sökväg inuti context.filesDir (internt lagring).
* Foton sparas ALDRIG externt lagring se CLAUDE.md säkerhetsregler.
*
* sortOrder: 0 = förstafoto (visas som thumbnail i ItemCard-komponenten).
*
* Freemium: maximalt 3 foton per item (AppConfig.FREE_PHOTOS_PER_ITEM).
* Gränsen kontrolleras i AddPhotoUseCase INNAN foto sparas.
*/
data class ItemPhoto(
val id: Long = 0,
val itemId: Long,
val filePath: String,
val sortOrder: Int = 0,
val createdAt: Long = 0,
)

View file

@ -0,0 +1,18 @@
package com.roundingmobile.boi.domain.model
/**
* Location domänmodell för en fysisk plats.
*
* Ren Kotlin inga Android-importer. Domänlagret känner inte till
* Room, Context eller Compose. Det gör domänlogiken testbar utan emulator.
*
* Hierarki: Location StorageRoom Box Item
*/
data class Location(
val id: Long = 0,
val name: String,
val description: String = "",
val photoPath: String? = null,
val createdAt: Long = 0,
val updatedAt: Long = 0,
)

View file

@ -0,0 +1,21 @@
package com.roundingmobile.boi.domain.model
/**
* LocationSummary domänmodell för en plats med aggregerade räknare.
*
* Används i HomeScreen för att visa antal rum och föremål per plats
* utan att göra separata queries för varje location.
*
* Separerad från Location för att hålla Location-modellen ren
* (utan aggregatdata som inte alltid behövs).
*/
data class LocationSummary(
val id: Long,
val name: String,
val description: String,
val photoPath: String? = null,
val roomCount: Int,
val itemCount: Int,
val createdAt: Long,
val updatedAt: Long,
)

View file

@ -0,0 +1,45 @@
package com.roundingmobile.boi.domain.model
/**
* SearchResult diskriminerat resultat från global sökning.
*
* Tre undertyper: föremål, lådor och platser.
* Varje undertyp bär det som behövs för att rendera ett sökkort och navigera.
*/
sealed class SearchResult {
data class ItemResult(
val itemId: Long,
val name: String,
val thumbnailPath: String?,
val boxName: String?,
val roomName: String?,
val locationName: String,
) : SearchResult()
data class BoxResult(
val boxId: Long,
val name: String,
val thumbnailPath: String?,
val roomName: String,
val locationName: String,
) : SearchResult()
data class LocationResult(
val locationId: Long,
val name: String,
val thumbnailPath: String?,
) : SearchResult()
}
/**
* SearchResults samlar resultaten per kategori för att möjliggöra
* grupperad visning i UI (Föremål Lådor Platser).
*/
data class SearchResults(
val items: List<SearchResult.ItemResult> = emptyList(),
val boxes: List<SearchResult.BoxResult> = emptyList(),
val locations: List<SearchResult.LocationResult> = emptyList(),
) {
fun isEmpty(): Boolean = items.isEmpty() && boxes.isEmpty() && locations.isEmpty()
}

View file

@ -0,0 +1,17 @@
package com.roundingmobile.boi.domain.model
/**
* StorageRoom domänmodell för ett rum eller område inom en Location.
*
* Namnkonvention "StorageRoom" (inte "Room") för att undvika kollision
* med android.room och Kotlin-keywordet.
*/
data class StorageRoom(
val id: Long = 0,
val locationId: Long,
val name: String,
val description: String = "",
val photoPath: String? = null,
val createdAt: Long = 0,
val updatedAt: Long = 0,
)

View file

@ -0,0 +1,15 @@
package com.roundingmobile.boi.domain.model
/**
* Tag domänmodell för en global tagg som kan kopplas till valfritt antal Items.
*
* colorHex: visuell färgmarkering i "#RRGGBB"-format.
* Lagras som sträng för att domänlagret inte ska bero Android Color-klassen.
* Konvertering sker i UI-lagret: Color(android.graphics.Color.parseColor(colorHex)).
*/
data class Tag(
val id: Long = 0,
val name: String,
val colorHex: String = "#6650A4",
val createdAt: Long = 0,
)

View file

@ -0,0 +1,50 @@
package com.roundingmobile.boi.domain.repository
import android.content.Intent
import com.roundingmobile.boi.domain.model.BackupInfo
import kotlinx.coroutines.flow.Flow
/**
* BackupRepository kontrakt för Google Drive-backup-operationer.
*
* Domänlagret definierar vad som behövs; datalagret (BackupRepositoryImpl)
* vet hur Google Drive och Sign-In fungerar.
*
* Notera: Intent är android.content.Intent, ett acceptabelt undantag
* i domänlagret för sign-in-flödet det inte finns en bättre abstraktion.
*/
interface BackupRepository {
/** Reaktivt konto-e-post. Null om ej inloggad. */
val signedInEmail: Flow<String?>
/** Epoch-millis för senaste lyckade backup. Null om ingen backup gjorts. */
val lastBackupTime: Flow<Long?>
// ── Google Sign-In ────────────────────────────────────────────────────────
/** Intent som startar Googles sign-in-dialog. */
fun getSignInIntent(): Intent
/** Extraherar kontot från Intent-resultatet och sparar e-posten. */
suspend fun handleSignInResult(data: Intent?): Result<String>
suspend fun signOut()
// ── Drive-operationer ──────────────────────────────────────────────────────
/** Laddar upp en krypterad backup-fil till Drive-mappen BoxOrganizerBackup. */
suspend fun uploadBackup(encryptedData: ByteArray, fileName: String): Result<Unit>
/** Listar tillgängliga backuper i Drive-mappen, nyaste först. */
suspend fun listBackups(): Result<List<BackupInfo>>
/** Laddar ner rå krypterad backup-data för en given fil-ID. */
suspend fun downloadBackup(fileId: String): Result<ByteArray>
/** Sparar tidsstämpel för senaste backup i DataStore. */
suspend fun saveLastBackupTime(timestamp: Long)
/** Behåller de [keepCount] nyaste backuperna, raderar resten. */
suspend fun pruneOldBackups(keepCount: Int = 5): Result<Unit>
}

View file

@ -0,0 +1,36 @@
package com.roundingmobile.boi.domain.repository
import kotlinx.coroutines.flow.Flow
/**
* BiometricRepository kontrakt för PIN-hantering och biometriinställningar.
*
* PIN lagras som PBKDF2-hash (aldrig i klartext) i EncryptedSharedPreferences.
* Aktiverad-flaggan lagras i DataStore.
*
* Notera: "biometri" täcker fingeravtryck, ansikts-ID och alla
* BiometricPrompt-mekanismer. Fallback-PIN är alltid tillgänglig.
*/
interface BiometricRepository {
/** Reaktivt tillstånd true om applåset är aktiverat. */
val isEnabled: Flow<Boolean>
/** Aktiverar eller inaktiverar applåset. */
suspend fun setEnabled(enabled: Boolean)
/** Sparar ett nytt PIN som PBKDF2-hash. Genererar nytt salt per anrop. */
suspend fun setupPin(pin: String)
/**
* Verifierar angiven PIN mot lagrad hash.
* Returnerar false om ingen PIN är konfigurerad.
*/
suspend fun verifyPin(pin: String): Boolean
/** Raderar lagrat PIN-hash och salt. */
suspend fun clearPin()
/** True om enheten har biometrisk hårdvara med inlagda fingeravtryck/ansikte. */
fun isHardwareAvailable(): Boolean
}

View file

@ -0,0 +1,22 @@
package com.roundingmobile.boi.domain.repository
import com.roundingmobile.boi.domain.model.Box
import kotlinx.coroutines.flow.Flow
interface BoxRepository {
fun getBoxesByRoom(roomId: Long): Flow<List<Box>>
suspend fun getBoxById(id: Long): Box?
fun observeBoxById(id: Long): Flow<Box?>
/** Används av QR-skannern (Fas 13) för direktnavigering till rätt box. */
suspend fun getBoxByQrCode(qrCode: String): Box?
suspend fun addBox(box: Box): Result<Long>
suspend fun updateBox(box: Box): Result<Unit>
suspend fun deleteBox(box: Box): Result<Unit>
}

View file

@ -0,0 +1,24 @@
package com.roundingmobile.boi.domain.repository
import com.roundingmobile.boi.domain.model.ItemPhoto
import kotlinx.coroutines.flow.Flow
interface ItemPhotoRepository {
fun getPhotosByItem(itemId: Long): Flow<List<ItemPhoto>>
/**
* Engångsmätning av antal foton för ett item.
* Anropas av AddPhotoUseCase INNAN insert för freemium-kontroll (max 3 gratis).
*/
suspend fun getPhotoCountForItem(itemId: Long): Int
suspend fun addPhoto(photo: ItemPhoto): Result<Long>
suspend fun updatePhoto(photo: ItemPhoto): Result<Unit>
suspend fun deletePhoto(photo: ItemPhoto): Result<Unit>
/** Raderar alla fotorader i databasen för ett item. Anropas före item-borttagning. */
suspend fun deleteAllPhotosForItem(itemId: Long): Result<Unit>
}

View file

@ -0,0 +1,33 @@
package com.roundingmobile.boi.domain.repository
import com.roundingmobile.boi.domain.model.Item
import kotlinx.coroutines.flow.Flow
interface ItemRepository {
fun getItemsByBox(boxId: Long): Flow<List<Item>>
fun getItemsByRoom(roomId: Long): Flow<List<Item>>
fun getItemsByLocation(locationId: Long): Flow<List<Item>>
/**
* Reaktivt antal items totalt i hela databasen.
* Används för att visa räknaren "X / 500" kontinuerligt i UI.
*/
fun getTotalItemCount(): Flow<Int>
/**
* Engångsmätning av totalt antal items.
* Anropas av AddItemUseCase INNAN insert för freemium-kontroll.
*/
suspend fun getTotalItemCountOnce(): Int
suspend fun getItemById(id: Long): Item?
suspend fun addItem(item: Item): Result<Long>
suspend fun updateItem(item: Item): Result<Unit>
suspend fun deleteItem(item: Item): Result<Unit>
}

View file

@ -0,0 +1,31 @@
package com.roundingmobile.boi.domain.repository
import com.roundingmobile.boi.domain.model.Location
import com.roundingmobile.boi.domain.model.LocationSummary
import kotlinx.coroutines.flow.Flow
/**
* LocationRepository gränssnitt för Location-data.
*
* Domänlagret definierar kontraktet. Datalagret (LocationRepositoryImpl)
* uppfyller kontraktet och känner till Room/SQLCipher.
*
* Alla felfall returneras som Result.failure() aldrig okastade exceptions.
* Repositories kastar aldrig; UseCases hanterar Result och omvandlar till UiState.
*/
interface LocationRepository {
fun getAllLocations(): Flow<List<Location>>
fun getLocationSummaries(): Flow<List<LocationSummary>>
suspend fun getLocationById(id: Long): Location?
fun observeLocationById(id: Long): Flow<Location?>
suspend fun addLocation(location: Location): Result<Long>
suspend fun updateLocation(location: Location): Result<Unit>
suspend fun deleteLocation(location: Location): Result<Unit>
}

View file

@ -0,0 +1,22 @@
package com.roundingmobile.boi.domain.repository
import kotlinx.coroutines.flow.Flow
/**
* ProStatusRepository hanterar PRO-prenumerationsstatus.
*
* isPro: reaktiv ström från DataStore. Används i ViewModels för att
* kontinuerligt spegla PRO-statusen i UI utan polling.
*
* setProStatus(): anropas av BillingRepository när ett köp bekräftas
* eller återkallas av Google Play Billing.
*
* Implementationen (ProStatusRepositoryImpl) använder DataStore<Preferences>
* för persistent lagring säkrare än SharedPreferences för känslig status.
*/
interface ProStatusRepository {
val isPro: Flow<Boolean>
suspend fun setProStatus(isPro: Boolean)
}

View file

@ -0,0 +1,15 @@
package com.roundingmobile.boi.domain.repository
import com.roundingmobile.boi.domain.model.SearchResults
import kotlinx.coroutines.flow.Flow
/**
* SearchRepository global sökning över föremål, lådor och platser.
*
* Implementationen kombinerar FTS4 (items) och LIKE (boxes, locations)
* och returnerar grupperade resultat redo för UI-konsumtion.
*/
interface SearchRepository {
fun search(query: String): Flow<SearchResults>
}

View file

@ -0,0 +1,19 @@
package com.roundingmobile.boi.domain.repository
import com.roundingmobile.boi.domain.model.StorageRoom
import kotlinx.coroutines.flow.Flow
interface StorageRoomRepository {
fun getRoomsByLocation(locationId: Long): Flow<List<StorageRoom>>
suspend fun getRoomById(id: Long): StorageRoom?
fun observeRoomById(id: Long): Flow<StorageRoom?>
suspend fun addRoom(room: StorageRoom): Result<Long>
suspend fun updateRoom(room: StorageRoom): Result<Unit>
suspend fun deleteRoom(room: StorageRoom): Result<Unit>
}

View file

@ -0,0 +1,25 @@
package com.roundingmobile.boi.domain.repository
import com.roundingmobile.boi.domain.model.Tag
import kotlinx.coroutines.flow.Flow
interface TagRepository {
fun getAllTags(): Flow<List<Tag>>
fun getTagsForItem(itemId: Long): Flow<List<Tag>>
suspend fun getTagById(id: Long): Tag?
suspend fun addTag(tag: Tag): Result<Long>
suspend fun updateTag(tag: Tag): Result<Unit>
suspend fun deleteTag(tag: Tag): Result<Unit>
suspend fun addTagToItem(itemId: Long, tagId: Long): Result<Unit>
suspend fun removeTagFromItem(itemId: Long, tagId: Long): Result<Unit>
suspend fun removeAllTagsFromItem(itemId: Long): Result<Unit>
}

View file

@ -0,0 +1,12 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.Box
import com.roundingmobile.boi.domain.repository.BoxRepository
import javax.inject.Inject
class AddBoxUseCase @Inject constructor(
private val boxRepository: BoxRepository,
) {
suspend operator fun invoke(box: Box): Result<Long> =
boxRepository.addBox(box)
}

View file

@ -0,0 +1,43 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.AppConfig
import com.roundingmobile.boi.domain.model.FreemiumException
import com.roundingmobile.boi.domain.model.Item
import com.roundingmobile.boi.domain.repository.ItemRepository
import com.roundingmobile.boi.domain.repository.ProStatusRepository
import kotlinx.coroutines.flow.first
import javax.inject.Inject
/**
* AddItemUseCase lägger till ett nytt föremål med freemium-kontroll.
*
* Flöde:
* 1. Hämta aktuell PRO-status (första värdet från Flow).
* 2. Om FREE: kontrollera totalt antal items INNAN insert.
* Om count >= FREE_ITEM_LIMIT returnera Result.failure(FreemiumException.ItemLimitReached).
* 3. Om PRO (eller under gränsen): spara i databasen.
*
* Varför i UseCase och inte Repository?
* Freemium-regeln är affärslogik, inte dataåtkomst.
* Repository vet inte och ska inte veta om PRO-status.
* Separationen gör regeln lätt att hitta, testa och ändra.
*
* Varför INNAN insert (inte EFTER)?
* Ett misslyckat insert efter att datan visats i UI skapar en inkonsekvent upplevelse.
* Kontroll INNAN enkel rollback, inget att ångra.
*/
class AddItemUseCase @Inject constructor(
private val itemRepository: ItemRepository,
private val proStatusRepository: ProStatusRepository,
) {
suspend operator fun invoke(item: Item): Result<Long> {
val isPro = proStatusRepository.isPro.first()
if (!isPro) {
val currentCount = itemRepository.getTotalItemCountOnce()
if (currentCount >= AppConfig.FREE_ITEM_LIMIT) {
return Result.failure(FreemiumException.ItemLimitReached())
}
}
return itemRepository.addItem(item)
}
}

View file

@ -0,0 +1,12 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.Location
import com.roundingmobile.boi.domain.repository.LocationRepository
import javax.inject.Inject
class AddLocationUseCase @Inject constructor(
private val locationRepository: LocationRepository,
) {
suspend operator fun invoke(location: Location): Result<Long> =
locationRepository.addLocation(location)
}

View file

@ -0,0 +1,31 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.AppConfig
import com.roundingmobile.boi.domain.model.FreemiumException
import com.roundingmobile.boi.domain.model.ItemPhoto
import com.roundingmobile.boi.domain.repository.ItemPhotoRepository
import com.roundingmobile.boi.domain.repository.ProStatusRepository
import kotlinx.coroutines.flow.first
import javax.inject.Inject
/**
* AddPhotoUseCase lägger till ett foto med freemium-kontroll per item.
*
* Kontrollerar per-item-gränsen (FREE_PHOTOS_PER_ITEM = 3), inte en global gräns.
* PRO-användare: obegränsat antal foton per föremål.
*/
class AddPhotoUseCase @Inject constructor(
private val itemPhotoRepository: ItemPhotoRepository,
private val proStatusRepository: ProStatusRepository,
) {
suspend operator fun invoke(photo: ItemPhoto): Result<Long> {
val isPro = proStatusRepository.isPro.first()
if (!isPro) {
val photoCount = itemPhotoRepository.getPhotoCountForItem(photo.itemId)
if (photoCount >= AppConfig.FREE_PHOTOS_PER_ITEM) {
return Result.failure(FreemiumException.PhotoLimitReached())
}
}
return itemPhotoRepository.addPhoto(photo)
}
}

View file

@ -0,0 +1,12 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.StorageRoom
import com.roundingmobile.boi.domain.repository.StorageRoomRepository
import javax.inject.Inject
class AddRoomUseCase @Inject constructor(
private val storageRoomRepository: StorageRoomRepository,
) {
suspend operator fun invoke(room: StorageRoom): Result<Long> =
storageRoomRepository.addRoom(room)
}

View file

@ -0,0 +1,11 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.repository.TagRepository
import javax.inject.Inject
class AddTagToItemUseCase @Inject constructor(
private val tagRepository: TagRepository,
) {
suspend operator fun invoke(itemId: Long, tagId: Long): Result<Unit> =
tagRepository.addTagToItem(itemId, tagId)
}

View file

@ -0,0 +1,12 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.Tag
import com.roundingmobile.boi.domain.repository.TagRepository
import javax.inject.Inject
class AddTagUseCase @Inject constructor(
private val tagRepository: TagRepository,
) {
suspend operator fun invoke(tag: Tag): Result<Long> =
tagRepository.addTag(tag)
}

View file

@ -0,0 +1,39 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.data.backup.BackupCrypto
import com.roundingmobile.boi.data.backup.BackupSerializer
import com.roundingmobile.boi.domain.repository.BackupRepository
import kotlinx.coroutines.flow.first
import javax.inject.Inject
/**
* BackupNowUseCase kör en komplett backup-sekvens:
* 1. Serialiserar databasen till JSON-bytes.
* 2. Krypterar med AES-256 och Google-kontots e-post som nyckelkälla.
* 3. Laddar upp till Drive-mappen BoxOrganizerBackup.
* 4. Rensar gamla backuper (behåller de 5 senaste).
* 5. Sparar tidsstämpel för senaste lyckade backup.
*/
class BackupNowUseCase @Inject constructor(
private val backupSerializer: BackupSerializer,
private val backupCrypto: BackupCrypto,
private val backupRepository: BackupRepository,
) {
suspend operator fun invoke(): Result<Unit> = runCatching {
val email = backupRepository.signedInEmail.first()
?: error("Ingen Google-inloggning logga in för att säkerhetskopiera")
val jsonBytes = backupSerializer.serialize()
val encryptedBytes = backupCrypto.encrypt(jsonBytes, email)
val fileName = buildFileName()
backupRepository.uploadBackup(encryptedBytes, fileName).getOrThrow()
backupRepository.pruneOldBackups(keepCount = 5).getOrThrow()
backupRepository.saveLastBackupTime(System.currentTimeMillis())
}
private fun buildFileName(): String {
val sdf = java.text.SimpleDateFormat("yyyyMMdd_HHmmss", java.util.Locale.getDefault())
return "backup_${sdf.format(java.util.Date())}.boi"
}
}

View file

@ -0,0 +1,12 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.Box
import com.roundingmobile.boi.domain.repository.BoxRepository
import javax.inject.Inject
class DeleteBoxUseCase @Inject constructor(
private val boxRepository: BoxRepository,
) {
suspend operator fun invoke(box: Box): Result<Unit> =
boxRepository.deleteBox(box)
}

View file

@ -0,0 +1,12 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.Item
import com.roundingmobile.boi.domain.repository.ItemRepository
import javax.inject.Inject
class DeleteItemUseCase @Inject constructor(
private val itemRepository: ItemRepository,
) {
suspend operator fun invoke(item: Item): Result<Unit> =
itemRepository.deleteItem(item)
}

View file

@ -0,0 +1,12 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.Location
import com.roundingmobile.boi.domain.repository.LocationRepository
import javax.inject.Inject
class DeleteLocationUseCase @Inject constructor(
private val locationRepository: LocationRepository,
) {
suspend operator fun invoke(location: Location): Result<Unit> =
locationRepository.deleteLocation(location)
}

View file

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

View file

@ -0,0 +1,12 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.StorageRoom
import com.roundingmobile.boi.domain.repository.StorageRoomRepository
import javax.inject.Inject
class DeleteRoomUseCase @Inject constructor(
private val storageRoomRepository: StorageRoomRepository,
) {
suspend operator fun invoke(room: StorageRoom): Result<Unit> =
storageRoomRepository.deleteRoom(room)
}

View file

@ -0,0 +1,12 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.Tag
import com.roundingmobile.boi.domain.repository.TagRepository
import javax.inject.Inject
class DeleteTagUseCase @Inject constructor(
private val tagRepository: TagRepository,
) {
suspend operator fun invoke(tag: Tag): Result<Unit> =
tagRepository.deleteTag(tag)
}

View file

@ -0,0 +1,28 @@
package com.roundingmobile.boi.domain.usecase
import android.net.Uri
import com.roundingmobile.boi.data.backup.BackupSerializer
import com.roundingmobile.boi.data.local.dao.BackupDao
import com.roundingmobile.boi.util.InventoryPackager
import javax.inject.Inject
/**
* ExportInventoryFileUseCase skapar en `.boxorg`-fil med hela inventariet.
*
* Flöde:
* 1. Hämtar alla entiteter via BackupDao (en transaktion per tabell).
* 2. Serialiserar till JSON med BackupSerializer.
* 3. Packar JSON + komprimerade foton i ett ZIP-arkiv (.boxorg).
* 4. Returnerar FileProvider-URI för ShareSheet.
*/
class ExportInventoryFileUseCase @Inject constructor(
private val backupSerializer: BackupSerializer,
private val backupDao: BackupDao,
private val inventoryPackager: InventoryPackager,
) {
suspend operator fun invoke(): Result<Uri> = runCatching {
val jsonBytes = backupSerializer.serialize()
val photos = backupDao.getAllPhotos()
inventoryPackager.pack(jsonBytes, photos).getOrThrow()
}
}

View file

@ -0,0 +1,82 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.ExportBox
import com.roundingmobile.boi.domain.model.ExportData
import com.roundingmobile.boi.domain.model.ExportItem
import com.roundingmobile.boi.domain.model.ExportLocation
import com.roundingmobile.boi.domain.model.ExportRoom
import com.roundingmobile.boi.domain.repository.BoxRepository
import com.roundingmobile.boi.domain.repository.ItemPhotoRepository
import com.roundingmobile.boi.domain.repository.ItemRepository
import com.roundingmobile.boi.domain.repository.LocationRepository
import com.roundingmobile.boi.domain.repository.StorageRoomRepository
import com.roundingmobile.boi.domain.repository.TagRepository
import kotlinx.coroutines.flow.first
import javax.inject.Inject
/**
* ExportInventoryUseCase samlar hela inventeringen i en ExportData-struktur.
*
* Utför N+1-anrop via .first() varje reaktiv ström. Acceptabelt för 500 föremål.
* Returnerar Result.failure() om något lager-anrop kastar ett undantag.
*/
class ExportInventoryUseCase @Inject constructor(
private val locationRepository: LocationRepository,
private val storageRoomRepository: StorageRoomRepository,
private val boxRepository: BoxRepository,
private val itemRepository: ItemRepository,
private val tagRepository: TagRepository,
private val itemPhotoRepository: ItemPhotoRepository,
) {
suspend operator fun invoke(): Result<ExportData> = runCatching {
val locations = locationRepository.getAllLocations().first()
val exportLocations = locations.map { location ->
val rooms = storageRoomRepository.getRoomsByLocation(location.id).first()
val exportRooms = rooms.map { room ->
val boxes = boxRepository.getBoxesByRoom(room.id).first()
val exportBoxes = boxes.map { box ->
val items = itemRepository.getItemsByBox(box.id).first()
val exportItems = items.map { item ->
val tags = tagRepository.getTagsForItem(item.id).first()
val photos = itemPhotoRepository.getPhotosByItem(item.id).first()
ExportItem(
name = item.name,
description = item.description,
quantity = item.quantity,
value = item.value,
unit = item.unit,
notes = item.notes,
tags = tags.map { it.name },
firstPhotoPath = photos.firstOrNull()?.filePath,
)
}
ExportBox(
name = box.name,
description = box.description,
items = exportItems,
)
}
ExportRoom(
name = room.name,
description = room.description,
boxes = exportBoxes,
)
}
ExportLocation(
name = location.name,
description = location.description,
rooms = exportRooms,
)
}
ExportData(locations = exportLocations)
}
}

View file

@ -0,0 +1,12 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.Tag
import com.roundingmobile.boi.domain.repository.TagRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class GetAllTagsUseCase @Inject constructor(
private val tagRepository: TagRepository,
) {
operator fun invoke(): Flow<List<Tag>> = tagRepository.getAllTags()
}

View file

@ -0,0 +1,16 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.BackupInfo
import com.roundingmobile.boi.domain.repository.BackupRepository
import javax.inject.Inject
/**
* GetBackupListUseCase hämtar listan med tillgängliga backuper från Drive,
* sorterade med den nyaste först.
*/
class GetBackupListUseCase @Inject constructor(
private val backupRepository: BackupRepository,
) {
suspend operator fun invoke(): Result<List<BackupInfo>> =
backupRepository.listBackups()
}

View file

@ -0,0 +1,11 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.Box
import com.roundingmobile.boi.domain.repository.BoxRepository
import javax.inject.Inject
class GetBoxByIdUseCase @Inject constructor(
private val boxRepository: BoxRepository,
) {
suspend operator fun invoke(id: Long): Box? = boxRepository.getBoxById(id)
}

View file

@ -0,0 +1,18 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.Box
import com.roundingmobile.boi.domain.repository.BoxRepository
import javax.inject.Inject
/**
* GetBoxByQrCodeUseCase används av QR-skannern (Fas 13) för att
* direkt navigera till rätt box efter en lyckad skanning.
*
* Returnerar null om koden inte matchar någon känd box.
*/
class GetBoxByQrCodeUseCase @Inject constructor(
private val boxRepository: BoxRepository,
) {
suspend operator fun invoke(qrCode: String): Box? =
boxRepository.getBoxByQrCode(qrCode)
}

View file

@ -0,0 +1,13 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.Box
import com.roundingmobile.boi.domain.repository.BoxRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class GetBoxesByRoomUseCase @Inject constructor(
private val boxRepository: BoxRepository,
) {
operator fun invoke(roomId: Long): Flow<List<Box>> =
boxRepository.getBoxesByRoom(roomId)
}

View file

@ -0,0 +1,11 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.Item
import com.roundingmobile.boi.domain.repository.ItemRepository
import javax.inject.Inject
class GetItemByIdUseCase @Inject constructor(
private val itemRepository: ItemRepository,
) {
suspend operator fun invoke(id: Long): Item? = itemRepository.getItemById(id)
}

View file

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

View file

@ -0,0 +1,16 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.Item
import com.roundingmobile.boi.domain.repository.ItemRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
/**
* Returnerar föremål som ligger direkt en plats (locationId != null, boxId = null, roomId = null).
*/
class GetItemsByLocationDirectUseCase @Inject constructor(
private val itemRepository: ItemRepository,
) {
operator fun invoke(locationId: Long): Flow<List<Item>> =
itemRepository.getItemsByLocation(locationId)
}

View file

@ -0,0 +1,16 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.Item
import com.roundingmobile.boi.domain.repository.ItemRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
/**
* Returnerar föremål som ligger direkt i ett rum (roomId != null, boxId = null).
*/
class GetItemsByRoomDirectUseCase @Inject constructor(
private val itemRepository: ItemRepository,
) {
operator fun invoke(roomId: Long): Flow<List<Item>> =
itemRepository.getItemsByRoom(roomId)
}

View file

@ -0,0 +1,11 @@
package com.roundingmobile.boi.domain.usecase
import com.roundingmobile.boi.domain.model.Location
import com.roundingmobile.boi.domain.repository.LocationRepository
import javax.inject.Inject
class GetLocationByIdUseCase @Inject constructor(
private val locationRepository: LocationRepository,
) {
suspend operator fun invoke(id: Long): Location? = locationRepository.getLocationById(id)
}

Some files were not shown because too many files have changed in this diff Show more