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