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:
parent
2784315bb4
commit
d1d64c3854
217 changed files with 20301 additions and 182 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
78
app/src/main/java/com/roundingmobile/boi/AppConfig.kt
Normal file
78
app/src/main/java/com/roundingmobile/boi/AppConfig.kt
Normal 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 (0–100).
|
||||
* 80 ger bra balans mellan kvalitet och filstorlek.
|
||||
*/
|
||||
const val IMAGE_COMPRESSION_QUALITY = 80
|
||||
}
|
||||
63
app/src/main/java/com/roundingmobile/boi/BoxOrganizerApp.kt
Normal file
63
app/src/main/java/com/roundingmobile/boi/BoxOrganizerApp.kt
Normal 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 på 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
214
app/src/main/java/com/roundingmobile/boi/MainActivity.kt
Normal file
214
app/src/main/java/com/roundingmobile/boi/MainActivity.kt
Normal 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 på 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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 på 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 på 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 på 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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 på 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>)
|
||||
}
|
||||
|
|
@ -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 på 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)
|
||||
}
|
||||
|
|
@ -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 på en plats (locationId). Varje query filtrerar på 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)
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 på ItemTagEntity direkt.
|
||||
* IGNORE på 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)
|
||||
}
|
||||
|
|
@ -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 så 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 på items-tabellen,
|
||||
* så att föremål kan ligga direkt i ett rum eller direkt på 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 på 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(),
|
||||
)
|
||||
|
|
@ -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 på 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 på 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(),
|
||||
)
|
||||
|
|
@ -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 på 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,
|
||||
)
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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 på 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,
|
||||
)
|
||||
|
|
@ -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 på 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(),
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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 på 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(),
|
||||
)
|
||||
|
|
@ -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 på name: förhindrar duplicerade taggar på 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(),
|
||||
)
|
||||
|
|
@ -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 på 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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 rå sökterm till FTS4 MATCH-syntax.
|
||||
*
|
||||
* Varje ord får ett trailing "*" för prefix-matching så 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("\"", "")}*" }
|
||||
}
|
||||
|
|
@ -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()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
30
app/src/main/java/com/roundingmobile/boi/di/AppModule.kt
Normal file
30
app/src/main/java/com/roundingmobile/boi/di/AppModule.kt
Normal 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()
|
||||
}
|
||||
55
app/src/main/java/com/roundingmobile/boi/di/BackupModule.kt
Normal file
55
app/src/main/java/com/roundingmobile/boi/di/BackupModule.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
136
app/src/main/java/com/roundingmobile/boi/di/DatabaseModule.kt
Normal file
136
app/src/main/java/com/roundingmobile/boi/di/DatabaseModule.kt
Normal 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 på 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()
|
||||
}
|
||||
|
|
@ -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 på 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 på 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")
|
||||
},
|
||||
)
|
||||
}
|
||||
108
app/src/main/java/com/roundingmobile/boi/di/RepositoryModule.kt
Normal file
108
app/src/main/java/com/roundingmobile/boi/di/RepositoryModule.kt
Normal 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),
|
||||
* så 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.roundingmobile.boi.domain.model
|
||||
|
||||
/**
|
||||
* BackupInfo – metadata för en backupfil lagrad på 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,
|
||||
)
|
||||
19
app/src/main/java/com/roundingmobile/boi/domain/model/Box.kt
Normal file
19
app/src/main/java/com/roundingmobile/boi/domain/model/Box.kt
Normal 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,
|
||||
)
|
||||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -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 på 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.",
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 på 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,
|
||||
)
|
||||
|
|
@ -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 på 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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
15
app/src/main/java/com/roundingmobile/boi/domain/model/Tag.kt
Normal file
15
app/src/main/java/com/roundingmobile/boi/domain/model/Tag.kt
Normal 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 på 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,
|
||||
)
|
||||
|
|
@ -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 då 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>
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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() på 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 på 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue