/* * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.bartek.iwentaryzacja import android.app.DownloadManager import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Environment import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.* import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextAlign import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.apache.poi.ss.usermodel.WorkbookFactory import org.json.JSONObject import java.io.File import java.io.InputStream import java.net.HttpURLConnection import java.net.URL import java.util.Scanner // ========================================== // 1. MODEL DANYCH MASZYNY // ========================================== data class Machine( val id: String, // Nr ewid. (np. 7065/K) val name: String, // Nazwa sprzętu val category: String, // Kategoria (np. minikoparki) val originalStatus: String, // Status z kolumny H val isChecked: Boolean = false ) // ========================================== // 2. VIEWMODEL (DOPASOWANY PARSER .XLSX) // ========================================== class InventoryViewModel : ViewModel() { private val _machines = MutableStateFlow>(emptyList()) val machines: StateFlow> = _machines.asStateFlow() private val _searchQuery = MutableStateFlow("") val searchQuery: StateFlow = _searchQuery.asStateFlow() private val _filterMissing = MutableStateFlow(false) val filterMissing: StateFlow = _filterMissing.asStateFlow() private val _notes = MutableStateFlow("") val notes: StateFlow = _notes.asStateFlow() private val _updateInfo = MutableStateFlow(null) val updateInfo: StateFlow = _updateInfo.asStateFlow() data class UpdateInfo(val versionCode: Int, val apkUrl: String) fun checkForUpdates(context: Context) { viewModelScope.launch(Dispatchers.IO) { try { val url = URL("https://bbjsolution.org/AR/InwentARyzacja/src/branch/master/update.json") val connection = url.openConnection() as HttpURLConnection connection.connectTimeout = 5000 connection.readTimeout = 5000 if (connection.responseCode == 200) { val scanner = Scanner(connection.inputStream).useDelimiter("\\A") val result = if (scanner.hasNext()) scanner.next() else "" val json = JSONObject(result) val newVersionCode = json.getInt("versionCode") val apkUrl = json.getString("url") val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) @Suppress("DEPRECATION") val currentVersionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { packageInfo.longVersionCode.toInt() } else { packageInfo.versionCode } if (newVersionCode > currentVersionCode) { _updateInfo.value = UpdateInfo(newVersionCode, apkUrl) } } } catch (e: Exception) { e.printStackTrace() } } } fun downloadAndInstall(context: Context, apkUrl: String) { val request = DownloadManager.Request(Uri.parse(apkUrl)) .setTitle("Aktualizacja inwentARyzacja") .setDescription("Pobieranie nowej wersji...") .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) .setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, "update.apk") .setAllowedOverMetered(true) .setAllowedOverRoaming(true) val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager downloadManager.enqueue(request) } fun setSearchQuery(query: String) { _searchQuery.value = query } fun setNotes(newNotes: String) { _notes.value = newNotes } fun appendDigit(digit: String) { _searchQuery.value += digit } fun backspace() { if (_searchQuery.value.isNotEmpty()) { _searchQuery.value = _searchQuery.value.dropLast(1) } } fun clearSearch() { _searchQuery.value = "" } fun toggleFilterMissing() { _filterMissing.value = !_filterMissing.value } fun getUniqueStatuses(): List { return _machines.value .map { it.originalStatus } .filter { it.isNotEmpty() } .distinct() .sorted() } fun markMachinesByStatuses(statuses: Set, isChecked: Boolean) { _machines.value = _machines.value.map { machine -> if (statuses.contains(machine.originalStatus)) { machine.copy(isChecked = isChecked) } else { machine } } } fun toggleMachineCheck(machineId: String) { _machines.value = _machines.value.map { machine -> if (machine.id == machineId) { machine.copy(isChecked = !machine.isChecked) } else { machine } } } // Parser zoptymalizowany pod strukturę raportu z systemu fun parseXlsFile(inputStream: InputStream) { try { // WorkbookFactory automatycznie obsługuje zarówno .xls jak i .xlsx val workbook = WorkbookFactory.create(inputStream) val sheet = workbook.getSheetAt(0) val parsedList = mutableListOf() // Dane zaczynają się od wiersza indeks 2 (po 2 wierszach nagłówka) for (rowIndex in 2..sheet.lastRowNum) { val row = sheet.getRow(rowIndex) ?: continue val category = row.getCell(1)?.toString()?.trim() ?: "" val id = row.getCell(2)?.toString()?.trim() ?: "" val name = row.getCell(3)?.toString()?.trim() ?: "" val status = row.getCell(7)?.toString()?.trim() ?: "" // Wczytujemy tylko, gdy pozycja ma nazwę i nr ewidencyjny if (name.isNotEmpty() && id.isNotEmpty()) { parsedList.add( Machine( id = id, name = name, category = category, originalStatus = status ) ) } } _machines.value = parsedList workbook.close() } catch (e: Exception) { e.printStackTrace() } } } // ========================================== // 3. GŁÓWNA AKTYWNOŚĆ // ========================================== class MainActivity : ComponentActivity() { private val onDownloadComplete = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) if (id != -1L) { installApk(context) } } } private fun installApk(context: Context) { val file = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "update.apk") if (file.exists()) { val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) val intent = Intent(Intent.ACTION_VIEW) intent.setDataAndType(uri, "application/vnd.android.package-archive") intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() ContextCompat.registerReceiver( this, onDownloadComplete, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), ContextCompat.RECEIVER_EXPORTED ) setContent { MaterialTheme( colorScheme = darkColorScheme( primary = Color(0xFF007ACC), background = Color(0xFF121824), surface = Color(0xFF1E2638) ) ) { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { InventoryScreen() } } } } override fun onDestroy() { super.onDestroy() unregisterReceiver(onDownloadComplete) } } // ========================================== // 4. EKRAN GŁÓWNY // ========================================== @OptIn(ExperimentalMaterial3Api::class) @Composable fun InventoryScreen(viewModel: InventoryViewModel = viewModel()) { val context = LocalContext.current val machines by viewModel.machines.collectAsState() val searchQuery by viewModel.searchQuery.collectAsState() val filterMissing by viewModel.filterMissing.collectAsState() val notes by viewModel.notes.collectAsState() val updateInfo by viewModel.updateInfo.collectAsState() var showStatusDialog by remember { mutableStateOf(false) } var showNotesDialog by remember { mutableStateOf(false) } var showUpdateDialog by remember { mutableStateOf(false) } LaunchedEffect(Unit) { viewModel.checkForUpdates(context) } LaunchedEffect(updateInfo) { if (updateInfo != null) { showUpdateDialog = true } } if (showUpdateDialog && updateInfo != null) { AlertDialog( onDismissRequest = { showUpdateDialog = false }, title = { Text("Dostępna aktualizacja", color = Color.White) }, text = { Text("Czy chcesz pobrać i zainstalować nową wersję aplikacji?", color = Color.White) }, confirmButton = { Button(onClick = { viewModel.downloadAndInstall(context, updateInfo!!.apkUrl) showUpdateDialog = false }) { Text("Pobierz") } }, dismissButton = { TextButton(onClick = { showUpdateDialog = false }) { Text("Później") } }, containerColor = Color(0xFF1E2638) ) } // Launcher akceptujący pliki .xlsx oraz .xls val filePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent() ) { uri: Uri? -> uri?.let { context.contentResolver.openInputStream(it)?.use { inputStream -> viewModel.parseXlsFile(inputStream) } } } if (showStatusDialog) { StatusSelectionDialog( statuses = viewModel.getUniqueStatuses(), onDismiss = { showStatusDialog = false }, onConfirm = { selectedStatuses -> viewModel.markMachinesByStatuses(selectedStatuses, true) showStatusDialog = false } ) } if (showNotesDialog) { NotesDialog( notes = notes, onNotesChange = { viewModel.setNotes(it) }, onDismiss = { showNotesDialog = false } ) } val filteredMachines = remember(machines, searchQuery, filterMissing) { var list = if (searchQuery.isBlank()) machines else machines.filter { it.name.contains(searchQuery, ignoreCase = true) || it.id.contains(searchQuery, ignoreCase = true) || it.category.contains(searchQuery, ignoreCase = true) } if (filterMissing) { list = list.filter { !it.isChecked } } list } val checkedCount = machines.count { it.isChecked } val totalCount = machines.size val progress = if (totalCount > 0) checkedCount.toFloat() / totalCount.toFloat() else 0f Scaffold( modifier = Modifier.fillMaxSize(), containerColor = MaterialTheme.colorScheme.background, contentWindowInsets = WindowInsets.safeDrawing ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) .padding(horizontal = 16.dp, vertical = 8.dp) ) { // Cztery małe kafelki w jednej linii Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp) ) { DashboardTile( title = "Wczytaj", icon = Icons.Default.FileDownload, color = Color(0xFF007ACC), onClick = { filePickerLauncher.launch("*/*") }, modifier = Modifier.weight(1f) ) DashboardTile( title = if (filterMissing) "Wszystkie" else "Brakujące", icon = if (filterMissing) Icons.AutoMirrored.Filled.List else Icons.Default.Search, color = if (filterMissing) Color(0xFFFF9800) else Color(0xFFE91E63), onClick = { viewModel.toggleFilterMissing() }, modifier = Modifier.weight(1f) ) DashboardTile( title = "Statusy", icon = Icons.Default.Info, color = Color(0xFF9C27B0), onClick = { showStatusDialog = true }, modifier = Modifier.weight(1f) ) DashboardTile( title = "Dopisz", icon = Icons.Default.EditNote, color = Color(0xFF4CAF50), onClick = { showNotesDialog = true }, modifier = Modifier.weight(1f) ) } Spacer(modifier = Modifier.height(16.dp)) if (machines.isNotEmpty()) { Card( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), shape = RoundedCornerShape(8.dp), modifier = Modifier.fillMaxWidth() ) { Column(modifier = Modifier.padding(8.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { Text("Postęp:", color = Color.Gray, fontSize = 12.sp) Text( "$checkedCount / $totalCount (${(progress * 100).toInt()}%)", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 12.sp ) } Spacer(modifier = Modifier.height(4.dp)) LinearProgressIndicator( progress = { progress }, modifier = Modifier .fillMaxWidth() .height(4.dp) .clip(RoundedCornerShape(2.dp)), color = Color(0xFF11CAA0), trackColor = Color(0xFF2A3447) ) } } Spacer(modifier = Modifier.height(8.dp)) // Lista zajmuje elastyczną przestrzeń, ale zostanie ograniczona przez klawiaturę LazyColumn( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.weight(1f) // Używamy weight, aby zajęła resztę miejsca przed klawiaturą ) { items(filteredMachines, key = { it.id }) { machine -> MachineItem( machine = machine, onToggle = { viewModel.toggleMachineCheck(machine.id) } ) } } Spacer(modifier = Modifier.height(8.dp)) // Bardzo mały pasek szukania nad klawiaturą OutlinedTextField( value = searchQuery, onValueChange = { viewModel.setSearchQuery(it) }, placeholder = { Text("Szukaj...", fontSize = 12.sp, color = Color.Gray) }, modifier = Modifier.fillMaxWidth().height(48.dp), shape = RoundedCornerShape(8.dp), singleLine = true, textStyle = LocalTextStyle.current.copy(fontSize = 13.sp), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), unfocusedBorderColor = Color(0xFF2A3447), focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedContainerColor = MaterialTheme.colorScheme.surface, focusedTextColor = Color.White, unfocusedTextColor = Color.White ) ) Spacer(modifier = Modifier.height(8.dp)) // Klawiatura numeryczna na dole NumericKeyboard( onDigitClick = { viewModel.appendDigit(it) }, onBackspace = { viewModel.backspace() }, onClear = { viewModel.clearSearch() } ) } else { Box( modifier = Modifier .fillMaxSize() .padding(32.dp), contentAlignment = Alignment.Center ) { Text( text = "Brak wczytanych maszyn.\nWybierz plik .xlsx wygenerowany z systemu.", color = Color.Gray, fontSize = 16.sp, textAlign = TextAlign.Center ) } } } } } // ========================================== // 5. POJEDYNCZY ELEMENT LISTY // ========================================== @OptIn(ExperimentalMaterial3Api::class) @Composable fun DashboardTile( title: String, icon: ImageVector, color: Color, onClick: () -> Unit, modifier: Modifier = Modifier ) { Card( onClick = onClick, modifier = modifier.height(70.dp), // Zmniejszona wysokość shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.15f)), border = androidx.compose.foundation.BorderStroke(1.dp, color.copy(alpha = 0.3f)) ) { Column( modifier = Modifier.fillMaxSize().padding(4.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(24.dp)) Spacer(modifier = Modifier.height(2.dp)) Text( text = title, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, textAlign = TextAlign.Center, lineHeight = 12.sp ) } } } @Composable fun StatusSelectionDialog( statuses: List, onDismiss: () -> Unit, onConfirm: (Set) -> Unit ) { var selectedStatuses by remember { mutableStateOf(setOf()) } AlertDialog( onDismissRequest = onDismiss, title = { Text("Wybierz statusy do zaznaczenia") }, text = { if (statuses.isEmpty()) { Text("Brak wczytanych maszyn ze statusami.") } else { LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 300.dp)) { items(statuses) { status -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clickable { selectedStatuses = if (selectedStatuses.contains(status)) { selectedStatuses - status } else { selectedStatuses + status } } .padding(vertical = 8.dp) ) { Checkbox( checked = selectedStatuses.contains(status), onCheckedChange = null // Obsłużone w Row.clickable ) Spacer(modifier = Modifier.width(8.dp)) Text(status, color = Color.White) } } } } }, confirmButton = { TextButton(onClick = { onConfirm(selectedStatuses) }) { Text("Zastosuj") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Anuluj") } }, containerColor = Color(0xFF1E2638), titleContentColor = Color.White, textContentColor = Color.White ) } @Composable fun NotesDialog( notes: String, onNotesChange: (String) -> Unit, onDismiss: () -> Unit ) { AlertDialog( onDismissRequest = onDismiss, title = { Text("Notatnik", color = Color.White) }, text = { OutlinedTextField( value = notes, onValueChange = onNotesChange, modifier = Modifier.fillMaxWidth().height(200.dp), placeholder = { Text("Wpisz notatki tutaj...", color = Color.Gray) }, colors = OutlinedTextFieldDefaults.colors( focusedTextColor = Color.White, unfocusedTextColor = Color.White, focusedContainerColor = Color(0xFF2A3447), unfocusedContainerColor = Color(0xFF2A3447) ) ) }, confirmButton = { TextButton(onClick = onDismiss) { Text("Zamknij") } }, containerColor = Color(0xFF1E2638) ) } @Composable fun NumericKeyboard( onDigitClick: (String) -> Unit, onBackspace: () -> Unit, onClear: () -> Unit ) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { val buttons = listOf( listOf("1", "2", "3"), listOf("4", "5", "6"), listOf("7", "8", "9"), listOf("C", "0", "⌫") ) buttons.forEach { row -> Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { row.forEach { label -> val isSpecial = label == "C" || label == "⌫" KeyButton( label = label, onClick = { when (label) { "C" -> onClear() "⌫" -> onBackspace() else -> onDigitClick(label) } }, color = if (isSpecial) Color(0xFF2A3447) else Color(0xFF1E2638), textColor = if (label == "⌫") Color(0xFFE91E63) else if (label == "C") Color(0xFFFF9800) else Color.White, modifier = Modifier.weight(1f) ) } } } } } @Composable fun KeyButton( label: String, onClick: () -> Unit, color: Color, textColor: Color, modifier: Modifier = Modifier ) { Surface( onClick = onClick, modifier = modifier.height(54.dp), shape = RoundedCornerShape(12.dp), color = color, border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)) ) { Box(contentAlignment = Alignment.Center) { Text( text = label, fontSize = 20.sp, fontWeight = FontWeight.Bold, color = textColor ) } } } @Composable fun MachineItem( machine: Machine, onToggle: () -> Unit ) { Card( shape = RoundedCornerShape(8.dp), colors = CardDefaults.cardColors( containerColor = if (machine.isChecked) Color(0xFF183B32) else MaterialTheme.colorScheme.surface ), modifier = Modifier .fillMaxWidth() .clickable { onToggle() } ) { Row( modifier = Modifier .padding(8.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Column(modifier = Modifier.weight(1f)) { Text( text = machine.id, fontWeight = FontWeight.Bold, fontSize = 14.sp, color = Color.White ) Text( text = machine.name, fontSize = 11.sp, color = Color.LightGray, maxLines = 1 ) } if (machine.originalStatus.isNotEmpty()) { Text( text = machine.originalStatus, fontSize = 10.sp, fontWeight = FontWeight.Medium, color = Color(0xFF11CAA0), modifier = Modifier.padding(start = 8.dp) ) } } } }