Files
InwentARyzacja/app/src/main/java/com/bartek/iwentaryzacja/MainActivity.kt
T

705 lines
25 KiB
Kotlin
Raw Normal View History

2026-08-15 19:36:18 +02:00
/*
* 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 <https://www.gnu.org/licenses/>.
*/
2026-08-15 16:36:12 +02:00
package com.bartek.iwentaryzacja
2026-08-15 19:36:18 +02:00
import android.content.Context
import android.net.Uri
2026-08-15 16:36:12 +02:00
import android.os.Bundle
import androidx.activity.ComponentActivity
2026-08-15 19:36:18 +02:00
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
2026-08-15 16:36:12 +02:00
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
2026-08-15 19:36:18 +02:00
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
2026-08-15 16:36:12 +02:00
import androidx.compose.ui.Modifier
2026-08-15 19:36:18 +02:00
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.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.apache.poi.ss.usermodel.WorkbookFactory
2026-08-16 14:27:59 +02:00
import org.json.JSONArray
2026-08-15 19:36:18 +02:00
import org.json.JSONObject
import java.io.InputStream
2026-08-15 16:36:12 +02:00
2026-08-15 19:36:18 +02:00
// ==========================================
// 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<List<Machine>>(emptyList())
val machines: StateFlow<List<Machine>> = _machines.asStateFlow()
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
private val _filterMissing = MutableStateFlow(false)
val filterMissing: StateFlow<Boolean> = _filterMissing.asStateFlow()
private val _notes = MutableStateFlow("")
val notes: StateFlow<String> = _notes.asStateFlow()
2026-08-16 14:27:59 +02:00
fun loadState(context: Context) {
val prefs = context.getSharedPreferences("inventory_prefs", Context.MODE_PRIVATE)
_notes.value = prefs.getString("notes", "") ?: ""
val machinesJson = prefs.getString("machines", null)
if (machinesJson != null) {
2026-08-15 19:36:18 +02:00
try {
2026-08-16 14:27:59 +02:00
val jsonArray = JSONArray(machinesJson)
val loadedList = mutableListOf<Machine>()
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
loadedList.add(
Machine(
id = obj.getString("id"),
name = obj.getString("name"),
category = obj.getString("category"),
originalStatus = obj.getString("originalStatus"),
isChecked = obj.getBoolean("isChecked")
)
)
2026-08-15 19:36:18 +02:00
}
2026-08-16 14:27:59 +02:00
_machines.value = loadedList
2026-08-15 19:36:18 +02:00
} catch (e: Exception) {
e.printStackTrace()
}
}
}
2026-08-16 14:27:59 +02:00
private fun saveState(context: Context) {
val prefs = context.getSharedPreferences("inventory_prefs", Context.MODE_PRIVATE)
val editor = prefs.edit()
editor.putString("notes", _notes.value)
val jsonArray = JSONArray()
_machines.value.forEach { machine ->
val obj = JSONObject()
obj.put("id", machine.id)
obj.put("name", machine.name)
obj.put("category", machine.category)
obj.put("originalStatus", machine.originalStatus)
obj.put("isChecked", machine.isChecked)
jsonArray.put(obj)
}
editor.putString("machines", jsonArray.toString())
editor.apply()
2026-08-15 19:36:18 +02:00
}
fun setSearchQuery(query: String) {
_searchQuery.value = query
}
2026-08-16 14:27:59 +02:00
fun setNotes(context: Context, newNotes: String) {
2026-08-15 19:36:18 +02:00
_notes.value = newNotes
2026-08-16 14:27:59 +02:00
saveState(context)
2026-08-15 19:36:18 +02:00
}
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<String> {
return _machines.value
.map { it.originalStatus }
.filter { it.isNotEmpty() }
.distinct()
.sorted()
}
2026-08-16 14:27:59 +02:00
fun markMachinesByStatuses(context: Context, statuses: Set<String>, isChecked: Boolean) {
2026-08-15 19:36:18 +02:00
_machines.value = _machines.value.map { machine ->
if (statuses.contains(machine.originalStatus)) {
machine.copy(isChecked = isChecked)
} else {
machine
}
}
2026-08-16 14:27:59 +02:00
saveState(context)
2026-08-15 19:36:18 +02:00
}
2026-08-16 14:27:59 +02:00
fun toggleMachineCheck(context: Context, machineId: String) {
2026-08-15 19:36:18 +02:00
_machines.value = _machines.value.map { machine ->
if (machine.id == machineId) {
machine.copy(isChecked = !machine.isChecked)
} else {
machine
}
}
2026-08-16 14:27:59 +02:00
saveState(context)
2026-08-15 19:36:18 +02:00
}
// Parser zoptymalizowany pod strukturę raportu z systemu
2026-08-16 14:27:59 +02:00
fun parseXlsFile(context: Context, inputStream: InputStream) {
2026-08-15 19:36:18 +02:00
try {
// WorkbookFactory automatycznie obsługuje zarówno .xls jak i .xlsx
val workbook = WorkbookFactory.create(inputStream)
val sheet = workbook.getSheetAt(0)
val parsedList = mutableListOf<Machine>()
// 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()
2026-08-16 14:27:59 +02:00
saveState(context)
2026-08-15 19:36:18 +02:00
} catch (e: Exception) {
e.printStackTrace()
}
}
}
// ==========================================
// 3. GŁÓWNA AKTYWNOŚĆ
// ==========================================
2026-08-15 16:36:12 +02:00
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
2026-08-15 19:36:18 +02:00
2026-08-15 16:36:12 +02:00
setContent {
2026-08-15 19:36:18 +02:00
MaterialTheme(
colorScheme = darkColorScheme(
primary = Color(0xFF007ACC),
background = Color(0xFF121824),
surface = Color(0xFF1E2638)
)
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
InventoryScreen()
}
}
}
}
}
// ==========================================
// 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()
var showStatusDialog by remember { mutableStateOf(false) }
var showNotesDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
2026-08-16 14:27:59 +02:00
viewModel.loadState(context)
2026-08-15 19:36:18 +02:00
}
// Launcher akceptujący pliki .xlsx oraz .xls
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let {
context.contentResolver.openInputStream(it)?.use { inputStream ->
2026-08-16 14:27:59 +02:00
viewModel.parseXlsFile(context, inputStream)
2026-08-15 19:36:18 +02:00
}
}
}
if (showStatusDialog) {
StatusSelectionDialog(
statuses = viewModel.getUniqueStatuses(),
onDismiss = { showStatusDialog = false },
onConfirm = { selectedStatuses ->
2026-08-16 14:27:59 +02:00
viewModel.markMachinesByStatuses(context, selectedStatuses, true)
2026-08-15 19:36:18 +02:00
showStatusDialog = false
}
)
}
if (showNotesDialog) {
NotesDialog(
notes = notes,
2026-08-16 14:27:59 +02:00
onNotesChange = { viewModel.setNotes(context, it) },
2026-08-15 19:36:18 +02:00
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,
2026-08-16 14:27:59 +02:00
onToggle = { viewModel.toggleMachineCheck(context, machine.id) }
2026-08-15 19:36:18 +02:00
)
}
}
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<String>,
onDismiss: () -> Unit,
onConfirm: (Set<String>) -> Unit
) {
var selectedStatuses by remember { mutableStateOf(setOf<String>()) }
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)
2026-08-15 16:36:12 +02:00
)
}
}
}
}
}
@Composable
2026-08-15 19:36:18 +02:00
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
)
}
}
2026-08-15 16:36:12 +02:00
}
@Composable
2026-08-15 19:36:18 +02:00
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)
)
}
}
2026-08-15 16:36:12 +02:00
}
}