diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/yaros/shiftmanager/CalendarScreen.kt b/app/src/main/java/com/yaros/shiftmanager/CalendarScreen.kt index b823ac4..cce5651 100644 --- a/app/src/main/java/com/yaros/shiftmanager/CalendarScreen.kt +++ b/app/src/main/java/com/yaros/shiftmanager/CalendarScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -22,6 +23,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import java.time.DayOfWeek import java.time.LocalDate @@ -158,7 +160,10 @@ fun CalendarScreen( editingShift = shift showAddDialog = true }, - onDelete = { shiftToDelete = shift } + onDelete = { shiftToDelete = shift }, + onToggleComplete = { + viewModel.updateShift(shift.copy(isCompleted = !shift.isCompleted)) + } ) } } @@ -256,14 +261,33 @@ private fun DayCell( modifier: Modifier = Modifier, onClick: () -> Unit ) { - val hasDayShift = shifts.any { it.type == ShiftType.DAY } + val hasDayShift = shifts.any { it.type == ShiftType.DAY || it.type == ShiftType.WEEKEND } val hasNightShift = shifts.any { it.type == ShiftType.NIGHT } + val hasWeekendShift = shifts.any { it.type == ShiftType.WEEKEND } + + // Determine dominant color for the cell indicator + val indicatorColor = when { + hasNightShift && hasDayShift -> Color(0xFF7C4DFF) // both: purple gradient indicator + hasNightShift -> Color(0xFF2196F3) + hasWeekendShift -> Color(0xFF9C27B0) + hasDayShift -> Color(0xFFFFC107) + else -> Color.Transparent + } + + // Background tint for night shifts + val cellBgColor = when { + !isInMonth -> Color.Transparent + hasNightShift -> Color(0xFF2196F3).copy(alpha = 0.08f) + isSelected -> MaterialTheme.colorScheme.primaryContainer + else -> Color.Transparent + } Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier .aspectRatio(1f) - .padding(2.dp) + .padding(1.dp) + .background(cellBgColor, RoundedCornerShape(6.dp)) .clickable(enabled = isInMonth) { onClick() } ) { Box( @@ -282,36 +306,38 @@ private fun DayCell( color = when { !isInMonth -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f) isSelected -> MaterialTheme.colorScheme.onPrimaryContainer + hasNightShift -> Color(0xFF2196F3) // blue number for night + hasWeekendShift -> Color(0xFF9C27B0) // purple for weekend else -> MaterialTheme.colorScheme.onSurface }, - style = MaterialTheme.typography.bodySmall + style = MaterialTheme.typography.bodySmall, + fontWeight = if (isInMonth && shifts.isNotEmpty()) FontWeight.Bold else FontWeight.Normal ) } - // Shift type indicators + // Visible shift indicator bar if (isInMonth && shifts.isNotEmpty()) { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.height(8.dp) - ) { - if (hasDayShift) { - Box( - modifier = Modifier - .size(5.dp) - .background(Color(0xFFFFC107), CircleShape) - ) - Spacer(Modifier.width(2.dp)) - } - if (hasNightShift) { - Box( - modifier = Modifier - .size(5.dp) - .background(Color(0xFF2196F3), CircleShape) - ) - } + Box( + modifier = Modifier + .fillMaxWidth(0.7f) + .height(3.dp) + .background(indicatorColor, RoundedCornerShape(2.dp)) + ) + Spacer(Modifier.height(2.dp)) + + // Small count badge if multiple shifts + if (shifts.size > 1) { + Text( + text = "${shifts.size}", + style = MaterialTheme.typography.labelSmall, + color = indicatorColor, + fontSize = 8.sp + ) + } else { + Spacer(Modifier.height(8.dp)) } } else { - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(if (isInMonth) 13.dp else 8.dp)) } } } @@ -324,7 +350,8 @@ private fun ShiftCard( templates: List, currencySymbol: String, onClick: () -> Unit, - onDelete: () -> Unit + onDelete: () -> Unit, + onToggleComplete: () -> Unit ) { val template = templates.find { it.id == shift.templateId } val typeLabel = when (shift.type) { @@ -343,7 +370,11 @@ private fun ShiftCard( .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 4.dp) .clickable { onClick() }, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + colors = CardDefaults.cardColors( + containerColor = if (shift.isCompleted) + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + else MaterialTheme.colorScheme.surfaceVariant + ) ) { Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { // Color strip @@ -354,15 +385,29 @@ private fun ShiftCard( .background(typeColor, MaterialTheme.shapes.small) ) Spacer(Modifier.width(12.dp)) + + // Quick-complete checkbox + Checkbox( + checked = shift.isCompleted, + onCheckedChange = { onToggleComplete() }, + modifier = Modifier.size(24.dp) + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { Text( text = template?.name ?: shift.title.ifBlank { typeLabel }, - style = MaterialTheme.typography.titleSmall + style = MaterialTheme.typography.titleSmall, + color = if (shift.isCompleted) + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.onSurface ) Text( text = "${String.format(Locale.getDefault(), "%.1f", shift.hoursWorked)} h · $typeLabel", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = if (shift.isCompleted) + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.onSurfaceVariant ) } Column(horizontalAlignment = Alignment.End) { @@ -387,15 +432,9 @@ private fun ShiftCard( Text( text = "${currencySymbol}${String.format(Locale.getDefault(), "%.2f", earnings)}", style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary + color = if (shift.isCompleted) MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.primary ) - if (shift.isCompleted) { - Text( - text = stringResource(R.string.completed), - style = MaterialTheme.typography.labelSmall, - color = Color(0xFF4CAF50) - ) - } } IconButton(onClick = onDelete) { Text("✕", color = MaterialTheme.colorScheme.error) diff --git a/app/src/main/java/com/yaros/shiftmanager/EarningsChart.kt b/app/src/main/java/com/yaros/shiftmanager/EarningsChart.kt new file mode 100644 index 0000000..bc42ed0 --- /dev/null +++ b/app/src/main/java/com/yaros/shiftmanager/EarningsChart.kt @@ -0,0 +1,93 @@ +package com.yaros.shiftmanager + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import java.text.SimpleDateFormat +import java.util.* + +data class ChartDataPoint( + val label: String, + val value: Double +) + +@Composable +fun EarningsBarChart( + data: List, + modifier: Modifier = Modifier, + barColor: Color = MaterialTheme.colorScheme.primary, + labelColor: Color = MaterialTheme.colorScheme.onSurfaceVariant +) { + if (data.isEmpty() || data.all { it.value == 0.0 }) return + + val maxValue = data.maxOf { it.value }.toFloat().coerceAtLeast(1f) + + Column(modifier = modifier.fillMaxWidth()) { + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .padding(horizontal = 4.dp) + ) { + val barCount = data.size + val spacing = 8.dp.toPx() + val totalSpacing = spacing * (barCount + 1) + val barWidth = ((size.width - totalSpacing) / barCount).coerceAtLeast(4f) + val chartHeight = size.height - 20.dp.toPx() + + // Draw horizontal grid lines + val gridLineCount = 4 + for (i in 0..gridLineCount) { + val y = chartHeight * i / gridLineCount + drawLine( + color = labelColor.copy(alpha = 0.15f), + start = Offset(0f, y), + end = Offset(size.width, y), + strokeWidth = 1f + ) + } + + // Draw bars + data.forEachIndexed { index, point -> + val x = spacing + index * (barWidth + spacing) + val barHeight = (point.value.toFloat() / maxValue) * chartHeight + val y = chartHeight - barHeight + + drawRoundRect( + color = barColor, + topLeft = Offset(x, y), + size = Size(barWidth, barHeight), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(4f, 4f) + ) + } + } + + // Draw labels below the chart + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + data.forEach { point -> + Text( + text = point.label, + style = MaterialTheme.typography.labelSmall, + color = labelColor, + textAlign = TextAlign.Center, + maxLines = 1, + modifier = Modifier.weight(1f) + ) + } + } + } +} diff --git a/app/src/main/java/com/yaros/shiftmanager/Incommer.kt b/app/src/main/java/com/yaros/shiftmanager/Incommer.kt index ceba08c..05418e7 100644 --- a/app/src/main/java/com/yaros/shiftmanager/Incommer.kt +++ b/app/src/main/java/com/yaros/shiftmanager/Incommer.kt @@ -8,7 +8,8 @@ import androidx.room.* import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import android.content.Context -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import java.util.* import javax.inject.Inject @@ -64,10 +65,25 @@ class IncomeRepository @Inject constructor(private val incomeDao: IncomeDao) { } } +@OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class IncomeViewModel @Inject constructor(private val repository: IncomeRepository) : ViewModel() { val allIncomes: LiveData> = repository.allIncomes.asLiveData() + // Reactive period-based incomes for statistics screen + private val _statsPeriod = MutableStateFlow(StatisticsPeriod.MONTH) + + val periodIncomes: StateFlow> = _statsPeriod + .flatMapLatest { period -> + val (start, end) = getDateRangeForPeriod(period) + repository.getIncomesForPeriod(start, end) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + fun setStatsPeriod(period: StatisticsPeriod) { + _statsPeriod.value = period + } + fun getIncomesForPeriod(startDate: Date, endDate: Date): LiveData> { return repository.getIncomesForPeriod(startDate, endDate).asLiveData() } diff --git a/app/src/main/java/com/yaros/shiftmanager/ShiftHelper.kt b/app/src/main/java/com/yaros/shiftmanager/ShiftHelper.kt index 93fc8b3..bcd5fa1 100644 --- a/app/src/main/java/com/yaros/shiftmanager/ShiftHelper.kt +++ b/app/src/main/java/com/yaros/shiftmanager/ShiftHelper.kt @@ -7,9 +7,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import java.time.DayOfWeek import java.time.LocalDate +import java.time.LocalTime import java.time.YearMonth import java.time.ZoneId +import java.time.temporal.ChronoUnit import java.util.* import javax.inject.Inject @@ -146,6 +149,20 @@ class ShiftViewModel @Inject constructor( .flatMapLatest { month -> repository.getShiftsForMonth(month) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + // Reactive period-based shifts for statistics screen + private val _statsPeriod = MutableStateFlow(StatisticsPeriod.MONTH) + + val periodShifts: StateFlow> = _statsPeriod + .flatMapLatest { period -> + val (start, end) = getDateRangeForPeriod(period) + repository.getShiftsForDateRange(start, end) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + fun setStatsPeriod(period: StatisticsPeriod) { + _statsPeriod.value = period + } + val allTemplates: StateFlow> = templateRepository.allTemplates .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) @@ -166,6 +183,11 @@ class ShiftViewModel @Inject constructor( fun updateTemplate(template: ShiftTemplate) = viewModelScope.launch { templateRepository.updateTemplate(template) } fun deleteTemplate(template: ShiftTemplate) = viewModelScope.launch { templateRepository.deleteTemplate(template) } + fun bulkInsertShifts(shifts: List, onComplete: (Int) -> Unit = {}) = viewModelScope.launch { + shifts.forEach { repository.insertShift(it) } + onComplete(shifts.size) + } + fun shiftsForDate(date: LocalDate): List { return monthShifts.value.filter { shift -> shift.startTime.toInstant() @@ -228,5 +250,85 @@ class ShiftViewModel @Inject constructor( return total } + + // ─────────────────── Bulk Shift Generation ─────────────────── + + fun generateBulkShifts( + template: ShiftTemplate, + pattern: SchedulePattern, + startDate: LocalDate, + durationDays: Int, + dayHours: Double, + nightHours: Double, + dayStartTime: LocalTime = LocalTime.of(8, 0), + nightStartTime: LocalTime = LocalTime.of(20, 0), + customDayAssignments: Map? = null // dayIndex (0-based) -> ShiftType + ): List { + val shifts = mutableListOf() + val zone = ZoneId.systemDefault() + + for (dayOffset in 0 until durationDays) { + val date = startDate.plusDays(dayOffset.toLong()) + val shiftType = when (pattern) { + SchedulePattern.FIVE_TWO -> { + // 5 work days (Mon-Fri), 2 rest (Sat-Sun) + val dow = date.dayOfWeek + if (dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY) null else ShiftType.DAY + } + SchedulePattern.TWO_TWO -> { + // 2 work, 2 rest repeating + val cyclePos = dayOffset % 4 + when (cyclePos) { + 0, 1 -> ShiftType.DAY + else -> null // rest + } + } + SchedulePattern.CZECH -> { + // Day, Night, Rest, Rest repeating + val cyclePos = dayOffset % 4 + when (cyclePos) { + 0 -> ShiftType.DAY + 1 -> ShiftType.NIGHT + else -> null // rest + } + } + SchedulePattern.CUSTOM -> { + customDayAssignments?.get(dayOffset % (customDayAssignments.size.coerceAtLeast(1))) + } + } + + if (shiftType != null) { + val hours = if (shiftType == ShiftType.NIGHT) nightHours else dayHours + val startTime = if (shiftType == ShiftType.NIGHT) nightStartTime else dayStartTime + val startDateTime = date.atTime(startTime) + val endDateTime = startDateTime.plusHours(hours.toLong()) + .plusMinutes(((hours % 1) * 60).toInt().toLong()) + + val isWeekend = date.dayOfWeek.let { + it == DayOfWeek.SATURDAY || it == DayOfWeek.SUNDAY + } + val actualType = if (isWeekend && shiftType != ShiftType.NIGHT) ShiftType.WEEKEND else shiftType + + shifts.add( + Shift( + id = UUID.randomUUID().toString(), + title = template.name, + startTime = Date.from(startDateTime.atZone(zone).toInstant()), + endTime = Date.from(endDateTime.atZone(zone).toInstant()), + type = actualType, + hoursWorked = hours, + hourlyRate = template.hourlyRate, + isCompleted = false, + templateId = template.id + ) + ) + } + } + return shifts + } } +} + +enum class SchedulePattern { + FIVE_TWO, TWO_TWO, CZECH, CUSTOM } \ No newline at end of file diff --git a/app/src/main/java/com/yaros/shiftmanager/ShiftTemplatesScreen.kt b/app/src/main/java/com/yaros/shiftmanager/ShiftTemplatesScreen.kt index 80b655a..edd61fa 100644 --- a/app/src/main/java/com/yaros/shiftmanager/ShiftTemplatesScreen.kt +++ b/app/src/main/java/com/yaros/shiftmanager/ShiftTemplatesScreen.kt @@ -1,9 +1,15 @@ package com.yaros.shiftmanager +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -12,9 +18,16 @@ import androidx.compose.runtime.* import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter import java.util.Locale import java.util.UUID @@ -27,18 +40,31 @@ fun ShiftTemplatesScreen( ) { val templates by viewModel.allTemplates.collectAsState() val currency by settingsViewModel.currency.collectAsState() + val context = LocalContext.current var showDialog by remember { mutableStateOf(false) } var editingTemplate by remember { mutableStateOf(null) } var templateToDelete by remember { mutableStateOf(null) } + var showBulkDialog by remember { mutableStateOf(false) } Scaffold( floatingActionButton = { - FloatingActionButton(onClick = { - editingTemplate = null - showDialog = true - }) { - Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_template)) + Column(horizontalAlignment = Alignment.End) { + // Bulk generate button + ExtendedFloatingActionButton( + onClick = { showBulkDialog = true }, + icon = { Text("📅") }, + text = { Text(stringResource(R.string.generate_shifts)) }, + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ) + Spacer(Modifier.height(12.dp)) + FloatingActionButton(onClick = { + editingTemplate = null + showDialog = true + }) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_template)) + } } } ) { paddingValues -> @@ -103,6 +129,24 @@ fun ShiftTemplatesScreen( ) } + if (showBulkDialog) { + BulkGenerateDialog( + templates = templates, + currencySymbol = currency.symbol, + onDismiss = { showBulkDialog = false }, + onGenerate = { shifts -> + viewModel.bulkInsertShifts(shifts) { count -> + Toast.makeText( + context, + context.getString(R.string.shifts_generated, count), + Toast.LENGTH_SHORT + ).show() + } + showBulkDialog = false + } + ) + } + if (templateToDelete != null) { AlertDialog( onDismissRequest = { templateToDelete = null }, @@ -158,15 +202,21 @@ private fun TemplateCard( style = MaterialTheme.typography.bodyMedium ) if (template.weekendCoefficient != 1.0) { + val percent = ((template.weekendCoefficient - 1.0) * 100).toInt() + val sign = if (percent > 0) "+" else "" Text( - text = stringResource(R.string.weekend_coeff_short, String.format(Locale.getDefault(), "%.1f", template.weekendCoefficient)), + text = stringResource(R.string.weekend_coeff_short, String.format(Locale.getDefault(), "%.1f", template.weekendCoefficient)) + + " (${sign}${percent}%)", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary ) } if (template.overtimeThreshold > 0 && template.overtimeCoefficient != 1.0) { + val percent = ((template.overtimeCoefficient - 1.0) * 100).toInt() + val sign = if (percent > 0) "+" else "" Text( - text = stringResource(R.string.overtime_label, String.format(Locale.getDefault(), "%.0f", template.overtimeThreshold), String.format(Locale.getDefault(), "%.1f", template.overtimeCoefficient)), + text = stringResource(R.string.overtime_label, String.format(Locale.getDefault(), "%.0f", template.overtimeThreshold), String.format(Locale.getDefault(), "%.1f", template.overtimeCoefficient)) + + " (${sign}${percent}%)", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.tertiary ) @@ -217,16 +267,17 @@ private fun TemplateDialog( it.addAll(existingTemplate?.coefficientRules ?: emptyList()) } } - var weekendCoeffText by remember { + // Weekend coefficient — allow input as percentage + var weekendPercentText by remember { mutableStateOf( if (existingTemplate != null && existingTemplate.weekendCoefficient != 1.0) - existingTemplate.weekendCoefficient.toString() else "" + String.format(Locale.getDefault(), "%.0f", (existingTemplate.weekendCoefficient - 1.0) * 100) else "" ) } - var overtimeCoeffText by remember { + var overtimePercentText by remember { mutableStateOf( if (existingTemplate != null && existingTemplate.overtimeCoefficient != 1.5) - existingTemplate.overtimeCoefficient.toString() else "" + String.format(Locale.getDefault(), "%.0f", (existingTemplate.overtimeCoefficient - 1.0) * 100) else "" ) } var overtimeThresholdText by remember { @@ -242,10 +293,10 @@ private fun TemplateDialog( ) } - // New rule fields + // New rule fields — percentage input var newStartHour by remember { mutableStateOf("") } var newEndHour by remember { mutableStateOf("") } - var newCoefficient by remember { mutableStateOf("") } + var newPercent by remember { mutableStateOf("") } AlertDialog( onDismissRequest = onDismiss, @@ -273,13 +324,22 @@ private fun TemplateDialog( HorizontalDivider() Text(stringResource(R.string.additional_rules), style = MaterialTheme.typography.labelLarge) + // Weekend coefficient as percentage OutlinedTextField( - value = weekendCoeffText, - onValueChange = { weekendCoeffText = it }, - label = { Text(stringResource(R.string.weekend_coefficient)) }, + value = weekendPercentText, + onValueChange = { weekendPercentText = it.filter { c -> c.isDigit() || c == '-' || c == '.' } }, + label = { Text(stringResource(R.string.weekend_coefficient) + " (%)") }, placeholder = { Text(stringResource(R.string.weekend_placeholder)) }, + suffix = { Text("%") }, modifier = Modifier.fillMaxWidth(), - singleLine = true + singleLine = true, + supportingText = { + val pct = weekendPercentText.toDoubleOrNull() + if (pct != null) { + val coeff = 1.0 + pct / 100.0 + Text("= ×${String.format(Locale.getDefault(), "%.2f", coeff)}") + } + } ) Row( @@ -295,12 +355,20 @@ private fun TemplateDialog( singleLine = true ) OutlinedTextField( - value = overtimeCoeffText, - onValueChange = { overtimeCoeffText = it }, - label = { Text(stringResource(R.string.overtime_multiplier)) }, - placeholder = { Text("1.5") }, + value = overtimePercentText, + onValueChange = { overtimePercentText = it.filter { c -> c.isDigit() || c == '-' || c == '.' } }, + label = { Text(stringResource(R.string.overtime_multiplier) + " (%)") }, + placeholder = { Text("50") }, + suffix = { Text("%") }, modifier = Modifier.weight(1f), - singleLine = true + singleLine = true, + supportingText = { + val pct = overtimePercentText.toDoubleOrNull() + if (pct != null) { + val coeff = 1.0 + pct / 100.0 + Text("= ×${String.format(Locale.getDefault(), "%.2f", coeff)}") + } + } ) } @@ -323,6 +391,8 @@ private fun TemplateDialog( // Existing rules rules.forEachIndexed { index, rule -> + val percent = ((rule.coefficient - 1.0) * 100).toInt() + val sign = if (percent > 0) "+" else "" Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -331,7 +401,7 @@ private fun TemplateDialog( Text( text = "${String.format(Locale.getDefault(), "%02d:00", rule.startHour)}–" + "${String.format(Locale.getDefault(), "%02d:00", rule.endHour)} → " + - "×${String.format(Locale.getDefault(), "%.1f", rule.coefficient)}", + "${sign}${percent}% (×${String.format(Locale.getDefault(), "%.1f", rule.coefficient)})", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f) ) @@ -341,7 +411,7 @@ private fun TemplateDialog( } } - // Add new rule row + // Add new rule row — percentage input Text(stringResource(R.string.add_rule), style = MaterialTheme.typography.labelSmall) Row( horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -365,10 +435,10 @@ private fun TemplateDialog( singleLine = true ) OutlinedTextField( - value = newCoefficient, - onValueChange = { newCoefficient = it }, - label = { Text("×") }, - placeholder = { Text("1.4") }, + value = newPercent, + onValueChange = { newPercent = it.filter { c -> c.isDigit() || c == '-' || c == '.' } }, + label = { Text("%") }, + placeholder = { Text("40") }, modifier = Modifier.weight(1f), singleLine = true ) @@ -376,12 +446,13 @@ private fun TemplateDialog( onClick = { val start = newStartHour.toIntOrNull() ?: return@FilledTonalButton val end = newEndHour.toIntOrNull() ?: return@FilledTonalButton - val coeff = newCoefficient.toDoubleOrNull() ?: return@FilledTonalButton + val pct = newPercent.toDoubleOrNull() ?: return@FilledTonalButton if (start !in 0..23 || end !in 0..23) return@FilledTonalButton + val coeff = 1.0 + pct / 100.0 rules.add(CoefficientRule(start, end, coeff)) newStartHour = "" newEndHour = "" - newCoefficient = "" + newPercent = "" }, modifier = Modifier.size(40.dp), contentPadding = PaddingValues(0.dp) @@ -395,8 +466,10 @@ private fun TemplateDialog( val untitledName = stringResource(R.string.untitled_template) Button(onClick = { val rate = rateText.toDoubleOrNull() ?: return@Button - val weekendCoeff = weekendCoeffText.toDoubleOrNull() ?: 1.0 - val overtimeCoeff = overtimeCoeffText.toDoubleOrNull() ?: 1.5 + val weekendPct = weekendPercentText.toDoubleOrNull() + val weekendCoeff = if (weekendPct != null) 1.0 + weekendPct / 100.0 else 1.0 + val overtimePct = overtimePercentText.toDoubleOrNull() + val overtimeCoeff = if (overtimePct != null) 1.0 + overtimePct / 100.0 else 1.5 val overtimeThreshold = overtimeThresholdText.toDoubleOrNull() ?: 8.0 val bonus = bonusText.toDoubleOrNull() ?: 0.0 val template = ShiftTemplate( @@ -417,3 +490,333 @@ private fun TemplateDialog( } ) } + +// ─────────────────── Bulk Generate Dialog ─────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BulkGenerateDialog( + templates: List, + currencySymbol: String, + onDismiss: () -> Unit, + onGenerate: (List) -> Unit +) { + var selectedTemplateId by remember { mutableStateOf(null) } + var pattern by remember { mutableStateOf(SchedulePattern.FIVE_TWO) } + var durationDays by remember { mutableIntStateOf(7) } + var startDate by remember { mutableStateOf(LocalDate.now()) } + var dayHoursText by remember { mutableStateOf("8") } + var nightHoursText by remember { mutableStateOf("12") } + var showStartDatePicker by remember { mutableStateOf(false) } + + // Custom pattern: map of dayIndex (in cycle) -> ShiftType or null (rest) + val customAssignments = remember { + mutableStateListOf().apply { + // Default: 7 days, Mon-Fri = DAY, Sat-Sun = null + addAll(listOf(ShiftType.DAY, ShiftType.DAY, ShiftType.DAY, ShiftType.DAY, ShiftType.DAY, null, null)) + } + } + + val selectedTemplate = templates.find { it.id == selectedTemplateId } + + // Generate preview + val previewShifts = remember(selectedTemplateId, pattern, durationDays, startDate, dayHoursText, nightHoursText, customAssignments.toList()) { + val template = templates.find { it.id == selectedTemplateId } ?: return@remember emptyList() + val dayHours = dayHoursText.toDoubleOrNull() ?: 8.0 + val nightHours = nightHoursText.toDoubleOrNull() ?: 12.0 + + val customMap = if (pattern == SchedulePattern.CUSTOM) { + customAssignments.mapIndexedNotNull { index, type -> + if (type != null) index to type else null + }.toMap() + } else null + + ShiftViewModel.generateBulkShifts( + template = template, + pattern = pattern, + startDate = startDate, + durationDays = durationDays, + dayHours = dayHours, + nightHours = nightHours, + customDayAssignments = customMap + ) + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.generate_shifts_title)) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + // Template selection + Text(stringResource(R.string.select_template_for_gen), style = MaterialTheme.typography.titleSmall) + if (templates.isEmpty()) { + Text(stringResource(R.string.no_templates_hint), color = MaterialTheme.colorScheme.error) + } else { + templates.forEach { tmpl -> + val isSelected = selectedTemplateId == tmpl.id + Card( + onClick = { selectedTemplateId = tmpl.id }, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier.padding(12.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = isSelected, onClick = { selectedTemplateId = tmpl.id }) + Spacer(Modifier.width(8.dp)) + Column { + Text(tmpl.name, style = MaterialTheme.typography.titleSmall) + Text( + "${currencySymbol}${String.format(Locale.getDefault(), "%.2f", tmpl.hourlyRate)}/hr", + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } + } + + HorizontalDivider() + + // Schedule pattern + Text(stringResource(R.string.schedule_pattern), style = MaterialTheme.typography.titleSmall) + SchedulePattern.entries.forEach { p -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { pattern = p } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = pattern == p, onClick = { pattern = p }) + Spacer(Modifier.width(8.dp)) + Text( + when (p) { + SchedulePattern.FIVE_TWO -> stringResource(R.string.pattern_5_2) + SchedulePattern.TWO_TWO -> stringResource(R.string.pattern_2_2) + SchedulePattern.CZECH -> stringResource(R.string.pattern_czech) + SchedulePattern.CUSTOM -> stringResource(R.string.pattern_custom) + } + ) + } + } + + // Custom pattern editor + if (pattern == SchedulePattern.CUSTOM) { + HorizontalDivider() + Text(stringResource(R.string.work_days_label), style = MaterialTheme.typography.labelMedium) + CustomPatternEditor(customAssignments) + } + + HorizontalDivider() + + // Duration + Text(stringResource(R.string.duration_label), style = MaterialTheme.typography.titleSmall) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + FilterChip( + selected = durationDays == 7, + onClick = { durationDays = 7 }, + label = { Text(stringResource(R.string.duration_week)) } + ) + FilterChip( + selected = durationDays == 14, + onClick = { durationDays = 14 }, + label = { Text(stringResource(R.string.duration_2weeks)) } + ) + FilterChip( + selected = durationDays == 30, + onClick = { durationDays = 30 }, + label = { Text(stringResource(R.string.duration_month)) } + ) + } + + // Start date + OutlinedButton(onClick = { showStartDatePicker = true }) { + Text(stringResource(R.string.start_date_label) + ": " + + startDate.format(DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.getDefault()))) + } + + // Hours per shift + Text(stringResource(R.string.hours_per_shift), style = MaterialTheme.typography.titleSmall) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = dayHoursText, + onValueChange = { dayHoursText = it }, + label = { Text(stringResource(R.string.day_shift_label)) }, + modifier = Modifier.weight(1f), + singleLine = true + ) + OutlinedTextField( + value = nightHoursText, + onValueChange = { nightHoursText = it }, + label = { Text(stringResource(R.string.night_shift_label)) }, + modifier = Modifier.weight(1f), + singleLine = true + ) + } + + // Preview + HorizontalDivider() + Text( + stringResource(R.string.generate_preview, previewShifts.size), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + + // Show shift count by type + val dayCount = previewShifts.count { it.type == ShiftType.DAY || it.type == ShiftType.WEEKEND } + val nightCount = previewShifts.count { it.type == ShiftType.NIGHT } + val totalEarnings = previewShifts.sumOf { shift -> + val isWeekend = shift.type == ShiftType.WEEKEND + if (selectedTemplate != null) { + ShiftViewModel.calculateEarnings( + baseRate = shift.hourlyRate, + hoursWorked = shift.hoursWorked, + rules = selectedTemplate.coefficientRules, + weekendCoefficient = selectedTemplate.weekendCoefficient, + overtimeCoefficient = selectedTemplate.overtimeCoefficient, + overtimeThreshold = selectedTemplate.overtimeThreshold, + bonusPerShift = selectedTemplate.bonusPerShift, + isWeekend = isWeekend + ) + } else shift.hourlyRate * shift.hoursWorked + } + + Text( + "${stringResource(R.string.day_shift_label)}: $dayCount | ${stringResource(R.string.night_shift_label)}: $nightCount", + style = MaterialTheme.typography.bodySmall + ) + Text( + stringResource(R.string.earnings_preview, currencySymbol, String.format(Locale.getDefault(), "%.2f", totalEarnings)), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + }, + confirmButton = { + Button( + onClick = { onGenerate(previewShifts) }, + enabled = selectedTemplate != null && previewShifts.isNotEmpty() + ) { + Text(stringResource(R.string.generate_confirm, previewShifts.size)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + } + ) + + if (showStartDatePicker) { + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() + ) + DatePickerDialog( + onDismissRequest = { showStartDatePicker = false }, + confirmButton = { + Button(onClick = { + datePickerState.selectedDateMillis?.let { millis -> + startDate = java.time.Instant.ofEpochMilli(millis) + .atZone(ZoneId.systemDefault()).toLocalDate() + } + showStartDatePicker = false + }) { Text(stringResource(R.string.ok)) } + }, + dismissButton = { + TextButton(onClick = { showStartDatePicker = false }) { Text(stringResource(R.string.cancel)) } + } + ) { + DatePicker(state = datePickerState) + } + } +} + +// ─────────────────── Custom Pattern Editor ─────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CustomPatternEditor(assignments: SnapshotStateList) { + val dayNames = listOf( + stringResource(R.string.monday_short), + stringResource(R.string.tuesday_short), + stringResource(R.string.wednesday_short), + stringResource(R.string.thursday_short), + stringResource(R.string.friday_short), + stringResource(R.string.saturday_short), + stringResource(R.string.sunday_short) + ) + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + // Show 7 days in the cycle, user can toggle each + dayNames.forEachIndexed { index, dayName -> + val currentType = if (index < assignments.size) assignments[index] else null + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + dayName, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.width(32.dp) + ) + + // Day shift chip + FilterChip( + selected = currentType == ShiftType.DAY, + onClick = { + while (assignments.size <= index) assignments.add(null) + assignments[index] = if (currentType == ShiftType.DAY) null else ShiftType.DAY + }, + label = { Text(stringResource(R.string.czech_day), style = MaterialTheme.typography.labelSmall) }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = Color(0xFFFFC107).copy(alpha = 0.3f) + ) + ) + + // Night shift chip + FilterChip( + selected = currentType == ShiftType.NIGHT, + onClick = { + while (assignments.size <= index) assignments.add(null) + assignments[index] = if (currentType == ShiftType.NIGHT) null else ShiftType.NIGHT + }, + label = { Text(stringResource(R.string.czech_night), style = MaterialTheme.typography.labelSmall) }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = Color(0xFF2196F3).copy(alpha = 0.3f) + ) + ) + + // Rest indicator + if (currentType == null) { + Text( + stringResource(R.string.rest_day_label), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // Legend + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.size(8.dp).background(Color(0xFFFFC107), CircleShape)) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.day_shift_label), style = MaterialTheme.typography.labelSmall) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.size(8.dp).background(Color(0xFF2196F3), CircleShape)) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.night_shift_label), style = MaterialTheme.typography.labelSmall) + } + } + } +} diff --git a/app/src/main/java/com/yaros/shiftmanager/Staticanager.kt b/app/src/main/java/com/yaros/shiftmanager/Staticanager.kt index a3bca04..066c42c 100644 --- a/app/src/main/java/com/yaros/shiftmanager/Staticanager.kt +++ b/app/src/main/java/com/yaros/shiftmanager/Staticanager.kt @@ -12,16 +12,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.time.DayOfWeek -import java.time.LocalDate import java.time.ZoneId -import java.time.temporal.TemporalAdjusters import java.util.* -import kotlin.math.roundToInt - @Composable fun StatisticsScreen( @@ -30,35 +25,21 @@ fun StatisticsScreen( settingsViewModel: SettingsViewModel ) { var selectedPeriod by remember { mutableStateOf(StatisticsPeriod.MONTH) } - var incomes by remember { mutableStateOf>(emptyList()) } - var shifts by remember { mutableStateOf>(emptyList()) } + + // Reactive flows — automatically update when data changes + val shifts by shiftViewModel.periodShifts.collectAsState() + val incomes by incomeViewModel.periodIncomes.collectAsState() val templates by shiftViewModel.allTemplates.collectAsState() val currency by settingsViewModel.currency.collectAsState() val taxRate by settingsViewModel.taxRate.collectAsState() + // Sync period to ViewModels so their internal queries update LaunchedEffect(selectedPeriod) { - val (startDate, endDate) = getDateRangeForPeriod(selectedPeriod) - - // Observe incomes - val incomeObserver = androidx.lifecycle.Observer> { incomes = it } - val incomeLiveData = incomeViewModel.getIncomesForPeriod(startDate, endDate) - incomeLiveData.observeForever(incomeObserver) - - // Collect shifts - val shiftsJob = launch { - shiftViewModel.getShiftsForDateRange(startDate, endDate).collect { shifts = it } - } - - // Cleanup on recomposition - try { - awaitCancellation() - } finally { - incomeLiveData.removeObserver(incomeObserver) - shiftsJob.cancel() - } + shiftViewModel.setStatsPeriod(selectedPeriod) + incomeViewModel.setStatsPeriod(selectedPeriod) } - // Calculate estimated earnings from shifts + // Calculate estimated earnings from ALL shifts in the period val estimatedEarnings = remember(shifts, templates) { shifts.sumOf { shift -> val template = templates.find { it.id == shift.templateId } @@ -83,6 +64,9 @@ fun StatisticsScreen( } } + val estimatedTaxAmount = estimatedEarnings * (taxRate / 100.0) + val estimatedAfterTax = estimatedEarnings - estimatedTaxAmount + val realEarnings = incomes.sumOf { it.amount } val taxAmount = realEarnings * (taxRate / 100.0) val afterTaxEarnings = realEarnings - taxAmount @@ -101,7 +85,7 @@ fun StatisticsScreen( PeriodSelector(selectedPeriod) { selectedPeriod = it } Spacer(modifier = Modifier.height(16.dp)) - // Earnings Overview Cards + // ── Earnings Overview ── Text( stringResource(R.string.earnings_overview), style = MaterialTheme.typography.titleLarge, @@ -120,6 +104,28 @@ fun StatisticsScreen( Spacer(modifier = Modifier.height(8.dp)) + // Estimated After Tax Card (NEW) + if (taxRate > 0f) { + EarningsCard( + title = stringResource(R.string.estimated_after_tax), + amount = estimatedAfterTax, + currencySymbol = currency.symbol, + description = stringResource(R.string.estimated_after_tax_desc, String.format(Locale.getDefault(), "%.1f", taxRate)), + color = Color(0xFF00BCD4) + ) + + if (estimatedTaxAmount > 0) { + Text( + text = stringResource(R.string.estimated_tax_amount, currency.symbol, String.format(Locale.getDefault(), "%.2f", estimatedTaxAmount)), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + } + // Real Earnings Card EarningsCard( title = stringResource(R.string.real_earnings), @@ -131,30 +137,31 @@ fun StatisticsScreen( Spacer(modifier = Modifier.height(8.dp)) - // After Tax Card - EarningsCard( - title = stringResource(R.string.after_tax), - amount = afterTaxEarnings, - currencySymbol = currency.symbol, - description = stringResource(R.string.after_tax_description, String.format(Locale.getDefault(), "%.1f", taxRate)), - color = Color(0xFFFF9800) - ) - - if (taxAmount > 0) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.tax_amount, currency.symbol, String.format(Locale.getDefault(), "%.2f", taxAmount)), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 12.dp) + // After Tax Card (on real earnings) + if (taxRate > 0f) { + EarningsCard( + title = stringResource(R.string.after_tax), + amount = afterTaxEarnings, + currencySymbol = currency.symbol, + description = stringResource(R.string.after_tax_description, String.format(Locale.getDefault(), "%.1f", taxRate)), + color = Color(0xFFFF9800) ) + + if (taxAmount > 0) { + Text( + text = stringResource(R.string.tax_amount, currency.symbol, String.format(Locale.getDefault(), "%.2f", taxAmount)), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + ) + } } Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider() Spacer(modifier = Modifier.height(16.dp)) - // Earnings Chart + // ── Earnings Chart ── val chartData = remember(shifts, templates) { buildChartData(shifts, templates, selectedPeriod) } @@ -174,7 +181,7 @@ fun StatisticsScreen( Spacer(modifier = Modifier.height(16.dp)) } - // Income Breakdown + // ── Income Breakdown ── Text( stringResource(R.string.income_breakdown), style = MaterialTheme.typography.titleMedium, @@ -185,14 +192,14 @@ fun StatisticsScreen( Spacer(modifier = Modifier.height(16.dp)) - // Shifts summary + // ── Shifts Summary ── Text( stringResource(R.string.shifts_summary), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.height(8.dp)) - ShiftsSummary(shifts, currency.symbol) + ShiftsSummary(shifts, currency.symbol, templates) } } @@ -214,7 +221,6 @@ private fun EarningsCard( .padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { - // Color strip Box( modifier = Modifier .width(4.dp) @@ -301,16 +307,77 @@ private fun BreakdownRow( } @Composable -private fun ShiftsSummary(shifts: List, currencySymbol: String) { +private fun ShiftsSummary(shifts: List, currencySymbol: String, templates: List) { val completedShifts = shifts.count { it.isCompleted } val totalHours = shifts.sumOf { it.hoursWorked } val avgHours = if (shifts.isNotEmpty()) totalHours / shifts.size else 0.0 + // Coefficient breakdown — show how much extra each coefficient added + val coefficientExtras = remember(shifts, templates) { + shifts.flatMap { shift -> + val template = templates.find { it.id == shift.templateId } ?: return@flatMap emptyList>() + val isWeekend = shift.startTime.toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .dayOfWeek.let { it == DayOfWeek.SATURDAY || it == DayOfWeek.SUNDAY } + val extras = mutableListOf>() + + // Weekend bonus + if (isWeekend && template.weekendCoefficient != 1.0) { + val basePay = shift.hourlyRate * shift.hoursWorked + val weekendExtra = basePay * (template.weekendCoefficient - 1.0) + extras.add("Weekend ×${String.format(Locale.getDefault(), "%.1f", template.weekendCoefficient)}" to weekendExtra) + } + + // Time-based coefficient rules + template.coefficientRules.forEach { rule -> + val percent = ((rule.coefficient - 1.0) * 100).toInt() + val sign = if (percent > 0) "+" else "" + val label = "${String.format(Locale.getDefault(), "%02d:00", rule.startHour)}–${String.format(Locale.getDefault(), "%02d:00", rule.endHour)} ${sign}${percent}%" + val baseForHour = shift.hourlyRate * 1.0 // per hour + val extra = baseForHour * (rule.coefficient - 1.0) + extras.add(label to extra) + } + + // Overtime + val overtimeHours = (shift.hoursWorked - template.overtimeThreshold).coerceAtLeast(0.0) + if (overtimeHours > 0 && template.overtimeCoefficient != 1.0) { + val overtimeExtra = shift.hourlyRate * (template.overtimeCoefficient - 1.0) * overtimeHours + extras.add("Overtime ×${String.format(Locale.getDefault(), "%.1f", template.overtimeCoefficient)}" to overtimeExtra) + } + + extras + } + } + Column { SummaryRow(stringResource(R.string.total_shifts), shifts.size.toString()) SummaryRow(stringResource(R.string.completed_label), completedShifts.toString()) SummaryRow(stringResource(R.string.total_hours), String.format(Locale.getDefault(), stringResource(R.string.hours_format), totalHours)) SummaryRow(stringResource(R.string.avg_hours), String.format(Locale.getDefault(), stringResource(R.string.hours_format), avgHours)) + + // Show coefficient extras if any + if (coefficientExtras.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.coefficient_rules), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) + coefficientExtras.forEach { (label, amount) -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(label, style = MaterialTheme.typography.bodySmall) + Text( + "+${currencySymbol}${String.format(Locale.getDefault(), "%.2f", amount)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + } } } @@ -343,12 +410,10 @@ private fun buildChartData( StatisticsPeriod.YEAR -> SimpleDateFormat("MMM", Locale.getDefault()) } - // Group shifts by day val shiftsByDay = shifts.groupBy { shift -> dateFormat.format(shift.startTime) } - // For YEAR period, group by month; otherwise by day val grouped = when (period) { StatisticsPeriod.YEAR -> { val monthFormat = SimpleDateFormat("yyyy-MM", Locale.getDefault()) @@ -375,7 +440,6 @@ private fun buildChartData( // Limit to max 15 bars for readability return if (grouped.size > 15) { - // Aggregate into buckets val bucketSize = (grouped.size + 14) / 15 grouped.chunked(bucketSize).map { chunk -> ChartDataPoint( diff --git a/app/src/main/java/com/yaros/shiftmanager/SwipeToDelete.kt b/app/src/main/java/com/yaros/shiftmanager/SwipeToDelete.kt new file mode 100644 index 0000000..4464a4c --- /dev/null +++ b/app/src/main/java/com/yaros/shiftmanager/SwipeToDelete.kt @@ -0,0 +1,71 @@ +package com.yaros.shiftmanager + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +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.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SwipeToDeleteContainer( + onDelete: () -> Unit, + content: @Composable () -> Unit +) { + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { dismissValue -> + if (dismissValue == SwipeToDismissBoxValue.EndToStart) { + onDelete() + true + } else { + false + } + }, + positionalThreshold = { it * 0.3f } + ) + + SwipeToDismissBox( + state = dismissState, + backgroundContent = { + val color by animateColorAsState( + when (dismissState.targetValue) { + SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.error + else -> Color.Transparent + }, + label = "swipe_bg_color" + ) + val iconScale by animateFloatAsState( + if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart) 1.2f else 0.8f, + label = "swipe_icon_scale" + ) + Box( + Modifier + .fillMaxSize() + .background(color) + .padding(horizontal = 20.dp), + contentAlignment = Alignment.CenterEnd + ) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.delete), + tint = Color.White, + modifier = Modifier.scale(iconScale) + ) + } + }, + enableDismissFromStartToEnd = false + ) { + content() + } +} diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..8f3246b --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,213 @@ + + ShiftManager + + + Смены + Шаблоны + Доход + Настройки + + + Добавить смену + Предыдущий месяц + Следующий месяц + Смены на %1$d %2$s + Нет смен — нажмите + чтобы добавить + + + День + Ночь + Выходной + Выполнено + + + Редактировать смену + Добавить смену + Выберите шаблон смены + Нет шаблонов — создайте во вкладке Шаблоны + Название (необязательно) + Тип + Часы + Ставка/час + Начало: %1$s + Выполнено + Заработок: %1$s%2$s + Сохранить + Отмена + Время начала смены + ОК + +%1$s%2$s бонус + %1$s%2$s/ч + + + Дневная смена + Ночная смена + Выходная смена + + + Шаблоны смен + Создавайте шаблоны с почасовой оплатой и коэффициентами за ночь/выходные + Шаблонов пока нет + Нажмите + чтобы создать первый шаблон + Добавить шаблон + Базовая ставка: %1$s%2$s/ч + Выходные: \u00D7%1$s + Переработка (>%1$sч): \u00D7%2$s + Бонус/смена: %1$s%2$s + Правила коэффициентов: + + + Редактировать шаблон + Новый шаблон + Название (напр. Дневная смена, Ночная смена) + Базовая ставка в час ($) + Дополнительные правила + Коэффициент выходных + напр. 1.5 = +50% + Переработка после (ч) + Переработка \u00D7 + Бонус за смену ($) + Правила коэффициентов по времени + Пример: 00:00\u201306:00 \u2192 1.4 означает +40% за часы с полуночи до 6 утра + Добавить правило: + С + По + Безымянный шаблон + + + Настройки + Тема оформления + Режим отображения + Валюта + Налоговая ставка + Налоговая ставка (%) + Эта ставка используется для расчёта заработка после уплаты налогов + + + СВЕТЛАЯ + ТЁМНАЯ + СИСТЕМНАЯ + + + ПОНЕДЕЛЬНО + ПОМЕСЯЧНО + + + Выберите период + Обзор заработка + Ожидаемый заработок + На основе запланированных смен и шаблонов + Фактический заработок + Фактически полученный доход + После налогов + После вычета налога %1$s%% + Сумма налога: %1$s%2$s + Разбивка дохода + Сводка по сменам + + + Неделя + Месяц + Год + + + Итого + Обычные + Чаевые + Бонусы + + + Всего смен + Выполнено + Всего часов + Среднее ч/смена + %.1f ч + + + Эл. почта + Пароль + Регистрация + Войти + Уже есть аккаунт? Войти + Нет аккаунта? Зарегистрироваться + + + Налоговые напоминания + Добавить напоминание + Срок: %1$s + Описание + Добавить + + + Доллар США + Евро + Фунт стерлингов + Российский рубль + Украинская гривна + Казахстанский тенге + Японская иена + Китайский юань + + + Сегодня + + + Удалить? + Удалить эту смену? + Удалить этот шаблон? + Удалить + + + Свайпните для удаления + + + График заработка + Нет данных для графика + + + Ожидаемый после налогов + Ожидаемый заработок после налога %1$s%% + Ожидаемый налог: %1$s%2$s + + + Коэффициент (%%) + напр. 40 = +40%% + Ночной коэффициент + Ночь: \u00D7%1$s + + + Генерировать смены + Массовая генерация смен + Выберите шаблон + График работы + 5/2 (5 рабочих, 2 выходных) + 2/2 (2 рабочих, 2 выходных) + Чешский (день/ночь/2 выходных) + Свой + Продолжительность + 1 неделя + 2 недели + 1 месяц + Дата начала + Рабочие дни + Выходные дни + Дневная смена + Ночная смена + Выходной + Предпросмотр (%1$d смен) + Сгенерировать %1$d смен + Сгенерировано %1$d смен + Шаблон не выбран + Пн + Вт + Ср + Чт + Пт + Сб + Вс + Часов за смену + День + Ночь + Отдых 1 + Отдых 2 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8f06de0..e92c23f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -163,4 +163,51 @@ Earnings Chart No data for chart + + + Estimated After Tax + Estimated earnings after %1$s%% tax + Estimated tax: %1$s%2$s + + + Coefficient (%%) + e.g. 40 = +40%% + Night coefficient + Night: \u00D7%1$s + + + Generate Shifts + Bulk Generate Shifts + Select template + Schedule pattern + 5/2 (5 work, 2 off) + 2/2 (2 work, 2 off) + Czech (day/night/2 off) + Custom + Duration + 1 Week + 2 Weeks + 1 Month + Start date + Work days + Rest days + Day shift + Night shift + Rest + Preview (%1$d shifts) + Generate %1$d shifts + Generated %1$d shifts + No template selected + Mon + Tue + Wed + Thu + Fri + Sat + Sun + Hours per shift + Day + Night + Rest 1 + Rest 2 \ No newline at end of file