diff --git a/inventory-app/app/build.gradle b/inventory-app/app/build.gradle index 22a3067f63df..0112ed41c62c 100644 --- a/inventory-app/app/build.gradle +++ b/inventory-app/app/build.gradle @@ -21,8 +21,13 @@ android { viewBinding true } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { - jvmTarget = '17' + jvmTarget = '1.8' } } @@ -41,15 +46,23 @@ dependencies { implementation "com.squareup.okhttp3:okhttp:4.12.0" implementation "com.squareup.retrofit2:retrofit:2.11.0" + implementation "com.squareup.retrofit2:converter-gson:2.11.0" - implementation "org.apache.poi:poi-ooxml-lite:5.2.5" + // Apache POI for Excel and Streaming Reader + implementation "org.apache.poi:poi:5.2.5" + implementation "org.apache.poi:poi-ooxml:5.2.5" + // Using excel-streaming-reader for memory efficiency (batch processing of large files) + implementation "com.github.pjfanning:excel-streaming-reader:4.2.1" implementation "com.journeyapps:zxing-android-embedded:4.3.0" + implementation "com.google.zxing:core:3.5.3" implementation "com.google.dagger:hilt-android:2.51" kapt "com.google.dagger:hilt-compiler:2.51" implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.7.0" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.7.0" implementation "androidx.activity:activity-ktx:1.9.0" implementation "androidx.fragment:fragment-ktx:1.6.2" } diff --git a/inventory-app/app/src/main/AndroidManifest.xml b/inventory-app/app/src/main/AndroidManifest.xml index 6f95bf322585..2551ecfa416a 100644 --- a/inventory-app/app/src/main/AndroidManifest.xml +++ b/inventory-app/app/src/main/AndroidManifest.xml @@ -1,21 +1,38 @@ + + + - + android:label="Inventory App" + android:supportsRtl="true" + android:theme="@style/Theme.AppCompat.Light.DarkActionBar" + tools:replace="android:theme"> + + + + + + + diff --git a/inventory-app/app/src/main/java/com/example/inventory/InventoryApplication.kt b/inventory-app/app/src/main/java/com/example/inventory/InventoryApplication.kt new file mode 100644 index 000000000000..b8ce83e2c7e3 --- /dev/null +++ b/inventory-app/app/src/main/java/com/example/inventory/InventoryApplication.kt @@ -0,0 +1,7 @@ +package com.example.inventory + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class InventoryApplication : Application() diff --git a/inventory-app/app/src/main/java/com/example/inventory/data/AppDatabase.kt b/inventory-app/app/src/main/java/com/example/inventory/data/AppDatabase.kt index 2ca95d56ccf2..0a7f92d2526b 100644 --- a/inventory-app/app/src/main/java/com/example/inventory/data/AppDatabase.kt +++ b/inventory-app/app/src/main/java/com/example/inventory/data/AppDatabase.kt @@ -2,9 +2,11 @@ package com.example.inventory.data import androidx.room.Database import androidx.room.RoomDatabase +import com.example.inventory.data.dao.InventoryDao +import com.example.inventory.data.entity.Item +import com.example.inventory.data.entity.WarehouseStock -@Database(entities = [Item::class, WarehouseStock::class], version = 1) +@Database(entities = [Item::class, WarehouseStock::class], version = 1, exportSchema = false) abstract class AppDatabase : RoomDatabase() { - abstract fun itemDao(): ItemDao - abstract fun warehouseStockDao(): WarehouseStockDao + abstract fun inventoryDao(): InventoryDao } diff --git a/inventory-app/app/src/main/java/com/example/inventory/data/Item.kt b/inventory-app/app/src/main/java/com/example/inventory/data/Item.kt deleted file mode 100644 index 8244964546db..000000000000 --- a/inventory-app/app/src/main/java/com/example/inventory/data/Item.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.example.inventory.data - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.Index -import androidx.room.PrimaryKey - -@Entity(tableName = "items", indices = [Index(value = ["code"], unique = true)]) -data class Item( - @PrimaryKey(autoGenerate = true) val id: Long = 0, - @ColumnInfo(name = "code") val code: String, - @ColumnInfo(name = "name") val name: String, - @ColumnInfo(name = "price") val price: Double -) diff --git a/inventory-app/app/src/main/java/com/example/inventory/data/ItemDao.kt b/inventory-app/app/src/main/java/com/example/inventory/data/ItemDao.kt deleted file mode 100644 index 05ec3346c64f..000000000000 --- a/inventory-app/app/src/main/java/com/example/inventory/data/ItemDao.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.example.inventory.data - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Transaction - -@Dao -interface ItemDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertItems(items: List): List - - @Query("SELECT * FROM items WHERE code = :code LIMIT 1") - suspend fun findByCode(code: String): Item? - - @Transaction - @Query("SELECT * FROM items WHERE code = :code LIMIT 1") - suspend fun findWithStock(code: String): ItemWithStock? - - @Query("DELETE FROM items") - suspend fun clearItems() -} diff --git a/inventory-app/app/src/main/java/com/example/inventory/data/ItemWithStock.kt b/inventory-app/app/src/main/java/com/example/inventory/data/ItemWithStock.kt deleted file mode 100644 index 488e62ee740b..000000000000 --- a/inventory-app/app/src/main/java/com/example/inventory/data/ItemWithStock.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.example.inventory.data - -import androidx.room.Embedded -import androidx.room.Relation - -data class ItemWithStock( - @Embedded val item: Item, - @Relation(parentColumn = "id", entityColumn = "itemId") - val stock: List -) diff --git a/inventory-app/app/src/main/java/com/example/inventory/data/WarehouseStockDao.kt b/inventory-app/app/src/main/java/com/example/inventory/data/WarehouseStockDao.kt deleted file mode 100644 index aa9e3e9f1b8a..000000000000 --- a/inventory-app/app/src/main/java/com/example/inventory/data/WarehouseStockDao.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.example.inventory.data - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query - -@Dao -interface WarehouseStockDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertAll(stocks: List) - - @Query("DELETE FROM warehouse_stock") - suspend fun clearStock() -} diff --git a/inventory-app/app/src/main/java/com/example/inventory/data/api/DownloadService.kt b/inventory-app/app/src/main/java/com/example/inventory/data/api/DownloadService.kt new file mode 100644 index 000000000000..f13be3ff6cb8 --- /dev/null +++ b/inventory-app/app/src/main/java/com/example/inventory/data/api/DownloadService.kt @@ -0,0 +1,13 @@ +package com.example.inventory.data.api + +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Streaming +import retrofit2.http.Url + +interface DownloadService { + @Streaming + @GET + suspend fun downloadFile(@Url url: String): Response +} diff --git a/inventory-app/app/src/main/java/com/example/inventory/data/dao/InventoryDao.kt b/inventory-app/app/src/main/java/com/example/inventory/data/dao/InventoryDao.kt new file mode 100644 index 000000000000..a35fa88eca52 --- /dev/null +++ b/inventory-app/app/src/main/java/com/example/inventory/data/dao/InventoryDao.kt @@ -0,0 +1,42 @@ +package com.example.inventory.data.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.example.inventory.data.entity.Item +import com.example.inventory.data.entity.WarehouseStock + +@Dao +interface InventoryDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertItems(items: List): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertStocks(stocks: List) + + @Query("SELECT * FROM items WHERE code = :code LIMIT 1") + suspend fun getItemByCode(code: String): Item? + + @Query("SELECT * FROM warehouse_stocks WHERE itemId = :itemId AND quantity > 0") + suspend fun getStocksByItemId(itemId: Long): List + + @Query("DELETE FROM items") + suspend fun clearAllItems() + + @Query("DELETE FROM warehouse_stocks") + suspend fun clearAllStocks() + + @Transaction + suspend fun clearAndInsertBatch(items: List, stocks: Map>) { + // This method is tricky because we need IDs for stocks. + // It's better to handle logic in Repository to insert items, get IDs, then insert stocks. + // But for batch processing, we might want to do it differently. + // Actually, if we use code as a reference in stock it might be easier but requirement says: + // "Item (id, code, name, price) and WarehouseStock (itemId, warehouseName, quantity)" + + // So we will just provide basic insert methods and handle logic in repository/usecase. + } +} diff --git a/inventory-app/app/src/main/java/com/example/inventory/data/entity/Item.kt b/inventory-app/app/src/main/java/com/example/inventory/data/entity/Item.kt new file mode 100644 index 000000000000..0795f2b9876e --- /dev/null +++ b/inventory-app/app/src/main/java/com/example/inventory/data/entity/Item.kt @@ -0,0 +1,17 @@ +package com.example.inventory.data.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "items", + indices = [Index(value = ["code"], unique = true)] +) +data class Item( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val code: String, + val name: String, + val price: Double +) diff --git a/inventory-app/app/src/main/java/com/example/inventory/data/WarehouseStock.kt b/inventory-app/app/src/main/java/com/example/inventory/data/entity/WarehouseStock.kt similarity index 66% rename from inventory-app/app/src/main/java/com/example/inventory/data/WarehouseStock.kt rename to inventory-app/app/src/main/java/com/example/inventory/data/entity/WarehouseStock.kt index 92675ba8a977..7222938fba53 100644 --- a/inventory-app/app/src/main/java/com/example/inventory/data/WarehouseStock.kt +++ b/inventory-app/app/src/main/java/com/example/inventory/data/entity/WarehouseStock.kt @@ -1,12 +1,12 @@ -package com.example.inventory.data +package com.example.inventory.data.entity import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index +import androidx.room.PrimaryKey @Entity( - tableName = "warehouse_stock", - primaryKeys = ["itemId", "warehouseName"], + tableName = "warehouse_stocks", foreignKeys = [ ForeignKey( entity = Item::class, @@ -15,9 +15,11 @@ import androidx.room.Index onDelete = ForeignKey.CASCADE ) ], - indices = [Index("itemId"), Index("warehouseName")] + indices = [Index(value = ["itemId"])] ) data class WarehouseStock( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, val itemId: Long, val warehouseName: String, val quantity: Int diff --git a/inventory-app/app/src/main/java/com/example/inventory/di/DatabaseModule.kt b/inventory-app/app/src/main/java/com/example/inventory/di/DatabaseModule.kt new file mode 100644 index 000000000000..dfab6a2a1dee --- /dev/null +++ b/inventory-app/app/src/main/java/com/example/inventory/di/DatabaseModule.kt @@ -0,0 +1,32 @@ +package com.example.inventory.di + +import android.content.Context +import androidx.room.Room +import com.example.inventory.data.AppDatabase +import com.example.inventory.data.dao.InventoryDao +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 + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): AppDatabase { + return Room.databaseBuilder( + context, + AppDatabase::class.java, + "inventory_db" + ).fallbackToDestructiveMigration().build() + } + + @Provides + fun provideInventoryDao(database: AppDatabase): InventoryDao { + return database.inventoryDao() + } +} diff --git a/inventory-app/app/src/main/java/com/example/inventory/repository/InventoryRepository.kt b/inventory-app/app/src/main/java/com/example/inventory/repository/InventoryRepository.kt index 44c540fc6d1d..7a546197b07f 100644 --- a/inventory-app/app/src/main/java/com/example/inventory/repository/InventoryRepository.kt +++ b/inventory-app/app/src/main/java/com/example/inventory/repository/InventoryRepository.kt @@ -1,118 +1,90 @@ package com.example.inventory.repository -import com.example.inventory.data.AppDatabase -import com.example.inventory.data.Item -import com.example.inventory.data.ItemWithStock -import com.example.inventory.data.WarehouseStock -import kotlinx.coroutines.CoroutineDispatcher +import com.example.inventory.data.api.DownloadService +import com.example.inventory.data.dao.InventoryDao +import com.example.inventory.data.entity.Item +import com.example.inventory.data.entity.WarehouseStock +import com.example.inventory.util.ExcelParser import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.Request -import org.apache.poi.ss.usermodel.WorkbookFactory -import java.io.File -import java.io.FileInputStream - -data class PendingStock( - val itemIndex: Int, - val warehouseName: String, - val quantity: Int -) - -class InventoryRepository( - private val db: AppDatabase, - private val okHttpClient: OkHttpClient, - private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import okhttp3.ResponseBody +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.io.InputStream +import javax.inject.Inject + +class InventoryRepository @Inject constructor( + private val inventoryDao: InventoryDao ) { - - private val _importProgress = MutableStateFlow(0) - val importProgress: StateFlow = _importProgress.asStateFlow() - - suspend fun downloadAndImport(url: String) = withContext(ioDispatcher) { - val tempFile = File.createTempFile("inventory", ".xlsx") - okHttpClient.newCall(Request.Builder().url(url).build()).execute().use { resp -> - resp.body?.byteStream()?.use { input -> - tempFile.outputStream().use { output -> input.copyTo(output) } - } + // Ideally, injected via Hilt + private val retrofit = Retrofit.Builder() + .baseUrl("https://google.com/") // Placeholder, URL will be dynamic + .addConverterFactory(GsonConverterFactory.create()) + .build() + + private val downloadService = retrofit.create(DownloadService::class.java) + private val parser = ExcelParser() + + fun getItemByCode(code: String): Flow = flow { + emit(inventoryDao.getItemByCode(code)) + }.flowOn(Dispatchers.IO) + + fun getStocksByItemId(itemId: Long): Flow> = flow { + emit(inventoryDao.getStocksByItemId(itemId)) + }.flowOn(Dispatchers.IO) + + suspend fun downloadAndProcessExcel(url: String, onProgress: (String) -> Unit) { + // 1. Download + onProgress("Downloading...") + // For dynamic URL with retrofit, we can just pass the full URL to the @Url parameter + // The base URL doesn't matter much if we use @Url + val response = downloadService.downloadFile(url) + val body = response.body() + + if (!response.isSuccessful || body == null) { + throw Exception("Download failed: ${response.code()}") } - importExcel(tempFile) - } - - private suspend fun importExcel(file: File) = withContext(ioDispatcher) { - db.withTransaction { - db.itemDao().clearItems() - db.warehouseStockDao().clearStock() - } - - FileInputStream(file).use { fis -> - val workbook = WorkbookFactory.create(fis) - val sheet = workbook.getSheetAt(0) - val totalRows = sheet.lastRowNum - if (totalRows == 0) return@use - - val headerRow = sheet.getRow(0) - val headers = headerRow.map { it.stringCellValue.trim() } - val warehouseCols = headers.drop(3) - - val batchItems = mutableListOf() - val pendingStocks = mutableListOf() - var processed = 0 - for (rowIndex in 1..totalRows) { - val row = sheet.getRow(rowIndex) ?: continue - val code = row.getCell(0)?.stringCellValue.orEmpty() - val name = row.getCell(1)?.stringCellValue.orEmpty() - val price = row.getCell(2)?.numericCellValue ?: 0.0 - - val itemIndex = batchItems.size - batchItems.add(Item(code = code, name = name, price = price)) - - warehouseCols.forEachIndexed { idx, warehouseName -> - val qty = row.getCell(3 + idx)?.numericCellValue?.toInt() ?: 0 - if (qty > 0) { - pendingStocks.add(PendingStock(itemIndex, warehouseName, qty)) - } - } - - if (batchItems.size >= 500) { - persistBatch(batchItems, pendingStocks) - processed += batchItems.size - batchItems.clear() - pendingStocks.clear() - _importProgress.value = ((processed / totalRows.toFloat()) * 100).toInt().coerceAtMost(99) + // 2. Parse and Insert + onProgress("Processing...") + val inputStream: InputStream = body.byteStream() + + // Clear old data? Requirement implies inventory update. + // Strategy: Clear all before load or Update? + // Usually full replace for "Import". + inventoryDao.clearAllStocks() + inventoryDao.clearAllItems() + + var count = 0 + parser.parseSuspending(inputStream) { batch -> + // Batch is List + val items = batch.map { it.item } + + // Insert Items + val insertedIds = inventoryDao.insertItems(items) + + // Prepare Stocks with correct ItemIDs + val stocks = mutableListOf() + batch.forEachIndexed { index, parsedData -> + val itemId = insertedIds[index] // Assuming ordered return match ordered insert + parsedData.stocks.forEach { dummy -> + stocks.add(WarehouseStock( + itemId = itemId, + warehouseName = dummy.warehouseName, + quantity = dummy.quantity + )) } } - if (batchItems.isNotEmpty()) { - persistBatch(batchItems, pendingStocks) - processed += batchItems.size - } - _importProgress.value = 100 - workbook.close() - } - } + // Insert Stocks + inventoryDao.insertStocks(stocks) - private suspend fun persistBatch( - items: List, - pendingStocks: List - ) { - db.withTransaction { - val ids = db.itemDao().insertItems(items) - val idMap = ids.withIndex().associate { (index, id) -> index to id } - val stocks = pendingStocks.mapNotNull { pending -> - val itemId = idMap[pending.itemIndex] ?: return@mapNotNull null - WarehouseStock(itemId = itemId, warehouseName = pending.warehouseName, quantity = pending.quantity) - } - if (stocks.isNotEmpty()) { - db.warehouseStockDao().insertAll(stocks) - } + count += batch.size + onProgress("Processed $count records...") } - } - suspend fun searchByCode(code: String): ItemWithStock? = withContext(ioDispatcher) { - db.itemDao().findWithStock(code.trim()) + onProgress("Completed! Total: $count") } } diff --git a/inventory-app/app/src/main/java/com/example/inventory/ui/InventoryViewModel.kt b/inventory-app/app/src/main/java/com/example/inventory/ui/InventoryViewModel.kt deleted file mode 100644 index a8fa3033c0f5..000000000000 --- a/inventory-app/app/src/main/java/com/example/inventory/ui/InventoryViewModel.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.example.inventory.ui - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.example.inventory.data.ItemWithStock -import com.example.inventory.repository.InventoryRepository -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -class InventoryViewModel( - private val repository: InventoryRepository, - private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO -) : ViewModel() { - - val importProgress: StateFlow = repository.importProgress - - private val _searchResult = MutableStateFlow(null) - val searchResult: StateFlow = _searchResult.asStateFlow() - - fun import(url: String) { - viewModelScope.launch { - repository.downloadAndImport(url) - } - } - - fun search(code: String) { - viewModelScope.launch(ioDispatcher) { - _searchResult.value = repository.searchByCode(code) - } - } -} diff --git a/inventory-app/app/src/main/java/com/example/inventory/ui/MainActivity.kt b/inventory-app/app/src/main/java/com/example/inventory/ui/MainActivity.kt deleted file mode 100644 index 82dd48560e6d..000000000000 --- a/inventory-app/app/src/main/java/com/example/inventory/ui/MainActivity.kt +++ /dev/null @@ -1,107 +0,0 @@ -package com.example.inventory.ui - -import android.os.Bundle -import android.view.View -import android.widget.Button -import android.widget.EditText -import android.widget.ProgressBar -import android.widget.TextView -import androidx.activity.ComponentActivity -import androidx.activity.viewModels -import androidx.appcompat.app.AlertDialog -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.ViewModelProvider -import androidx.room.Room -import com.example.inventory.R -import com.example.inventory.data.AppDatabase -import com.example.inventory.data.WarehouseStock -import com.example.inventory.repository.InventoryRepository -import com.journeyapps.barcodescanner.ScanContract -import com.journeyapps.barcodescanner.ScanOptions -import kotlinx.coroutines.launch -import okhttp3.OkHttpClient - -class MainActivity : ComponentActivity() { - - private val viewModel: InventoryViewModel by viewModels { - object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - val db = Room.databaseBuilder( - applicationContext, - AppDatabase::class.java, - "inventory-db" - ).fallbackToDestructiveMigration().build() - val repo = InventoryRepository(db, OkHttpClient()) - @Suppress("UNCHECKED_CAST") - return InventoryViewModel(repo) as T - } - } - } - - private lateinit var etCode: EditText - private lateinit var tvResult: TextView - private lateinit var btnShowStock: Button - private lateinit var progressBar: ProgressBar - - private val barcodeLauncher = registerForActivityResult(ScanContract()) { result -> - result.contents?.let { - etCode.setText(it) - viewModel.search(it) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - - etCode = findViewById(R.id.etCode) - tvResult = findViewById(R.id.tvResult) - btnShowStock = findViewById(R.id.btnShowStock) - progressBar = findViewById(R.id.progressImport) - - findViewById