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.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<ShiftTemplate>,
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)
@@ -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.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<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>> {
return repository.getIncomesForPeriod(startDate, endDate).asLiveData()
}
@@ -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<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
.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<Shift>, onComplete: (Int) -> Unit = {}) = viewModelScope.launch {
shifts.forEach { repository.insertShift(it) }
onComplete(shifts.size)
}
fun shiftsForDate(date: LocalDate): List<Shift> {
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<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
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<ShiftTemplate?>(null) }
var templateToDelete by remember { mutableStateOf<ShiftTemplate?>(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<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.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<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 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<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()
}
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<Shift>, currencySymbol: String) {
private fun ShiftsSummary(shifts: List<Shift>, currencySymbol: String, templates: List<ShiftTemplate>) {
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<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 {
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(
@@ -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 -->
<string name="earnings_chart">Earnings 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>