1.1
This commit is contained in:
Generated
+13
@@ -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,37 +306,39 @@ 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)
|
||||
.fillMaxWidth(0.7f)
|
||||
.height(3.dp)
|
||||
.background(indicatorColor, RoundedCornerShape(2.dp))
|
||||
)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
}
|
||||
if (hasNightShift) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(5.dp)
|
||||
.background(Color(0xFF2196F3), CircleShape)
|
||||
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(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,13 +40,25 @@ 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 = {
|
||||
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
|
||||
@@ -41,6 +66,7 @@ fun ShiftTemplatesScreen(
|
||||
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_template))
|
||||
}
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(modifier = Modifier.padding(paddingValues).fillMaxSize()) {
|
||||
Text(
|
||||
@@ -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 }
|
||||
shiftViewModel.setStatsPeriod(selectedPeriod)
|
||||
incomeViewModel.setStatsPeriod(selectedPeriod)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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,7 +137,8 @@ fun StatisticsScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// After Tax Card
|
||||
// After Tax Card (on real earnings)
|
||||
if (taxRate > 0f) {
|
||||
EarningsCard(
|
||||
title = stringResource(R.string.after_tax),
|
||||
amount = afterTaxEarnings,
|
||||
@@ -141,20 +148,20 @@ fun StatisticsScreen(
|
||||
)
|
||||
|
||||
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)
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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">Переработка (>%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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user