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