This commit is contained in:
2026-06-05 23:44:37 +03:00
parent 2e5c9b7123
commit da212052ce
10 changed files with 1184 additions and 123 deletions
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
@@ -7,6 +7,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.style.TextAlign
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import java.time.DayOfWeek import java.time.DayOfWeek
import java.time.LocalDate import java.time.LocalDate
@@ -158,7 +160,10 @@ fun CalendarScreen(
editingShift = shift editingShift = shift
showAddDialog = true 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, modifier: Modifier = Modifier,
onClick: () -> Unit 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 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( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier modifier = modifier
.aspectRatio(1f) .aspectRatio(1f)
.padding(2.dp) .padding(1.dp)
.background(cellBgColor, RoundedCornerShape(6.dp))
.clickable(enabled = isInMonth) { onClick() } .clickable(enabled = isInMonth) { onClick() }
) { ) {
Box( Box(
@@ -282,36 +306,38 @@ private fun DayCell(
color = when { color = when {
!isInMonth -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f) !isInMonth -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
isSelected -> MaterialTheme.colorScheme.onPrimaryContainer isSelected -> MaterialTheme.colorScheme.onPrimaryContainer
hasNightShift -> Color(0xFF2196F3) // blue number for night
hasWeekendShift -> Color(0xFF9C27B0) // purple for weekend
else -> MaterialTheme.colorScheme.onSurface 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()) { if (isInMonth && shifts.isNotEmpty()) {
Row( Box(
horizontalArrangement = Arrangement.Center, modifier = Modifier
modifier = Modifier.height(8.dp) .fillMaxWidth(0.7f)
) { .height(3.dp)
if (hasDayShift) { .background(indicatorColor, RoundedCornerShape(2.dp))
Box( )
modifier = Modifier Spacer(Modifier.height(2.dp))
.size(5.dp)
.background(Color(0xFFFFC107), CircleShape) // Small count badge if multiple shifts
) if (shifts.size > 1) {
Spacer(Modifier.width(2.dp)) Text(
} text = "${shifts.size}",
if (hasNightShift) { style = MaterialTheme.typography.labelSmall,
Box( color = indicatorColor,
modifier = Modifier fontSize = 8.sp
.size(5.dp) )
.background(Color(0xFF2196F3), CircleShape) } else {
) Spacer(Modifier.height(8.dp))
}
} }
} else { } 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<ShiftTemplate>, templates: List<ShiftTemplate>,
currencySymbol: String, currencySymbol: String,
onClick: () -> Unit, onClick: () -> Unit,
onDelete: () -> Unit onDelete: () -> Unit,
onToggleComplete: () -> Unit
) { ) {
val template = templates.find { it.id == shift.templateId } val template = templates.find { it.id == shift.templateId }
val typeLabel = when (shift.type) { val typeLabel = when (shift.type) {
@@ -343,7 +370,11 @@ private fun ShiftCard(
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp) .padding(horizontal = 12.dp, vertical = 4.dp)
.clickable { onClick() }, .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) { Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
// Color strip // Color strip
@@ -354,15 +385,29 @@ private fun ShiftCard(
.background(typeColor, MaterialTheme.shapes.small) .background(typeColor, MaterialTheme.shapes.small)
) )
Spacer(Modifier.width(12.dp)) 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)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = template?.name ?: shift.title.ifBlank { typeLabel }, 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(
text = "${String.format(Locale.getDefault(), "%.1f", shift.hoursWorked)} h · $typeLabel", text = "${String.format(Locale.getDefault(), "%.1f", shift.hoursWorked)} h · $typeLabel",
style = MaterialTheme.typography.bodySmall, 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) { Column(horizontalAlignment = Alignment.End) {
@@ -387,15 +432,9 @@ private fun ShiftCard(
Text( Text(
text = "${currencySymbol}${String.format(Locale.getDefault(), "%.2f", earnings)}", text = "${currencySymbol}${String.format(Locale.getDefault(), "%.2f", earnings)}",
style = MaterialTheme.typography.titleSmall, 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) { IconButton(onClick = onDelete) {
Text("", color = MaterialTheme.colorScheme.error) Text("", color = MaterialTheme.colorScheme.error)
@@ -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<ChartDataPoint>,
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)
)
}
}
}
}
@@ -8,7 +8,8 @@ import androidx.room.*
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import android.content.Context import android.content.Context
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
@@ -64,10 +65,25 @@ class IncomeRepository @Inject constructor(private val incomeDao: IncomeDao) {
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel @HiltViewModel
class IncomeViewModel @Inject constructor(private val repository: IncomeRepository) : ViewModel() { class IncomeViewModel @Inject constructor(private val repository: IncomeRepository) : ViewModel() {
val allIncomes: LiveData<List<Income>> = repository.allIncomes.asLiveData() val allIncomes: LiveData<List<Income>> = repository.allIncomes.asLiveData()
// Reactive period-based incomes for statistics screen
private val _statsPeriod = MutableStateFlow(StatisticsPeriod.MONTH)
val periodIncomes: StateFlow<List<Income>> = _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<List<Income>> { fun getIncomesForPeriod(startDate: Date, endDate: Date): LiveData<List<Income>> {
return repository.getIncomesForPeriod(startDate, endDate).asLiveData() return repository.getIncomesForPeriod(startDate, endDate).asLiveData()
} }
@@ -7,9 +7,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.DayOfWeek
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime
import java.time.YearMonth import java.time.YearMonth
import java.time.ZoneId import java.time.ZoneId
import java.time.temporal.ChronoUnit
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
@@ -146,6 +149,20 @@ class ShiftViewModel @Inject constructor(
.flatMapLatest { month -> repository.getShiftsForMonth(month) } .flatMapLatest { month -> repository.getShiftsForMonth(month) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
// Reactive period-based shifts for statistics screen
private val _statsPeriod = MutableStateFlow(StatisticsPeriod.MONTH)
val periodShifts: StateFlow<List<Shift>> = _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<List<ShiftTemplate>> = templateRepository.allTemplates val allTemplates: StateFlow<List<ShiftTemplate>> = templateRepository.allTemplates
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
@@ -166,6 +183,11 @@ class ShiftViewModel @Inject constructor(
fun updateTemplate(template: ShiftTemplate) = viewModelScope.launch { templateRepository.updateTemplate(template) } fun updateTemplate(template: ShiftTemplate) = viewModelScope.launch { templateRepository.updateTemplate(template) }
fun deleteTemplate(template: ShiftTemplate) = viewModelScope.launch { templateRepository.deleteTemplate(template) } fun deleteTemplate(template: ShiftTemplate) = viewModelScope.launch { templateRepository.deleteTemplate(template) }
fun bulkInsertShifts(shifts: List<Shift>, onComplete: (Int) -> Unit = {}) = viewModelScope.launch {
shifts.forEach { repository.insertShift(it) }
onComplete(shifts.size)
}
fun shiftsForDate(date: LocalDate): List<Shift> { fun shiftsForDate(date: LocalDate): List<Shift> {
return monthShifts.value.filter { shift -> return monthShifts.value.filter { shift ->
shift.startTime.toInstant() shift.startTime.toInstant()
@@ -228,5 +250,85 @@ class ShiftViewModel @Inject constructor(
return total 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<Int, ShiftType>? = null // dayIndex (0-based) -> ShiftType
): List<Shift> {
val shifts = mutableListOf<Shift>()
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
}
@@ -1,9 +1,15 @@
package com.yaros.shiftmanager 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.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState 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.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
@@ -12,9 +18,16 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel 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.Locale
import java.util.UUID import java.util.UUID
@@ -27,18 +40,31 @@ fun ShiftTemplatesScreen(
) { ) {
val templates by viewModel.allTemplates.collectAsState() val templates by viewModel.allTemplates.collectAsState()
val currency by settingsViewModel.currency.collectAsState() val currency by settingsViewModel.currency.collectAsState()
val context = LocalContext.current
var showDialog by remember { mutableStateOf(false) } var showDialog by remember { mutableStateOf(false) }
var editingTemplate by remember { mutableStateOf<ShiftTemplate?>(null) } var editingTemplate by remember { mutableStateOf<ShiftTemplate?>(null) }
var templateToDelete by remember { mutableStateOf<ShiftTemplate?>(null) } var templateToDelete by remember { mutableStateOf<ShiftTemplate?>(null) }
var showBulkDialog by remember { mutableStateOf(false) }
Scaffold( Scaffold(
floatingActionButton = { floatingActionButton = {
FloatingActionButton(onClick = { Column(horizontalAlignment = Alignment.End) {
editingTemplate = null // Bulk generate button
showDialog = true ExtendedFloatingActionButton(
}) { onClick = { showBulkDialog = true },
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_template)) 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 -> ) { 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) { if (templateToDelete != null) {
AlertDialog( AlertDialog(
onDismissRequest = { templateToDelete = null }, onDismissRequest = { templateToDelete = null },
@@ -158,15 +202,21 @@ private fun TemplateCard(
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
if (template.weekendCoefficient != 1.0) { if (template.weekendCoefficient != 1.0) {
val percent = ((template.weekendCoefficient - 1.0) * 100).toInt()
val sign = if (percent > 0) "+" else ""
Text( 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, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.secondary color = MaterialTheme.colorScheme.secondary
) )
} }
if (template.overtimeThreshold > 0 && template.overtimeCoefficient != 1.0) { if (template.overtimeThreshold > 0 && template.overtimeCoefficient != 1.0) {
val percent = ((template.overtimeCoefficient - 1.0) * 100).toInt()
val sign = if (percent > 0) "+" else ""
Text( 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, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary color = MaterialTheme.colorScheme.tertiary
) )
@@ -217,16 +267,17 @@ private fun TemplateDialog(
it.addAll(existingTemplate?.coefficientRules ?: emptyList()) it.addAll(existingTemplate?.coefficientRules ?: emptyList())
} }
} }
var weekendCoeffText by remember { // Weekend coefficient — allow input as percentage
var weekendPercentText by remember {
mutableStateOf( mutableStateOf(
if (existingTemplate != null && existingTemplate.weekendCoefficient != 1.0) 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( mutableStateOf(
if (existingTemplate != null && existingTemplate.overtimeCoefficient != 1.5) 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 { 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 newStartHour by remember { mutableStateOf("") }
var newEndHour by remember { mutableStateOf("") } var newEndHour by remember { mutableStateOf("") }
var newCoefficient by remember { mutableStateOf("") } var newPercent by remember { mutableStateOf("") }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -273,13 +324,22 @@ private fun TemplateDialog(
HorizontalDivider() HorizontalDivider()
Text(stringResource(R.string.additional_rules), style = MaterialTheme.typography.labelLarge) Text(stringResource(R.string.additional_rules), style = MaterialTheme.typography.labelLarge)
// Weekend coefficient as percentage
OutlinedTextField( OutlinedTextField(
value = weekendCoeffText, value = weekendPercentText,
onValueChange = { weekendCoeffText = it }, onValueChange = { weekendPercentText = it.filter { c -> c.isDigit() || c == '-' || c == '.' } },
label = { Text(stringResource(R.string.weekend_coefficient)) }, label = { Text(stringResource(R.string.weekend_coefficient) + " (%)") },
placeholder = { Text(stringResource(R.string.weekend_placeholder)) }, placeholder = { Text(stringResource(R.string.weekend_placeholder)) },
suffix = { Text("%") },
modifier = Modifier.fillMaxWidth(), 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( Row(
@@ -295,12 +355,20 @@ private fun TemplateDialog(
singleLine = true singleLine = true
) )
OutlinedTextField( OutlinedTextField(
value = overtimeCoeffText, value = overtimePercentText,
onValueChange = { overtimeCoeffText = it }, onValueChange = { overtimePercentText = it.filter { c -> c.isDigit() || c == '-' || c == '.' } },
label = { Text(stringResource(R.string.overtime_multiplier)) }, label = { Text(stringResource(R.string.overtime_multiplier) + " (%)") },
placeholder = { Text("1.5") }, placeholder = { Text("50") },
suffix = { Text("%") },
modifier = Modifier.weight(1f), 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 // Existing rules
rules.forEachIndexed { index, rule -> rules.forEachIndexed { index, rule ->
val percent = ((rule.coefficient - 1.0) * 100).toInt()
val sign = if (percent > 0) "+" else ""
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -331,7 +401,7 @@ private fun TemplateDialog(
Text( Text(
text = "${String.format(Locale.getDefault(), "%02d:00", rule.startHour)}" + text = "${String.format(Locale.getDefault(), "%02d:00", rule.startHour)}" +
"${String.format(Locale.getDefault(), "%02d:00", rule.endHour)}" + "${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, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f) 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) Text(stringResource(R.string.add_rule), style = MaterialTheme.typography.labelSmall)
Row( Row(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
@@ -365,10 +435,10 @@ private fun TemplateDialog(
singleLine = true singleLine = true
) )
OutlinedTextField( OutlinedTextField(
value = newCoefficient, value = newPercent,
onValueChange = { newCoefficient = it }, onValueChange = { newPercent = it.filter { c -> c.isDigit() || c == '-' || c == '.' } },
label = { Text("×") }, label = { Text("%") },
placeholder = { Text("1.4") }, placeholder = { Text("40") },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
singleLine = true singleLine = true
) )
@@ -376,12 +446,13 @@ private fun TemplateDialog(
onClick = { onClick = {
val start = newStartHour.toIntOrNull() ?: return@FilledTonalButton val start = newStartHour.toIntOrNull() ?: return@FilledTonalButton
val end = newEndHour.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 if (start !in 0..23 || end !in 0..23) return@FilledTonalButton
val coeff = 1.0 + pct / 100.0
rules.add(CoefficientRule(start, end, coeff)) rules.add(CoefficientRule(start, end, coeff))
newStartHour = "" newStartHour = ""
newEndHour = "" newEndHour = ""
newCoefficient = "" newPercent = ""
}, },
modifier = Modifier.size(40.dp), modifier = Modifier.size(40.dp),
contentPadding = PaddingValues(0.dp) contentPadding = PaddingValues(0.dp)
@@ -395,8 +466,10 @@ private fun TemplateDialog(
val untitledName = stringResource(R.string.untitled_template) val untitledName = stringResource(R.string.untitled_template)
Button(onClick = { Button(onClick = {
val rate = rateText.toDoubleOrNull() ?: return@Button val rate = rateText.toDoubleOrNull() ?: return@Button
val weekendCoeff = weekendCoeffText.toDoubleOrNull() ?: 1.0 val weekendPct = weekendPercentText.toDoubleOrNull()
val overtimeCoeff = overtimeCoeffText.toDoubleOrNull() ?: 1.5 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 overtimeThreshold = overtimeThresholdText.toDoubleOrNull() ?: 8.0
val bonus = bonusText.toDoubleOrNull() ?: 0.0 val bonus = bonusText.toDoubleOrNull() ?: 0.0
val template = ShiftTemplate( val template = ShiftTemplate(
@@ -417,3 +490,333 @@ private fun TemplateDialog(
} }
) )
} }
// ─────────────────── Bulk Generate Dialog ───────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BulkGenerateDialog(
templates: List<ShiftTemplate>,
currencySymbol: String,
onDismiss: () -> Unit,
onGenerate: (List<Shift>) -> Unit
) {
var selectedTemplateId by remember { mutableStateOf<String?>(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<ShiftType?>().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<ShiftType?>) {
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)
}
}
}
}
@@ -12,16 +12,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.time.DayOfWeek import java.time.DayOfWeek
import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.temporal.TemporalAdjusters
import java.util.* import java.util.*
import kotlin.math.roundToInt
@Composable @Composable
fun StatisticsScreen( fun StatisticsScreen(
@@ -30,35 +25,21 @@ fun StatisticsScreen(
settingsViewModel: SettingsViewModel settingsViewModel: SettingsViewModel
) { ) {
var selectedPeriod by remember { mutableStateOf(StatisticsPeriod.MONTH) } var selectedPeriod by remember { mutableStateOf(StatisticsPeriod.MONTH) }
var incomes by remember { mutableStateOf<List<Income>>(emptyList()) }
var shifts by remember { mutableStateOf<List<Shift>>(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 templates by shiftViewModel.allTemplates.collectAsState()
val currency by settingsViewModel.currency.collectAsState() val currency by settingsViewModel.currency.collectAsState()
val taxRate by settingsViewModel.taxRate.collectAsState() val taxRate by settingsViewModel.taxRate.collectAsState()
// Sync period to ViewModels so their internal queries update
LaunchedEffect(selectedPeriod) { LaunchedEffect(selectedPeriod) {
val (startDate, endDate) = getDateRangeForPeriod(selectedPeriod) shiftViewModel.setStatsPeriod(selectedPeriod)
incomeViewModel.setStatsPeriod(selectedPeriod)
// Observe incomes
val incomeObserver = androidx.lifecycle.Observer<List<Income>> { 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()
}
} }
// Calculate estimated earnings from shifts // Calculate estimated earnings from ALL shifts in the period
val estimatedEarnings = remember(shifts, templates) { val estimatedEarnings = remember(shifts, templates) {
shifts.sumOf { shift -> shifts.sumOf { shift ->
val template = templates.find { it.id == shift.templateId } 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 realEarnings = incomes.sumOf { it.amount }
val taxAmount = realEarnings * (taxRate / 100.0) val taxAmount = realEarnings * (taxRate / 100.0)
val afterTaxEarnings = realEarnings - taxAmount val afterTaxEarnings = realEarnings - taxAmount
@@ -101,7 +85,7 @@ fun StatisticsScreen(
PeriodSelector(selectedPeriod) { selectedPeriod = it } PeriodSelector(selectedPeriod) { selectedPeriod = it }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Earnings Overview Cards // ── Earnings Overview ──
Text( Text(
stringResource(R.string.earnings_overview), stringResource(R.string.earnings_overview),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
@@ -120,6 +104,28 @@ fun StatisticsScreen(
Spacer(modifier = Modifier.height(8.dp)) 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 // Real Earnings Card
EarningsCard( EarningsCard(
title = stringResource(R.string.real_earnings), title = stringResource(R.string.real_earnings),
@@ -131,30 +137,31 @@ fun StatisticsScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// After Tax Card // After Tax Card (on real earnings)
EarningsCard( if (taxRate > 0f) {
title = stringResource(R.string.after_tax), EarningsCard(
amount = afterTaxEarnings, title = stringResource(R.string.after_tax),
currencySymbol = currency.symbol, amount = afterTaxEarnings,
description = stringResource(R.string.after_tax_description, String.format(Locale.getDefault(), "%.1f", taxRate)), currencySymbol = currency.symbol,
color = Color(0xFFFF9800) 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)
) )
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)) Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider() HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Earnings Chart // ── Earnings Chart ──
val chartData = remember(shifts, templates) { val chartData = remember(shifts, templates) {
buildChartData(shifts, templates, selectedPeriod) buildChartData(shifts, templates, selectedPeriod)
} }
@@ -174,7 +181,7 @@ fun StatisticsScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
// Income Breakdown // ── Income Breakdown ──
Text( Text(
stringResource(R.string.income_breakdown), stringResource(R.string.income_breakdown),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -185,14 +192,14 @@ fun StatisticsScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Shifts summary // ── Shifts Summary ──
Text( Text(
stringResource(R.string.shifts_summary), stringResource(R.string.shifts_summary),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Spacer(modifier = Modifier.height(8.dp)) 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), .padding(16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Color strip
Box( Box(
modifier = Modifier modifier = Modifier
.width(4.dp) .width(4.dp)
@@ -301,16 +307,77 @@ private fun BreakdownRow(
} }
@Composable @Composable
private fun ShiftsSummary(shifts: List<Shift>, currencySymbol: String) { private fun ShiftsSummary(shifts: List<Shift>, currencySymbol: String, templates: List<ShiftTemplate>) {
val completedShifts = shifts.count { it.isCompleted } val completedShifts = shifts.count { it.isCompleted }
val totalHours = shifts.sumOf { it.hoursWorked } val totalHours = shifts.sumOf { it.hoursWorked }
val avgHours = if (shifts.isNotEmpty()) totalHours / shifts.size else 0.0 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<Pair<String, Double>>()
val isWeekend = shift.startTime.toInstant()
.atZone(ZoneId.systemDefault())
.toLocalDate()
.dayOfWeek.let { it == DayOfWeek.SATURDAY || it == DayOfWeek.SUNDAY }
val extras = mutableListOf<Pair<String, Double>>()
// 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 { Column {
SummaryRow(stringResource(R.string.total_shifts), shifts.size.toString()) SummaryRow(stringResource(R.string.total_shifts), shifts.size.toString())
SummaryRow(stringResource(R.string.completed_label), completedShifts.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.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)) 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()) StatisticsPeriod.YEAR -> SimpleDateFormat("MMM", Locale.getDefault())
} }
// Group shifts by day
val shiftsByDay = shifts.groupBy { shift -> val shiftsByDay = shifts.groupBy { shift ->
dateFormat.format(shift.startTime) dateFormat.format(shift.startTime)
} }
// For YEAR period, group by month; otherwise by day
val grouped = when (period) { val grouped = when (period) {
StatisticsPeriod.YEAR -> { StatisticsPeriod.YEAR -> {
val monthFormat = SimpleDateFormat("yyyy-MM", Locale.getDefault()) val monthFormat = SimpleDateFormat("yyyy-MM", Locale.getDefault())
@@ -375,7 +440,6 @@ private fun buildChartData(
// Limit to max 15 bars for readability // Limit to max 15 bars for readability
return if (grouped.size > 15) { return if (grouped.size > 15) {
// Aggregate into buckets
val bucketSize = (grouped.size + 14) / 15 val bucketSize = (grouped.size + 14) / 15
grouped.chunked(bucketSize).map { chunk -> grouped.chunked(bucketSize).map { chunk ->
ChartDataPoint( ChartDataPoint(
@@ -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()
}
}
+213
View File
@@ -0,0 +1,213 @@
<resources>
<string name="app_name">ShiftManager</string>
<!-- Navigation -->
<string name="nav_shifts">Смены</string>
<string name="nav_templates">Шаблоны</string>
<string name="nav_income">Доход</string>
<string name="nav_settings">Настройки</string>
<!-- Calendar Screen -->
<string name="add_shift">Добавить смену</string>
<string name="previous_month">Предыдущий месяц</string>
<string name="next_month">Следующий месяц</string>
<string name="shifts_on">Смены на %1$d %2$s</string>
<string name="no_shifts_hint">Нет смен — нажмите + чтобы добавить</string>
<!-- Shift Card -->
<string name="type_day">День</string>
<string name="type_night">Ночь</string>
<string name="type_weekend">Выходной</string>
<string name="completed">Выполнено</string>
<!-- Add/Edit Shift Dialog -->
<string name="edit_shift">Редактировать смену</string>
<string name="add_shift_title">Добавить смену</string>
<string name="select_shift_template">Выберите шаблон смены</string>
<string name="no_templates_hint">Нет шаблонов — создайте во вкладке Шаблоны</string>
<string name="title_optional">Название (необязательно)</string>
<string name="shift_type">Тип</string>
<string name="hours_label">Часы</string>
<string name="rate_per_hour">Ставка/час</string>
<string name="start_time">Начало: %1$s</string>
<string name="completed_checkbox">Выполнено</string>
<string name="earnings_preview">Заработок: %1$s%2$s</string>
<string name="save">Сохранить</string>
<string name="cancel">Отмена</string>
<string name="shift_start_time">Время начала смены</string>
<string name="ok">ОК</string>
<string name="bonus_label">+%1$s%2$s бонус</string>
<string name="rate_per_hour_format">%1$s%2$s/ч</string>
<!-- Default shift titles -->
<string name="default_day_shift">Дневная смена</string>
<string name="default_night_shift">Ночная смена</string>
<string name="default_weekend_shift">Выходная смена</string>
<!-- Shift Templates Screen -->
<string name="shift_templates_title">Шаблоны смен</string>
<string name="templates_subtitle">Создавайте шаблоны с почасовой оплатой и коэффициентами за ночь/выходные</string>
<string name="no_templates_yet">Шаблонов пока нет</string>
<string name="create_first_template">Нажмите + чтобы создать первый шаблон</string>
<string name="add_template">Добавить шаблон</string>
<string name="base_rate">Базовая ставка: %1$s%2$s/ч</string>
<string name="weekend_coeff_short">Выходные: \u00D7%1$s</string>
<string name="overtime_label">Переработка (&gt;%1$sч): \u00D7%2$s</string>
<string name="bonus_per_shift">Бонус/смена: %1$s%2$s</string>
<string name="coefficient_rules">Правила коэффициентов:</string>
<!-- Template Dialog -->
<string name="edit_template">Редактировать шаблон</string>
<string name="new_template">Новый шаблон</string>
<string name="template_name_hint">Название (напр. Дневная смена, Ночная смена)</string>
<string name="base_hourly_rate">Базовая ставка в час ($)</string>
<string name="additional_rules">Дополнительные правила</string>
<string name="weekend_coefficient">Коэффициент выходных</string>
<string name="weekend_placeholder">напр. 1.5 = +50%</string>
<string name="overtime_after">Переработка после (ч)</string>
<string name="overtime_multiplier">Переработка \u00D7</string>
<string name="bonus_per_shift_label">Бонус за смену ($)</string>
<string name="time_based_rules">Правила коэффициентов по времени</string>
<string name="time_rule_example">Пример: 00:00\u201306:00 \u2192 1.4 означает +40% за часы с полуночи до 6 утра</string>
<string name="add_rule">Добавить правило:</string>
<string name="from_label">С</string>
<string name="to_label">По</string>
<string name="untitled_template">Безымянный шаблон</string>
<!-- Settings Screen -->
<string name="settings_title">Настройки</string>
<string name="theme_mode">Тема оформления</string>
<string name="display_mode">Режим отображения</string>
<string name="currency_label">Валюта</string>
<string name="tax_rate_label">Налоговая ставка</string>
<string name="tax_rate_hint_label">Налоговая ставка (%)</string>
<string name="tax_rate_description">Эта ставка используется для расчёта заработка после уплаты налогов</string>
<!-- Theme Modes -->
<string name="theme_light">СВЕТЛАЯ</string>
<string name="theme_dark">ТЁМНАЯ</string>
<string name="theme_system">СИСТЕМНАЯ</string>
<!-- Display Modes -->
<string name="display_weekly">ПОНЕДЕЛЬНО</string>
<string name="display_monthly">ПОМЕСЯЧНО</string>
<!-- Statistics Screen -->
<string name="select_period">Выберите период</string>
<string name="earnings_overview">Обзор заработка</string>
<string name="estimated_earnings">Ожидаемый заработок</string>
<string name="estimated_description">На основе запланированных смен и шаблонов</string>
<string name="real_earnings">Фактический заработок</string>
<string name="real_description">Фактически полученный доход</string>
<string name="after_tax">После налогов</string>
<string name="after_tax_description">После вычета налога %1$s%%</string>
<string name="tax_amount">Сумма налога: %1$s%2$s</string>
<string name="income_breakdown">Разбивка дохода</string>
<string name="shifts_summary">Сводка по сменам</string>
<!-- Periods -->
<string name="period_week">Неделя</string>
<string name="period_month">Месяц</string>
<string name="period_year">Год</string>
<!-- Income Breakdown -->
<string name="total">Итого</string>
<string name="regular">Обычные</string>
<string name="tips">Чаевые</string>
<string name="bonuses">Бонусы</string>
<!-- Shifts Summary -->
<string name="total_shifts">Всего смен</string>
<string name="completed_label">Выполнено</string>
<string name="total_hours">Всего часов</string>
<string name="avg_hours">Среднее ч/смена</string>
<string name="hours_format">%.1f ч</string>
<!-- Auth Screen -->
<string name="email_label">Эл. почта</string>
<string name="password_label">Пароль</string>
<string name="sign_up">Регистрация</string>
<string name="sign_in">Войти</string>
<string name="already_have_account">Уже есть аккаунт? Войти</string>
<string name="no_account">Нет аккаунта? Зарегистрироваться</string>
<!-- Tax Reminders -->
<string name="tax_reminders">Налоговые напоминания</string>
<string name="add_tax_reminder">Добавить напоминание</string>
<string name="due_date">Срок: %1$s</string>
<string name="description_label">Описание</string>
<string name="add">Добавить</string>
<!-- Currency display names -->
<string name="currency_usd">Доллар США</string>
<string name="currency_eur">Евро</string>
<string name="currency_gbp">Фунт стерлингов</string>
<string name="currency_rub">Российский рубль</string>
<string name="currency_uah">Украинская гривна</string>
<string name="currency_kzt">Казахстанский тенге</string>
<string name="currency_jpy">Японская иена</string>
<string name="currency_cny">Китайский юань</string>
<!-- Today button -->
<string name="today">Сегодня</string>
<!-- Delete confirmation -->
<string name="delete_confirm_title">Удалить?</string>
<string name="delete_shift_message">Удалить эту смену?</string>
<string name="delete_template_message">Удалить этот шаблон?</string>
<string name="delete">Удалить</string>
<!-- Swipe to delete -->
<string name="swipe_to_delete">Свайпните для удаления</string>
<!-- Earnings chart -->
<string name="earnings_chart">График заработка</string>
<string name="chart_no_data">Нет данных для графика</string>
<!-- Estimated after tax -->
<string name="estimated_after_tax">Ожидаемый после налогов</string>
<string name="estimated_after_tax_desc">Ожидаемый заработок после налога %1$s%%</string>
<string name="estimated_tax_amount">Ожидаемый налог: %1$s%2$s</string>
<!-- Coefficient percentage -->
<string name="coefficient_percent_label">Коэффициент (%%)</string>
<string name="coefficient_percent_hint">напр. 40 = +40%%</string>
<string name="night_coefficient">Ночной коэффициент</string>
<string name="night_coefficient_short">Ночь: \u00D7%1$s</string>
<!-- Bulk shift generation -->
<string name="generate_shifts">Генерировать смены</string>
<string name="generate_shifts_title">Массовая генерация смен</string>
<string name="select_template_for_gen">Выберите шаблон</string>
<string name="schedule_pattern">График работы</string>
<string name="pattern_5_2">5/2 (5 рабочих, 2 выходных)</string>
<string name="pattern_2_2">2/2 (2 рабочих, 2 выходных)</string>
<string name="pattern_czech">Чешский (день/ночь/2 выходных)</string>
<string name="pattern_custom">Свой</string>
<string name="duration_label">Продолжительность</string>
<string name="duration_week">1 неделя</string>
<string name="duration_2weeks">2 недели</string>
<string name="duration_month">1 месяц</string>
<string name="start_date_label">Дата начала</string>
<string name="work_days_label">Рабочие дни</string>
<string name="rest_days_label">Выходные дни</string>
<string name="day_shift_label">Дневная смена</string>
<string name="night_shift_label">Ночная смена</string>
<string name="rest_day_label">Выходной</string>
<string name="generate_preview">Предпросмотр (%1$d смен)</string>
<string name="generate_confirm">Сгенерировать %1$d смен</string>
<string name="shifts_generated">Сгенерировано %1$d смен</string>
<string name="no_template_selected">Шаблон не выбран</string>
<string name="monday_short">Пн</string>
<string name="tuesday_short">Вт</string>
<string name="wednesday_short">Ср</string>
<string name="thursday_short">Чт</string>
<string name="friday_short">Пт</string>
<string name="saturday_short">Сб</string>
<string name="sunday_short">Вс</string>
<string name="hours_per_shift">Часов за смену</string>
<string name="czech_day">День</string>
<string name="czech_night">Ночь</string>
<string name="czech_rest1">Отдых 1</string>
<string name="czech_rest2">Отдых 2</string>
</resources>
+47
View File
@@ -163,4 +163,51 @@
<!-- Earnings chart --> <!-- Earnings chart -->
<string name="earnings_chart">Earnings Chart</string> <string name="earnings_chart">Earnings Chart</string>
<string name="chart_no_data">No data for chart</string> <string name="chart_no_data">No data for chart</string>
<!-- Estimated after tax -->
<string name="estimated_after_tax">Estimated After Tax</string>
<string name="estimated_after_tax_desc">Estimated earnings after %1$s%% tax</string>
<string name="estimated_tax_amount">Estimated tax: %1$s%2$s</string>
<!-- Coefficient percentage -->
<string name="coefficient_percent_label">Coefficient (%%)</string>
<string name="coefficient_percent_hint">e.g. 40 = +40%%</string>
<string name="night_coefficient">Night coefficient</string>
<string name="night_coefficient_short">Night: \u00D7%1$s</string>
<!-- Bulk shift generation -->
<string name="generate_shifts">Generate Shifts</string>
<string name="generate_shifts_title">Bulk Generate Shifts</string>
<string name="select_template_for_gen">Select template</string>
<string name="schedule_pattern">Schedule pattern</string>
<string name="pattern_5_2">5/2 (5 work, 2 off)</string>
<string name="pattern_2_2">2/2 (2 work, 2 off)</string>
<string name="pattern_czech">Czech (day/night/2 off)</string>
<string name="pattern_custom">Custom</string>
<string name="duration_label">Duration</string>
<string name="duration_week">1 Week</string>
<string name="duration_2weeks">2 Weeks</string>
<string name="duration_month">1 Month</string>
<string name="start_date_label">Start date</string>
<string name="work_days_label">Work days</string>
<string name="rest_days_label">Rest days</string>
<string name="day_shift_label">Day shift</string>
<string name="night_shift_label">Night shift</string>
<string name="rest_day_label">Rest</string>
<string name="generate_preview">Preview (%1$d shifts)</string>
<string name="generate_confirm">Generate %1$d shifts</string>
<string name="shifts_generated">Generated %1$d shifts</string>
<string name="no_template_selected">No template selected</string>
<string name="monday_short">Mon</string>
<string name="tuesday_short">Tue</string>
<string name="wednesday_short">Wed</string>
<string name="thursday_short">Thu</string>
<string name="friday_short">Fri</string>
<string name="saturday_short">Sat</string>
<string name="sunday_short">Sun</string>
<string name="hours_per_shift">Hours per shift</string>
<string name="czech_day">Day</string>
<string name="czech_night">Night</string>
<string name="czech_rest1">Rest 1</string>
<string name="czech_rest2">Rest 2</string>
</resources> </resources>