1
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
package com.yaros.shiftmanager
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverter
|
||||
import androidx.room.TypeConverters
|
||||
@@ -14,7 +12,7 @@ import java.util.Date
|
||||
|
||||
@Database(
|
||||
entities = [Income::class, Shift::class, TaxReminder::class, ShiftTemplate::class],
|
||||
version = 2,
|
||||
version = 3,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
@@ -25,9 +23,6 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun shiftTemplateDao(): ShiftTemplateDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE shifts ADD COLUMN hoursWorked REAL NOT NULL DEFAULT 0.0")
|
||||
@@ -43,17 +38,12 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
fun getInstance(context: Context): AppDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"shift_manager_database"
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2)
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE shift_templates ADD COLUMN weekendCoefficient REAL NOT NULL DEFAULT 1.0")
|
||||
db.execSQL("ALTER TABLE shift_templates ADD COLUMN overtimeCoefficient REAL NOT NULL DEFAULT 1.5")
|
||||
db.execSQL("ALTER TABLE shift_templates ADD COLUMN overtimeThreshold REAL NOT NULL DEFAULT 8.0")
|
||||
db.execSQL("ALTER TABLE shift_templates ADD COLUMN bonusPerShift REAL NOT NULL DEFAULT 0.0")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -89,14 +90,14 @@ fun AuthScreen(viewModel: AuthViewModel, onAuthSuccess: () -> Unit) {
|
||||
TextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text("Email") },
|
||||
label = { Text(stringResource(R.string.email_label)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Password") },
|
||||
label = { Text(stringResource(R.string.password_label)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
@@ -110,14 +111,14 @@ fun AuthScreen(viewModel: AuthViewModel, onAuthSuccess: () -> Unit) {
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(if (isSignUp) "Sign Up" else "Sign In")
|
||||
Text(if (isSignUp) stringResource(R.string.sign_up) else stringResource(R.string.sign_in))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextButton(
|
||||
onClick = { isSignUp = !isSignUp },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(if (isSignUp) "Already have an account? Sign In" else "Don't have an account? Sign Up")
|
||||
Text(if (isSignUp) stringResource(R.string.already_have_account) else stringResource(R.string.no_account))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,13 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ArrowForward
|
||||
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.graphics.Color
|
||||
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
|
||||
@@ -35,14 +37,19 @@ import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CalendarScreen(viewModel: ShiftViewModel = hiltViewModel()) {
|
||||
fun CalendarScreen(
|
||||
viewModel: ShiftViewModel = hiltViewModel(),
|
||||
settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||
) {
|
||||
val currentMonth by viewModel.currentMonth.collectAsState()
|
||||
val monthShifts by viewModel.monthShifts.collectAsState()
|
||||
val templates by viewModel.allTemplates.collectAsState()
|
||||
val currency by settingsViewModel.currency.collectAsState()
|
||||
|
||||
var selectedDate by remember { mutableStateOf(LocalDate.now()) }
|
||||
var showAddDialog by remember { mutableStateOf(false) }
|
||||
var editingShift by remember { mutableStateOf<Shift?>(null) }
|
||||
var shiftToDelete by remember { mutableStateOf<Shift?>(null) }
|
||||
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
@@ -50,7 +57,7 @@ fun CalendarScreen(viewModel: ShiftViewModel = hiltViewModel()) {
|
||||
editingShift = null
|
||||
showAddDialog = true
|
||||
}) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add Shift")
|
||||
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_shift))
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
@@ -67,16 +74,28 @@ fun CalendarScreen(viewModel: ShiftViewModel = hiltViewModel()) {
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = { viewModel.previousMonth() }) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Previous month")
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.previous_month))
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = currentMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault()) +
|
||||
" ${currentMonth.year}",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
viewModel.goToToday()
|
||||
selectedDate = LocalDate.now()
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.today), style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = currentMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault()) +
|
||||
" ${currentMonth.year}",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
IconButton(onClick = { viewModel.nextMonth() }) {
|
||||
Icon(Icons.Default.ArrowForward, contentDescription = "Next month")
|
||||
Icon(Icons.Default.ArrowForward, contentDescription = stringResource(R.string.next_month))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,29 +135,32 @@ fun CalendarScreen(viewModel: ShiftViewModel = hiltViewModel()) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
|
||||
Text(
|
||||
text = "Shifts on ${selectedDate.dayOfMonth} ${selectedDate.month.getDisplayName(TextStyle.SHORT, Locale.getDefault())}",
|
||||
text = stringResource(R.string.shifts_on, selectedDate.dayOfMonth, selectedDate.month.getDisplayName(TextStyle.SHORT, Locale.getDefault())),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
|
||||
if (shiftsForSelected.isEmpty()) {
|
||||
Text(
|
||||
text = "No shifts — tap + to add one",
|
||||
text = stringResource(R.string.no_shifts_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
} else {
|
||||
shiftsForSelected.forEach { shift ->
|
||||
ShiftCard(
|
||||
shift = shift,
|
||||
templates = templates,
|
||||
onClick = {
|
||||
editingShift = shift
|
||||
showAddDialog = true
|
||||
},
|
||||
onDelete = { viewModel.deleteShift(shift) }
|
||||
)
|
||||
SwipeToDeleteContainer(onDelete = { shiftToDelete = shift }) {
|
||||
ShiftCard(
|
||||
shift = shift,
|
||||
templates = templates,
|
||||
currencySymbol = currency.symbol,
|
||||
onClick = {
|
||||
editingShift = shift
|
||||
showAddDialog = true
|
||||
},
|
||||
onDelete = { shiftToDelete = shift }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,6 +171,7 @@ fun CalendarScreen(viewModel: ShiftViewModel = hiltViewModel()) {
|
||||
selectedDate = selectedDate,
|
||||
templates = templates,
|
||||
existingShift = editingShift,
|
||||
currencySymbol = currency.symbol,
|
||||
onDismiss = { showAddDialog = false },
|
||||
onSave = { shift ->
|
||||
if (editingShift != null) viewModel.updateShift(shift)
|
||||
@@ -157,6 +180,28 @@ fun CalendarScreen(viewModel: ShiftViewModel = hiltViewModel()) {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (shiftToDelete != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { shiftToDelete = null },
|
||||
title = { Text(stringResource(R.string.delete_confirm_title)) },
|
||||
text = { Text(stringResource(R.string.delete_shift_message)) },
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.deleteShift(shiftToDelete!!)
|
||||
shiftToDelete = null
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
|
||||
) { Text(stringResource(R.string.delete)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { shiftToDelete = null }) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────── Calendar Grid ───────────────────
|
||||
@@ -277,14 +322,15 @@ private fun DayCell(
|
||||
private fun ShiftCard(
|
||||
shift: Shift,
|
||||
templates: List<ShiftTemplate>,
|
||||
currencySymbol: String,
|
||||
onClick: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val template = templates.find { it.id == shift.templateId }
|
||||
val typeLabel = when (shift.type) {
|
||||
ShiftType.DAY -> "Day"
|
||||
ShiftType.NIGHT -> "Night"
|
||||
ShiftType.WEEKEND -> "Weekend"
|
||||
ShiftType.DAY -> stringResource(R.string.type_day)
|
||||
ShiftType.NIGHT -> stringResource(R.string.type_night)
|
||||
ShiftType.WEEKEND -> stringResource(R.string.type_weekend)
|
||||
}
|
||||
val typeColor = when (shift.type) {
|
||||
ShiftType.DAY -> Color(0xFFFFC107)
|
||||
@@ -321,20 +367,31 @@ private fun ShiftCard(
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
val earnings = if (template != null) {
|
||||
val isWeekend = shift.startTime.toInstant()
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
.dayOfWeek.let { it == java.time.DayOfWeek.SATURDAY || it == java.time.DayOfWeek.SUNDAY }
|
||||
ShiftViewModel.calculateEarnings(
|
||||
shift.hourlyRate, shift.hoursWorked, template.coefficientRules
|
||||
baseRate = shift.hourlyRate,
|
||||
hoursWorked = shift.hoursWorked,
|
||||
rules = template.coefficientRules,
|
||||
weekendCoefficient = template.weekendCoefficient,
|
||||
overtimeCoefficient = template.overtimeCoefficient,
|
||||
overtimeThreshold = template.overtimeThreshold,
|
||||
bonusPerShift = template.bonusPerShift,
|
||||
isWeekend = isWeekend
|
||||
)
|
||||
} else {
|
||||
shift.hourlyRate * shift.hoursWorked
|
||||
}
|
||||
Text(
|
||||
text = "$${String.format(Locale.getDefault(), "%.2f", earnings)}",
|
||||
text = "${currencySymbol}${String.format(Locale.getDefault(), "%.2f", earnings)}",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
if (shift.isCompleted) {
|
||||
Text(
|
||||
text = "Completed",
|
||||
text = stringResource(R.string.completed),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color(0xFF4CAF50)
|
||||
)
|
||||
@@ -355,6 +412,7 @@ private fun AddShiftDialog(
|
||||
selectedDate: LocalDate,
|
||||
templates: List<ShiftTemplate>,
|
||||
existingShift: Shift?,
|
||||
currencySymbol: String,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Shift) -> Unit
|
||||
) {
|
||||
@@ -384,47 +442,102 @@ private fun AddShiftDialog(
|
||||
|
||||
val selectedTemplate = templates.find { it.id == selectedTemplateId }
|
||||
|
||||
// Auto-fill rate from template
|
||||
LaunchedEffect(selectedTemplate) {
|
||||
selectedTemplate?.let { rateText = it.hourlyRate.toString() }
|
||||
selectedTemplate?.let { tmpl ->
|
||||
rateText = tmpl.hourlyRate.toString()
|
||||
if (title.isBlank() || templates.any { it.name == title }) {
|
||||
title = tmpl.name
|
||||
}
|
||||
// Infer shift type from template name
|
||||
val nameLower = tmpl.name.lowercase()
|
||||
shiftType = when {
|
||||
"night" in nameLower || "ночь" in nameLower || "ночн" in nameLower -> ShiftType.NIGHT
|
||||
"weekend" in nameLower || "выходн" in nameLower -> ShiftType.WEEKEND
|
||||
else -> ShiftType.DAY
|
||||
}
|
||||
// Pre-fill hours from overtime threshold if hours are empty
|
||||
if (hoursText.isBlank()) {
|
||||
hoursText = tmpl.overtimeThreshold.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if (existingShift != null) "Edit Shift" else "Add Shift") },
|
||||
title = { Text(if (existingShift != null) stringResource(R.string.edit_shift) else stringResource(R.string.add_shift_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
||||
// Template picker
|
||||
if (templates.isNotEmpty()) {
|
||||
Text("Template", style = MaterialTheme.typography.labelMedium)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.select_shift_template),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
templates.forEach { tmpl ->
|
||||
FilterChip(
|
||||
selected = selectedTemplateId == tmpl.id,
|
||||
val isSelected = selectedTemplateId == tmpl.id
|
||||
Card(
|
||||
onClick = {
|
||||
selectedTemplateId =
|
||||
if (selectedTemplateId == tmpl.id) null else tmpl.id
|
||||
},
|
||||
label = { Text(tmpl.name, style = MaterialTheme.typography.labelSmall) }
|
||||
)
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSelected)
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
selectedTemplateId =
|
||||
if (selectedTemplateId == tmpl.id) null else tmpl.id
|
||||
}
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
tmpl.name,
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
Text(
|
||||
"${currencySymbol}${String.format(Locale.getDefault(), "%.2f", tmpl.hourlyRate)}/${stringResource(R.string.hours_label).lowercase()}" +
|
||||
if (tmpl.bonusPerShift > 0) " + ${currencySymbol}${String.format(Locale.getDefault(), "%.0f", tmpl.bonusPerShift)} ${stringResource(R.string.bonuses).lowercase()}" else "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
stringResource(R.string.no_templates_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
label = { Text("Title (optional)") },
|
||||
label = { Text(stringResource(R.string.title_optional)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Shift type
|
||||
Text("Type", style = MaterialTheme.typography.labelMedium)
|
||||
Text(stringResource(R.string.shift_type), style = MaterialTheme.typography.labelMedium)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
ShiftType.entries.forEach { type ->
|
||||
FilterChip(
|
||||
@@ -433,9 +546,9 @@ private fun AddShiftDialog(
|
||||
label = {
|
||||
Text(
|
||||
when (type) {
|
||||
ShiftType.DAY -> "Day"
|
||||
ShiftType.NIGHT -> "Night"
|
||||
ShiftType.WEEKEND -> "Weekend"
|
||||
ShiftType.DAY -> stringResource(R.string.type_day)
|
||||
ShiftType.NIGHT -> stringResource(R.string.type_night)
|
||||
ShiftType.WEEKEND -> stringResource(R.string.type_weekend)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -447,14 +560,14 @@ private fun AddShiftDialog(
|
||||
OutlinedTextField(
|
||||
value = hoursText,
|
||||
onValueChange = { hoursText = it },
|
||||
label = { Text("Hours") },
|
||||
label = { Text(stringResource(R.string.hours_label)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = rateText,
|
||||
onValueChange = { rateText = it },
|
||||
label = { Text("$/hour") },
|
||||
label = { Text(stringResource(R.string.rate_per_hour)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true
|
||||
)
|
||||
@@ -463,33 +576,45 @@ private fun AddShiftDialog(
|
||||
// Start time button
|
||||
OutlinedButton(onClick = { showTimePicker = true }) {
|
||||
Text(
|
||||
"Start: ${
|
||||
stringResource(R.string.start_time,
|
||||
String.format(
|
||||
Locale.getDefault(),
|
||||
"%02d:%02d",
|
||||
startTime.hour,
|
||||
startTime.minute
|
||||
)
|
||||
}"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = isCompleted, onCheckedChange = { isCompleted = it })
|
||||
Text("Completed")
|
||||
Text(stringResource(R.string.completed_checkbox))
|
||||
}
|
||||
|
||||
// Earnings preview
|
||||
val hours = hoursText.toDoubleOrNull() ?: 0.0
|
||||
val rate = rateText.toDoubleOrNull() ?: 0.0
|
||||
val isWeekend = selectedDate.dayOfWeek.let {
|
||||
it == java.time.DayOfWeek.SATURDAY || it == java.time.DayOfWeek.SUNDAY
|
||||
}
|
||||
val earnings = if (selectedTemplate != null) {
|
||||
ShiftViewModel.calculateEarnings(rate, hours, selectedTemplate.coefficientRules)
|
||||
ShiftViewModel.calculateEarnings(
|
||||
baseRate = rate,
|
||||
hoursWorked = hours,
|
||||
rules = selectedTemplate.coefficientRules,
|
||||
weekendCoefficient = selectedTemplate.weekendCoefficient,
|
||||
overtimeCoefficient = selectedTemplate.overtimeCoefficient,
|
||||
overtimeThreshold = selectedTemplate.overtimeThreshold,
|
||||
bonusPerShift = selectedTemplate.bonusPerShift,
|
||||
isWeekend = isWeekend
|
||||
)
|
||||
} else {
|
||||
rate * hours
|
||||
}
|
||||
if (hours > 0 && rate > 0) {
|
||||
Text(
|
||||
text = "Earnings: $${String.format(Locale.getDefault(), "%.2f", earnings)}",
|
||||
text = stringResource(R.string.earnings_preview, currencySymbol, String.format(Locale.getDefault(), "%.2f", earnings)),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
@@ -497,6 +622,9 @@ private fun AddShiftDialog(
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
val defaultDayTitle = stringResource(R.string.default_day_shift)
|
||||
val defaultNightTitle = stringResource(R.string.default_night_shift)
|
||||
val defaultWeekendTitle = stringResource(R.string.default_weekend_shift)
|
||||
Button(onClick = {
|
||||
val hours = hoursText.toDoubleOrNull() ?: 0.0
|
||||
val rate = rateText.toDoubleOrNull() ?: 0.0
|
||||
@@ -508,9 +636,9 @@ private fun AddShiftDialog(
|
||||
id = existingShift?.id ?: UUID.randomUUID().toString(),
|
||||
title = title.ifBlank {
|
||||
when (shiftType) {
|
||||
ShiftType.DAY -> "Day Shift"
|
||||
ShiftType.NIGHT -> "Night Shift"
|
||||
ShiftType.WEEKEND -> "Weekend Shift"
|
||||
ShiftType.DAY -> defaultDayTitle
|
||||
ShiftType.NIGHT -> defaultNightTitle
|
||||
ShiftType.WEEKEND -> defaultWeekendTitle
|
||||
}
|
||||
},
|
||||
startTime = Date.from(startDateTime.atZone(zone).toInstant()),
|
||||
@@ -522,9 +650,9 @@ private fun AddShiftDialog(
|
||||
templateId = selectedTemplateId
|
||||
)
|
||||
onSave(shift)
|
||||
}) { Text("Save") }
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }
|
||||
)
|
||||
|
||||
if (showTimePicker) {
|
||||
@@ -535,16 +663,16 @@ private fun AddShiftDialog(
|
||||
)
|
||||
AlertDialog(
|
||||
onDismissRequest = { showTimePicker = false },
|
||||
title = { Text("Shift start time") },
|
||||
title = { Text(stringResource(R.string.shift_start_time)) },
|
||||
text = { TimePicker(state = pickerState) },
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
startTime = java.time.LocalTime.of(pickerState.hour, pickerState.minute)
|
||||
showTimePicker = false
|
||||
}) { Text("OK") }
|
||||
}) { Text(stringResource(R.string.ok)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showTimePicker = false }) { Text("Cancel") }
|
||||
TextButton(onClick = { showTimePicker = false }) { Text(stringResource(R.string.cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ object DatabaseModule {
|
||||
AppDatabase::class.java,
|
||||
"shift_manager_database"
|
||||
)
|
||||
.addMigrations(AppDatabase.MIGRATION_1_2)
|
||||
.addMigrations(AppDatabase.MIGRATION_1_2, AppDatabase.MIGRATION_2_3)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -47,7 +48,12 @@ fun MainScreen(
|
||||
incomeViewModel: IncomeViewModel,
|
||||
settingsViewModel: SettingsViewModel
|
||||
) {
|
||||
val navigationItems = listOf("Shifts", "Templates", "Income", "Settings")
|
||||
val navigationItems = listOf(
|
||||
stringResource(R.string.nav_shifts),
|
||||
stringResource(R.string.nav_templates),
|
||||
stringResource(R.string.nav_income),
|
||||
stringResource(R.string.nav_settings)
|
||||
)
|
||||
val (selectedItem, setSelectedItem) = remember { mutableStateOf(0) }
|
||||
|
||||
Scaffold(
|
||||
@@ -76,9 +82,9 @@ fun MainScreen(
|
||||
) { paddingValues ->
|
||||
Column(modifier = Modifier.padding(paddingValues)) {
|
||||
when (selectedItem) {
|
||||
0 -> CalendarScreen(shiftViewModel)
|
||||
1 -> ShiftTemplatesScreen(shiftViewModel)
|
||||
2 -> StatisticsScreen(incomeViewModel)
|
||||
0 -> CalendarScreen(shiftViewModel, settingsViewModel)
|
||||
1 -> ShiftTemplatesScreen(shiftViewModel, settingsViewModel)
|
||||
2 -> StatisticsScreen(incomeViewModel, shiftViewModel, settingsViewModel)
|
||||
3 -> SettingsScreen(settingsViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@ package com.yaros.shiftmanager
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.*
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
@@ -16,6 +20,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||
@@ -31,6 +36,14 @@ class SettingsRepository @Inject constructor(@ApplicationContext private val con
|
||||
DisplayMode.valueOf(preferences[PreferencesKeys.DISPLAY_MODE] ?: DisplayMode.WEEKLY.name)
|
||||
}
|
||||
|
||||
val currencyFlow: Flow<Currency> = dataStore.data.map { preferences ->
|
||||
Currency.valueOf(preferences[PreferencesKeys.CURRENCY] ?: Currency.USD.name)
|
||||
}
|
||||
|
||||
val taxRateFlow: Flow<Float> = dataStore.data.map { preferences ->
|
||||
preferences[PreferencesKeys.TAX_RATE] ?: 0f
|
||||
}
|
||||
|
||||
suspend fun setThemeMode(themeMode: ThemeMode) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.THEME_MODE] = themeMode.name
|
||||
@@ -43,9 +56,23 @@ class SettingsRepository @Inject constructor(@ApplicationContext private val con
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setCurrency(currency: Currency) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.CURRENCY] = currency.name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setTaxRate(taxRate: Float) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.TAX_RATE] = taxRate
|
||||
}
|
||||
}
|
||||
|
||||
private object PreferencesKeys {
|
||||
val THEME_MODE = stringPreferencesKey("theme_mode")
|
||||
val DISPLAY_MODE = stringPreferencesKey("display_mode")
|
||||
val CURRENCY = stringPreferencesKey("currency")
|
||||
val TAX_RATE = floatPreferencesKey("tax_rate")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +84,17 @@ enum class DisplayMode {
|
||||
WEEKLY, MONTHLY
|
||||
}
|
||||
|
||||
enum class Currency(val symbol: String, @StringRes val displayNameRes: Int) {
|
||||
USD("$", R.string.currency_usd),
|
||||
EUR("\u20AC", R.string.currency_eur),
|
||||
GBP("\u00A3", R.string.currency_gbp),
|
||||
RUB("\u20BD", R.string.currency_rub),
|
||||
UAH("\u20B4", R.string.currency_uah),
|
||||
KZT("\u20B8", R.string.currency_kzt),
|
||||
JPY("\u00A5", R.string.currency_jpy),
|
||||
CNY("\u00A5", R.string.currency_cny)
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(private val repository: SettingsRepository) : ViewModel() {
|
||||
val themeMode: StateFlow<ThemeMode> = repository.themeFlow.stateIn(
|
||||
@@ -71,6 +109,18 @@ class SettingsViewModel @Inject constructor(private val repository: SettingsRepo
|
||||
DisplayMode.WEEKLY
|
||||
)
|
||||
|
||||
val currency: StateFlow<Currency> = repository.currencyFlow.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
Currency.USD
|
||||
)
|
||||
|
||||
val taxRate: StateFlow<Float> = repository.taxRateFlow.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
0f
|
||||
)
|
||||
|
||||
fun setThemeMode(themeMode: ThemeMode) = viewModelScope.launch {
|
||||
repository.setThemeMode(themeMode)
|
||||
}
|
||||
@@ -78,26 +128,44 @@ class SettingsViewModel @Inject constructor(private val repository: SettingsRepo
|
||||
fun setDisplayMode(displayMode: DisplayMode) = viewModelScope.launch {
|
||||
repository.setDisplayMode(displayMode)
|
||||
}
|
||||
|
||||
fun setCurrency(currency: Currency) = viewModelScope.launch {
|
||||
repository.setCurrency(currency)
|
||||
}
|
||||
|
||||
fun setTaxRate(taxRate: Float) = viewModelScope.launch {
|
||||
repository.setTaxRate(taxRate)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(viewModel: SettingsViewModel) {
|
||||
val themeMode by viewModel.themeMode.collectAsState()
|
||||
val displayMode by viewModel.displayMode.collectAsState()
|
||||
val currency by viewModel.currency.collectAsState()
|
||||
val taxRate by viewModel.taxRate.collectAsState()
|
||||
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Settings", style = MaterialTheme.typography.titleLarge)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Text(stringResource(R.string.settings_title), style = MaterialTheme.typography.titleLarge)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
ThemeModeSelector(themeMode) { viewModel.setThemeMode(it) }
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
DisplayModeSelector(displayMode) { viewModel.setDisplayMode(it) }
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
CurrencySelector(currency) { viewModel.setCurrency(it) }
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
TaxRateSetting(taxRate) { viewModel.setTaxRate(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemeModeSelector(currentMode: ThemeMode, onModeSelected: (ThemeMode) -> Unit) {
|
||||
Column {
|
||||
Text("Theme Mode", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.theme_mode), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
ThemeMode.values().forEach { mode ->
|
||||
Row(
|
||||
@@ -106,7 +174,13 @@ fun ThemeModeSelector(currentMode: ThemeMode, onModeSelected: (ThemeMode) -> Uni
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(mode.name)
|
||||
Text(
|
||||
when (mode) {
|
||||
ThemeMode.LIGHT -> stringResource(R.string.theme_light)
|
||||
ThemeMode.DARK -> stringResource(R.string.theme_dark)
|
||||
ThemeMode.SYSTEM -> stringResource(R.string.theme_system)
|
||||
}
|
||||
)
|
||||
RadioButton(
|
||||
selected = mode == currentMode,
|
||||
onClick = { onModeSelected(mode) }
|
||||
@@ -119,7 +193,7 @@ fun ThemeModeSelector(currentMode: ThemeMode, onModeSelected: (ThemeMode) -> Uni
|
||||
@Composable
|
||||
fun DisplayModeSelector(currentMode: DisplayMode, onModeSelected: (DisplayMode) -> Unit) {
|
||||
Column {
|
||||
Text("Display Mode", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.display_mode), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
DisplayMode.values().forEach { mode ->
|
||||
Row(
|
||||
@@ -128,7 +202,12 @@ fun DisplayModeSelector(currentMode: DisplayMode, onModeSelected: (DisplayMode)
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(mode.name)
|
||||
Text(
|
||||
when (mode) {
|
||||
DisplayMode.WEEKLY -> stringResource(R.string.display_weekly)
|
||||
DisplayMode.MONTHLY -> stringResource(R.string.display_monthly)
|
||||
}
|
||||
)
|
||||
RadioButton(
|
||||
selected = mode == currentMode,
|
||||
onClick = { onModeSelected(mode) }
|
||||
@@ -138,6 +217,81 @@ fun DisplayModeSelector(currentMode: DisplayMode, onModeSelected: (DisplayMode)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CurrencySelector(currentCurrency: Currency, onCurrencySelected: (Currency) -> Unit) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Column {
|
||||
Text(stringResource(R.string.currency_label), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = "${currentCurrency.symbol} ${stringResource(currentCurrency.displayNameRes)}",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor()
|
||||
.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
Currency.values().forEach { currency ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text("${currency.symbol} ${stringResource(currency.displayNameRes)}")
|
||||
},
|
||||
onClick = {
|
||||
onCurrencySelected(currency)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TaxRateSetting(currentRate: Float, onRateChanged: (Float) -> Unit) {
|
||||
var rateText by remember(currentRate) {
|
||||
mutableStateOf(if (currentRate > 0f) String.format(Locale.getDefault(), "%.1f", currentRate) else "")
|
||||
}
|
||||
|
||||
Column {
|
||||
Text(stringResource(R.string.tax_rate_label), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = rateText,
|
||||
onValueChange = { newValue ->
|
||||
rateText = newValue
|
||||
val parsed = newValue.toFloatOrNull()
|
||||
if (parsed != null && parsed in 0f..100f) {
|
||||
onRateChanged(parsed)
|
||||
} else if (newValue.isEmpty()) {
|
||||
onRateChanged(0f)
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(R.string.tax_rate_hint_label)) },
|
||||
suffix = { Text("%") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.tax_rate_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
themeMode: ThemeMode,
|
||||
|
||||
@@ -52,7 +52,11 @@ data class ShiftTemplate(
|
||||
@PrimaryKey val id: String = UUID.randomUUID().toString(),
|
||||
val name: String,
|
||||
val hourlyRate: Double,
|
||||
val coefficientRules: List<CoefficientRule> = emptyList()
|
||||
val coefficientRules: List<CoefficientRule> = emptyList(),
|
||||
val weekendCoefficient: Double = 1.0,
|
||||
val overtimeCoefficient: Double = 1.5,
|
||||
val overtimeThreshold: Double = 8.0,
|
||||
val bonusPerShift: Double = 0.0
|
||||
)
|
||||
|
||||
data class CoefficientRule(
|
||||
@@ -71,6 +75,9 @@ interface ShiftDao {
|
||||
@Query("SELECT * FROM shifts WHERE startTime >= :start AND startTime < :end ORDER BY startTime ASC")
|
||||
fun getShiftsForMonth(start: Date, end: Date): Flow<List<Shift>>
|
||||
|
||||
@Query("SELECT * FROM shifts WHERE startTime >= :start AND startTime < :end ORDER BY startTime ASC")
|
||||
fun getShiftsForDateRange(start: Date, end: Date): Flow<List<Shift>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertShift(shift: Shift)
|
||||
|
||||
@@ -107,6 +114,10 @@ class ShiftRepository @Inject constructor(private val shiftDao: ShiftDao) {
|
||||
return shiftDao.getShiftsForMonth(start, end)
|
||||
}
|
||||
|
||||
fun getShiftsForDateRange(startDate: Date, endDate: Date): Flow<List<Shift>> {
|
||||
return shiftDao.getShiftsForDateRange(startDate, endDate)
|
||||
}
|
||||
|
||||
suspend fun insertShift(shift: Shift) = shiftDao.insertShift(shift)
|
||||
suspend fun updateShift(shift: Shift) = shiftDao.updateShift(shift)
|
||||
suspend fun deleteShift(shift: Shift) = shiftDao.deleteShift(shift)
|
||||
@@ -141,6 +152,11 @@ class ShiftViewModel @Inject constructor(
|
||||
fun setMonth(month: YearMonth) { _currentMonth.value = month }
|
||||
fun nextMonth() { _currentMonth.value = _currentMonth.value.plusMonths(1) }
|
||||
fun previousMonth() { _currentMonth.value = _currentMonth.value.minusMonths(1) }
|
||||
fun goToToday() { _currentMonth.value = YearMonth.now() }
|
||||
|
||||
fun getShiftsForDateRange(startDate: Date, endDate: Date): Flow<List<Shift>> {
|
||||
return repository.getShiftsForDateRange(startDate, endDate)
|
||||
}
|
||||
|
||||
fun insertShift(shift: Shift) = viewModelScope.launch { repository.insertShift(shift) }
|
||||
fun updateShift(shift: Shift) = viewModelScope.launch { repository.updateShift(shift) }
|
||||
@@ -159,19 +175,58 @@ class ShiftViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun calculateEarnings(baseRate: Double, hoursWorked: Double, rules: List<CoefficientRule>): Double {
|
||||
if (rules.isEmpty()) return baseRate * hoursWorked
|
||||
val hoursByCoefficient = mutableListOf<Double>()
|
||||
for (i in 0 until hoursWorked.toInt().coerceAtMost(24)) {
|
||||
fun calculateEarnings(
|
||||
baseRate: Double,
|
||||
hoursWorked: Double,
|
||||
rules: List<CoefficientRule>,
|
||||
weekendCoefficient: Double = 1.0,
|
||||
overtimeCoefficient: Double = 1.5,
|
||||
overtimeThreshold: Double = 8.0,
|
||||
bonusPerShift: Double = 0.0,
|
||||
isWeekend: Boolean = false
|
||||
): Double {
|
||||
if (hoursWorked <= 0) return 0.0
|
||||
|
||||
var total = 0.0
|
||||
val regularHours = hoursWorked.coerceAtMost(overtimeThreshold)
|
||||
val overtimeHours = (hoursWorked - overtimeThreshold).coerceAtLeast(0.0)
|
||||
|
||||
// Calculate regular hours with coefficient rules
|
||||
for (i in 0 until regularHours.toInt().coerceAtMost(24)) {
|
||||
val coeff = rules.firstOrNull { rule ->
|
||||
if (rule.startHour <= rule.endHour)
|
||||
i >= rule.startHour && i < rule.endHour
|
||||
else
|
||||
i >= rule.startHour || i < rule.endHour
|
||||
}?.coefficient ?: 1.0
|
||||
hoursByCoefficient.add(coeff)
|
||||
val weekendMult = if (isWeekend) weekendCoefficient else 1.0
|
||||
total += baseRate * coeff * weekendMult
|
||||
}
|
||||
return hoursByCoefficient.sumOf { baseRate * it }
|
||||
|
||||
// Fractional regular hour
|
||||
val fractionalRegular = regularHours - regularHours.toInt()
|
||||
if (fractionalRegular > 0) {
|
||||
val coeff = rules.firstOrNull { rule ->
|
||||
val hour = regularHours.toInt()
|
||||
if (rule.startHour <= rule.endHour)
|
||||
hour >= rule.startHour && hour < rule.endHour
|
||||
else
|
||||
hour >= rule.startHour || hour < rule.endHour
|
||||
}?.coefficient ?: 1.0
|
||||
val weekendMult = if (isWeekend) weekendCoefficient else 1.0
|
||||
total += baseRate * coeff * weekendMult * fractionalRegular
|
||||
}
|
||||
|
||||
// Overtime hours
|
||||
if (overtimeHours > 0) {
|
||||
val weekendMult = if (isWeekend) weekendCoefficient else 1.0
|
||||
total += baseRate * overtimeCoefficient * weekendMult * overtimeHours
|
||||
}
|
||||
|
||||
// Bonus
|
||||
total += bonusPerShift
|
||||
|
||||
return total
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package com.yaros.shiftmanager
|
||||
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.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.*
|
||||
@@ -10,6 +12,7 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import java.util.Locale
|
||||
@@ -18,11 +21,16 @@ import java.util.UUID
|
||||
// ─────────────────── Templates Screen ───────────────────
|
||||
|
||||
@Composable
|
||||
fun ShiftTemplatesScreen(viewModel: ShiftViewModel = hiltViewModel()) {
|
||||
fun ShiftTemplatesScreen(
|
||||
viewModel: ShiftViewModel = hiltViewModel(),
|
||||
settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||
) {
|
||||
val templates by viewModel.allTemplates.collectAsState()
|
||||
val currency by settingsViewModel.currency.collectAsState()
|
||||
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var editingTemplate by remember { mutableStateOf<ShiftTemplate?>(null) }
|
||||
var templateToDelete by remember { mutableStateOf<ShiftTemplate?>(null) }
|
||||
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
@@ -30,18 +38,18 @@ fun ShiftTemplatesScreen(viewModel: ShiftViewModel = hiltViewModel()) {
|
||||
editingTemplate = null
|
||||
showDialog = true
|
||||
}) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add Template")
|
||||
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_template))
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(modifier = Modifier.padding(paddingValues).fillMaxSize()) {
|
||||
Text(
|
||||
text = "Shift Templates",
|
||||
text = stringResource(R.string.shift_templates_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Create templates with hourly rates and night/weekend coefficients",
|
||||
text = stringResource(R.string.templates_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
@@ -51,13 +59,13 @@ fun ShiftTemplatesScreen(viewModel: ShiftViewModel = hiltViewModel()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
"No templates yet",
|
||||
stringResource(R.string.no_templates_yet),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Tap + to create your first shift template",
|
||||
stringResource(R.string.create_first_template),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -66,14 +74,17 @@ fun ShiftTemplatesScreen(viewModel: ShiftViewModel = hiltViewModel()) {
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||
items(templates) { template ->
|
||||
TemplateCard(
|
||||
template = template,
|
||||
onEdit = {
|
||||
editingTemplate = template
|
||||
showDialog = true
|
||||
},
|
||||
onDelete = { viewModel.deleteTemplate(template) }
|
||||
)
|
||||
SwipeToDeleteContainer(onDelete = { templateToDelete = template }) {
|
||||
TemplateCard(
|
||||
template = template,
|
||||
currencySymbol = currency.symbol,
|
||||
onEdit = {
|
||||
editingTemplate = template
|
||||
showDialog = true
|
||||
},
|
||||
onDelete = { templateToDelete = template }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +102,28 @@ fun ShiftTemplatesScreen(viewModel: ShiftViewModel = hiltViewModel()) {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (templateToDelete != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { templateToDelete = null },
|
||||
title = { Text(stringResource(R.string.delete_confirm_title)) },
|
||||
text = { Text(stringResource(R.string.delete_template_message)) },
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.deleteTemplate(templateToDelete!!)
|
||||
templateToDelete = null
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
|
||||
) { Text(stringResource(R.string.delete)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { templateToDelete = null }) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────── Template Card ───────────────────
|
||||
@@ -98,6 +131,7 @@ fun ShiftTemplatesScreen(viewModel: ShiftViewModel = hiltViewModel()) {
|
||||
@Composable
|
||||
private fun TemplateCard(
|
||||
template: ShiftTemplate,
|
||||
currencySymbol: String,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
@@ -120,13 +154,34 @@ private fun TemplateCard(
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Base rate: $${String.format(Locale.getDefault(), "%.2f", template.hourlyRate)}/hr",
|
||||
text = stringResource(R.string.base_rate, currencySymbol, String.format(Locale.getDefault(), "%.2f", template.hourlyRate)),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
if (template.weekendCoefficient != 1.0) {
|
||||
Text(
|
||||
text = stringResource(R.string.weekend_coeff_short, String.format(Locale.getDefault(), "%.1f", template.weekendCoefficient)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
}
|
||||
if (template.overtimeThreshold > 0 && template.overtimeCoefficient != 1.0) {
|
||||
Text(
|
||||
text = stringResource(R.string.overtime_label, String.format(Locale.getDefault(), "%.0f", template.overtimeThreshold), String.format(Locale.getDefault(), "%.1f", template.overtimeCoefficient)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
if (template.bonusPerShift > 0) {
|
||||
Text(
|
||||
text = stringResource(R.string.bonus_per_shift, currencySymbol, String.format(Locale.getDefault(), "%.2f", template.bonusPerShift)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
if (template.coefficientRules.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Coefficient rules:", style = MaterialTheme.typography.labelMedium)
|
||||
Text(stringResource(R.string.coefficient_rules), style = MaterialTheme.typography.labelMedium)
|
||||
template.coefficientRules.forEach { rule ->
|
||||
val percent = ((rule.coefficient - 1.0) * 100).toInt()
|
||||
val sign = if (percent > 0) "+" else ""
|
||||
@@ -162,6 +217,30 @@ private fun TemplateDialog(
|
||||
it.addAll(existingTemplate?.coefficientRules ?: emptyList())
|
||||
}
|
||||
}
|
||||
var weekendCoeffText by remember {
|
||||
mutableStateOf(
|
||||
if (existingTemplate != null && existingTemplate.weekendCoefficient != 1.0)
|
||||
existingTemplate.weekendCoefficient.toString() else ""
|
||||
)
|
||||
}
|
||||
var overtimeCoeffText by remember {
|
||||
mutableStateOf(
|
||||
if (existingTemplate != null && existingTemplate.overtimeCoefficient != 1.5)
|
||||
existingTemplate.overtimeCoefficient.toString() else ""
|
||||
)
|
||||
}
|
||||
var overtimeThresholdText by remember {
|
||||
mutableStateOf(
|
||||
if (existingTemplate != null && existingTemplate.overtimeThreshold != 8.0)
|
||||
existingTemplate.overtimeThreshold.toString() else ""
|
||||
)
|
||||
}
|
||||
var bonusText by remember {
|
||||
mutableStateOf(
|
||||
if (existingTemplate != null && existingTemplate.bonusPerShift > 0)
|
||||
existingTemplate.bonusPerShift.toString() else ""
|
||||
)
|
||||
}
|
||||
|
||||
// New rule fields
|
||||
var newStartHour by remember { mutableStateOf("") }
|
||||
@@ -170,28 +249,74 @@ private fun TemplateDialog(
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if (existingTemplate != null) "Edit Template" else "New Template") },
|
||||
title = { Text(if (existingTemplate != null) stringResource(R.string.edit_template) else stringResource(R.string.new_template)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Template name (e.g. Day Shift, Night Shift)") },
|
||||
label = { Text(stringResource(R.string.template_name_hint)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = rateText,
|
||||
onValueChange = { rateText = it },
|
||||
label = { Text("Base hourly rate ($)") },
|
||||
label = { Text(stringResource(R.string.base_hourly_rate)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Divider()
|
||||
Text("Coefficient rules", style = MaterialTheme.typography.labelLarge)
|
||||
HorizontalDivider()
|
||||
Text(stringResource(R.string.additional_rules), style = MaterialTheme.typography.labelLarge)
|
||||
|
||||
OutlinedTextField(
|
||||
value = weekendCoeffText,
|
||||
onValueChange = { weekendCoeffText = it },
|
||||
label = { Text(stringResource(R.string.weekend_coefficient)) },
|
||||
placeholder = { Text(stringResource(R.string.weekend_placeholder)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = overtimeThresholdText,
|
||||
onValueChange = { overtimeThresholdText = it },
|
||||
label = { Text(stringResource(R.string.overtime_after)) },
|
||||
placeholder = { Text("8") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = overtimeCoeffText,
|
||||
onValueChange = { overtimeCoeffText = it },
|
||||
label = { Text(stringResource(R.string.overtime_multiplier)) },
|
||||
placeholder = { Text("1.5") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = bonusText,
|
||||
onValueChange = { bonusText = it },
|
||||
label = { Text(stringResource(R.string.bonus_per_shift_label)) },
|
||||
placeholder = { Text("0") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
Text(stringResource(R.string.time_based_rules), style = MaterialTheme.typography.labelLarge)
|
||||
Text(
|
||||
"Example: 00:00–06:00 → 1.4 means +40% for hours between midnight and 6 AM",
|
||||
stringResource(R.string.time_rule_example),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -217,7 +342,7 @@ private fun TemplateDialog(
|
||||
}
|
||||
|
||||
// Add new rule row
|
||||
Text("Add rule:", style = MaterialTheme.typography.labelSmall)
|
||||
Text(stringResource(R.string.add_rule), style = MaterialTheme.typography.labelSmall)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
@@ -225,7 +350,7 @@ private fun TemplateDialog(
|
||||
OutlinedTextField(
|
||||
value = newStartHour,
|
||||
onValueChange = { newStartHour = it.filter { c -> c.isDigit() }.take(2) },
|
||||
label = { Text("From") },
|
||||
label = { Text(stringResource(R.string.from_label)) },
|
||||
placeholder = { Text("00") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true
|
||||
@@ -234,7 +359,7 @@ private fun TemplateDialog(
|
||||
OutlinedTextField(
|
||||
value = newEndHour,
|
||||
onValueChange = { newEndHour = it.filter { c -> c.isDigit() }.take(2) },
|
||||
label = { Text("To") },
|
||||
label = { Text(stringResource(R.string.to_label)) },
|
||||
placeholder = { Text("06") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true
|
||||
@@ -267,19 +392,28 @@ private fun TemplateDialog(
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
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 overtimeThreshold = overtimeThresholdText.toDoubleOrNull() ?: 8.0
|
||||
val bonus = bonusText.toDoubleOrNull() ?: 0.0
|
||||
val template = ShiftTemplate(
|
||||
id = existingTemplate?.id ?: UUID.randomUUID().toString(),
|
||||
name = name.ifBlank { "Untitled Template" },
|
||||
name = name.ifBlank { untitledName },
|
||||
hourlyRate = rate,
|
||||
coefficientRules = rules.toList()
|
||||
coefficientRules = rules.toList(),
|
||||
weekendCoefficient = weekendCoeff,
|
||||
overtimeCoefficient = overtimeCoeff,
|
||||
overtimeThreshold = overtimeThreshold,
|
||||
bonusPerShift = bonus
|
||||
)
|
||||
onSave(template)
|
||||
}) { Text("Save") }
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,82 +1,327 @@
|
||||
package com.yaros.shiftmanager
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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 java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
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(incomeViewModel: IncomeViewModel) {
|
||||
fun StatisticsScreen(
|
||||
incomeViewModel: IncomeViewModel,
|
||||
shiftViewModel: ShiftViewModel,
|
||||
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()) }
|
||||
val templates by shiftViewModel.allTemplates.collectAsState()
|
||||
val currency by settingsViewModel.currency.collectAsState()
|
||||
val taxRate by settingsViewModel.taxRate.collectAsState()
|
||||
|
||||
LaunchedEffect(selectedPeriod) {
|
||||
val (startDate, endDate) = getDateRangeForPeriod(selectedPeriod)
|
||||
incomeViewModel.getIncomesForPeriod(startDate, endDate).observeForever { incomes = it }
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Calculate estimated earnings from shifts
|
||||
val estimatedEarnings = remember(shifts, templates) {
|
||||
shifts.sumOf { shift ->
|
||||
val template = templates.find { it.id == shift.templateId }
|
||||
val isWeekend = shift.startTime.toInstant()
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
.dayOfWeek.let { it == DayOfWeek.SATURDAY || it == DayOfWeek.SUNDAY }
|
||||
if (template != null) {
|
||||
ShiftViewModel.calculateEarnings(
|
||||
baseRate = shift.hourlyRate,
|
||||
hoursWorked = shift.hoursWorked,
|
||||
rules = template.coefficientRules,
|
||||
weekendCoefficient = template.weekendCoefficient,
|
||||
overtimeCoefficient = template.overtimeCoefficient,
|
||||
overtimeThreshold = template.overtimeThreshold,
|
||||
bonusPerShift = template.bonusPerShift,
|
||||
isWeekend = isWeekend
|
||||
)
|
||||
} else {
|
||||
shift.hourlyRate * shift.hoursWorked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val realEarnings = incomes.sumOf { it.amount }
|
||||
val taxAmount = realEarnings * (taxRate / 100.0)
|
||||
val afterTaxEarnings = realEarnings - taxAmount
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.select_period),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
PeriodSelector(selectedPeriod) { selectedPeriod = it }
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
IncomeChart(incomes)
|
||||
|
||||
// Earnings Overview Cards
|
||||
Text(
|
||||
stringResource(R.string.earnings_overview),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Estimated Earnings Card
|
||||
EarningsCard(
|
||||
title = stringResource(R.string.estimated_earnings),
|
||||
amount = estimatedEarnings,
|
||||
currencySymbol = currency.symbol,
|
||||
description = stringResource(R.string.estimated_description),
|
||||
color = Color(0xFF2196F3)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Real Earnings Card
|
||||
EarningsCard(
|
||||
title = stringResource(R.string.real_earnings),
|
||||
amount = realEarnings,
|
||||
currencySymbol = currency.symbol,
|
||||
description = stringResource(R.string.real_description),
|
||||
color = Color(0xFF4CAF50)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// After Tax Card
|
||||
EarningsCard(
|
||||
title = stringResource(R.string.after_tax),
|
||||
amount = afterTaxEarnings,
|
||||
currencySymbol = currency.symbol,
|
||||
description = stringResource(R.string.after_tax_description, String.format(Locale.getDefault(), "%.1f", taxRate)),
|
||||
color = Color(0xFFFF9800)
|
||||
)
|
||||
|
||||
if (taxAmount > 0) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.tax_amount, currency.symbol, String.format(Locale.getDefault(), "%.2f", taxAmount)),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
IncomeBreakdown(incomes)
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Earnings Chart
|
||||
val chartData = remember(shifts, templates) {
|
||||
buildChartData(shifts, templates, selectedPeriod)
|
||||
}
|
||||
if (chartData.isNotEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.earnings_chart),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
EarningsBarChart(
|
||||
data = chartData,
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Income Breakdown
|
||||
Text(
|
||||
stringResource(R.string.income_breakdown),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
IncomeBreakdown(incomes, currency.symbol)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PeriodSelector(selectedPeriod: StatisticsPeriod, onPeriodSelected: (StatisticsPeriod) -> Unit) {
|
||||
Row {
|
||||
StatisticsPeriod.entries.forEach { period ->
|
||||
Button(
|
||||
onClick = { onPeriodSelected(period) },
|
||||
modifier = Modifier.padding(4.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (period == selectedPeriod) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary
|
||||
|
||||
private fun EarningsCard(
|
||||
title: String,
|
||||
amount: Double,
|
||||
currencySymbol: String,
|
||||
description: String,
|
||||
color: Color
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.1f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Color strip
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(4.dp)
|
||||
.height(48.dp)
|
||||
.background(color, MaterialTheme.shapes.small)
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = color
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
) {
|
||||
Text(period.name)
|
||||
}
|
||||
Text(
|
||||
text = "${currencySymbol}${String.format(Locale.getDefault(), "%.2f", amount)}",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PeriodSelector(selectedPeriod: StatisticsPeriod, onPeriodSelected: (StatisticsPeriod) -> Unit) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
StatisticsPeriod.entries.forEach { period ->
|
||||
FilterChip(
|
||||
selected = period == selectedPeriod,
|
||||
onClick = { onPeriodSelected(period) },
|
||||
label = {
|
||||
Text(
|
||||
when (period) {
|
||||
StatisticsPeriod.WEEK -> stringResource(R.string.period_week)
|
||||
StatisticsPeriod.MONTH -> stringResource(R.string.period_month)
|
||||
StatisticsPeriod.YEAR -> stringResource(R.string.period_year)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncomeChart(incomes: List<Income>) {
|
||||
// Placeholder for chart - Vico chart implementation
|
||||
Text("Income Chart: ${incomes.size} entries")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncomeBreakdown(incomes: List<Income>) {
|
||||
fun IncomeBreakdown(incomes: List<Income>, currencySymbol: String) {
|
||||
val totalIncome = incomes.sumOf { it.amount }
|
||||
val regularIncome = incomes.filter { it.type == IncomeType.REGULAR }.sumOf { it.amount }
|
||||
val tipsIncome = incomes.filter { it.type == IncomeType.TIPS }.sumOf { it.amount }
|
||||
val bonusIncome = incomes.filter { it.type == IncomeType.BONUS }.sumOf { it.amount }
|
||||
|
||||
Column {
|
||||
Text("Total Income: $${String.format(Locale.US, "%.2f", totalIncome)}", style = MaterialTheme.typography.titleLarge)
|
||||
Text("Regular Income: $${String.format(Locale.US, "%.2f", regularIncome)}", style = MaterialTheme.typography.bodyLarge)
|
||||
Text("Tips: $${String.format(Locale.US, "%.2f", tipsIncome)}", style = MaterialTheme.typography.bodyLarge)
|
||||
Text("Bonuses: $${String.format(Locale.US, "%.2f", bonusIncome)}", style = MaterialTheme.typography.bodyLarge)
|
||||
BreakdownRow(stringResource(R.string.total), totalIncome, currencySymbol, MaterialTheme.typography.titleMedium)
|
||||
BreakdownRow(stringResource(R.string.regular), regularIncome, currencySymbol, MaterialTheme.typography.bodyMedium)
|
||||
BreakdownRow(stringResource(R.string.tips), tipsIncome, currencySymbol, MaterialTheme.typography.bodyMedium)
|
||||
BreakdownRow(stringResource(R.string.bonuses), bonusIncome, currencySymbol, MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BreakdownRow(
|
||||
label: String,
|
||||
amount: Double,
|
||||
currencySymbol: String,
|
||||
style: androidx.compose.ui.text.TextStyle
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(label, style = style)
|
||||
Text(
|
||||
"${currencySymbol}${String.format(Locale.getDefault(), "%.2f", amount)}",
|
||||
style = style
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShiftsSummary(shifts: List<Shift>, currencySymbol: String) {
|
||||
val completedShifts = shifts.count { it.isCompleted }
|
||||
val totalHours = shifts.sumOf { it.hoursWorked }
|
||||
val avgHours = if (shifts.isNotEmpty()) totalHours / shifts.size else 0.0
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +329,87 @@ enum class StatisticsPeriod {
|
||||
WEEK, MONTH, YEAR
|
||||
}
|
||||
|
||||
private fun buildChartData(
|
||||
shifts: List<Shift>,
|
||||
templates: List<ShiftTemplate>,
|
||||
period: StatisticsPeriod
|
||||
): List<ChartDataPoint> {
|
||||
if (shifts.isEmpty()) return emptyList()
|
||||
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
|
||||
val labelFormat = when (period) {
|
||||
StatisticsPeriod.WEEK -> SimpleDateFormat("EEE", Locale.getDefault())
|
||||
StatisticsPeriod.MONTH -> SimpleDateFormat("dd", Locale.getDefault())
|
||||
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())
|
||||
shifts.groupBy { monthFormat.format(it.startTime) }
|
||||
.map { (key, dayShifts) ->
|
||||
val earnings = dayShifts.sumOf { shift ->
|
||||
calculateShiftEarnings(shift, templates)
|
||||
}
|
||||
val label = labelFormat.format(dayShifts.first().startTime)
|
||||
ChartDataPoint(label, earnings)
|
||||
}
|
||||
.sortedBy { it.label }
|
||||
}
|
||||
else -> {
|
||||
shiftsByDay.map { (key, dayShifts) ->
|
||||
val earnings = dayShifts.sumOf { shift ->
|
||||
calculateShiftEarnings(shift, templates)
|
||||
}
|
||||
val label = labelFormat.format(dayShifts.first().startTime)
|
||||
ChartDataPoint(label, earnings)
|
||||
}.sortedBy { it.label }
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
label = chunk.first().label,
|
||||
value = chunk.sumOf { it.value }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
grouped
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateShiftEarnings(shift: Shift, templates: List<ShiftTemplate>): Double {
|
||||
val template = templates.find { it.id == shift.templateId }
|
||||
val isWeekend = shift.startTime.toInstant()
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
.dayOfWeek.let { it == DayOfWeek.SATURDAY || it == DayOfWeek.SUNDAY }
|
||||
return if (template != null) {
|
||||
ShiftViewModel.calculateEarnings(
|
||||
baseRate = shift.hourlyRate,
|
||||
hoursWorked = shift.hoursWorked,
|
||||
rules = template.coefficientRules,
|
||||
weekendCoefficient = template.weekendCoefficient,
|
||||
overtimeCoefficient = template.overtimeCoefficient,
|
||||
overtimeThreshold = template.overtimeThreshold,
|
||||
bonusPerShift = template.bonusPerShift,
|
||||
isWeekend = isWeekend
|
||||
)
|
||||
} else {
|
||||
shift.hourlyRate * shift.hoursWorked
|
||||
}
|
||||
}
|
||||
|
||||
fun getDateRangeForPeriod(period: StatisticsPeriod): Pair<Date, Date> {
|
||||
val calendar = Calendar.getInstance()
|
||||
val endDate = calendar.time
|
||||
@@ -94,4 +420,4 @@ fun getDateRangeForPeriod(period: StatisticsPeriod): Pair<Date, Date> {
|
||||
}, -1)
|
||||
val startDate = calendar.time
|
||||
return Pair(startDate, endDate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.*
|
||||
import androidx.room.*
|
||||
@@ -91,7 +92,7 @@ fun TaxReminderScreen(viewModel: TaxReminderViewModel) {
|
||||
val taxReminders by viewModel.allTaxReminders.collectAsState(initial = emptyList())
|
||||
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Tax Reminders", style = MaterialTheme.typography.titleLarge)
|
||||
Text(stringResource(R.string.tax_reminders), style = MaterialTheme.typography.titleLarge)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
LazyColumn {
|
||||
items(taxReminders) { reminder ->
|
||||
@@ -120,7 +121,7 @@ fun TaxReminderItem(reminder: TaxReminder, viewModel: TaxReminderViewModel) {
|
||||
Column {
|
||||
Text(reminder.description, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"Due: ${SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()).format(reminder.dueDate)}",
|
||||
stringResource(R.string.due_date, SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()).format(reminder.dueDate)),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
@@ -139,7 +140,7 @@ fun AddTaxReminderButton(viewModel: TaxReminderViewModel) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Button(onClick = { showDialog = true }) {
|
||||
Text("Add Tax Reminder")
|
||||
Text(stringResource(R.string.add_tax_reminder))
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
@@ -160,13 +161,13 @@ fun AddTaxReminderDialog(onDismiss: () -> Unit, onAdd: (String, Date) -> Unit) {
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Add Tax Reminder") },
|
||||
title = { Text(stringResource(R.string.add_tax_reminder)) },
|
||||
text = {
|
||||
Column {
|
||||
TextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Description") }
|
||||
label = { Text(stringResource(R.string.description_label)) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
// Add a DatePicker here for selecting dueDate
|
||||
@@ -174,12 +175,12 @@ fun AddTaxReminderDialog(onDismiss: () -> Unit, onAdd: (String, Date) -> Unit) {
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { onAdd(description, dueDate) }) {
|
||||
Text("Add")
|
||||
Text(stringResource(R.string.add))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Button(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,3 +1,166 @@
|
||||
<resources>
|
||||
<string name="app_name">ShiftManager</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="nav_shifts">Shifts</string>
|
||||
<string name="nav_templates">Templates</string>
|
||||
<string name="nav_income">Income</string>
|
||||
<string name="nav_settings">Settings</string>
|
||||
|
||||
<!-- Calendar Screen -->
|
||||
<string name="add_shift">Add Shift</string>
|
||||
<string name="previous_month">Previous month</string>
|
||||
<string name="next_month">Next month</string>
|
||||
<string name="shifts_on">Shifts on %1$d %2$s</string>
|
||||
<string name="no_shifts_hint">No shifts — tap + to add one</string>
|
||||
|
||||
<!-- Shift Card -->
|
||||
<string name="type_day">Day</string>
|
||||
<string name="type_night">Night</string>
|
||||
<string name="type_weekend">Weekend</string>
|
||||
<string name="completed">Completed</string>
|
||||
|
||||
<!-- Add/Edit Shift Dialog -->
|
||||
<string name="edit_shift">Edit Shift</string>
|
||||
<string name="add_shift_title">Add Shift</string>
|
||||
<string name="select_shift_template">Select Shift Template</string>
|
||||
<string name="no_templates_hint">No templates — create one in the Templates tab</string>
|
||||
<string name="title_optional">Title (optional)</string>
|
||||
<string name="shift_type">Type</string>
|
||||
<string name="hours_label">Hours</string>
|
||||
<string name="rate_per_hour">Rate/hour</string>
|
||||
<string name="start_time">Start: %1$s</string>
|
||||
<string name="completed_checkbox">Completed</string>
|
||||
<string name="earnings_preview">Earnings: %1$s%2$s</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="shift_start_time">Shift start time</string>
|
||||
<string name="ok">OK</string>
|
||||
<string name="bonus_label">+%1$s%2$s bonus</string>
|
||||
<string name="rate_per_hour_format">%1$s%2$s/hr</string>
|
||||
|
||||
<!-- Default shift titles -->
|
||||
<string name="default_day_shift">Day Shift</string>
|
||||
<string name="default_night_shift">Night Shift</string>
|
||||
<string name="default_weekend_shift">Weekend Shift</string>
|
||||
|
||||
<!-- Shift Templates Screen -->
|
||||
<string name="shift_templates_title">Shift Templates</string>
|
||||
<string name="templates_subtitle">Create templates with hourly rates and night/weekend coefficients</string>
|
||||
<string name="no_templates_yet">No templates yet</string>
|
||||
<string name="create_first_template">Tap + to create your first shift template</string>
|
||||
<string name="add_template">Add Template</string>
|
||||
<string name="base_rate">Base rate: %1$s%2$s/hr</string>
|
||||
<string name="weekend_coeff_short">Weekend: \u00D7%1$s</string>
|
||||
<string name="overtime_label">Overtime (>%1$sh): \u00D7%2$s</string>
|
||||
<string name="bonus_per_shift">Bonus/shift: %1$s%2$s</string>
|
||||
<string name="coefficient_rules">Coefficient rules:</string>
|
||||
|
||||
<!-- Template Dialog -->
|
||||
<string name="edit_template">Edit Template</string>
|
||||
<string name="new_template">New Template</string>
|
||||
<string name="template_name_hint">Template name (e.g. Day Shift, Night Shift)</string>
|
||||
<string name="base_hourly_rate">Base hourly rate ($)</string>
|
||||
<string name="additional_rules">Additional Rules</string>
|
||||
<string name="weekend_coefficient">Weekend coefficient</string>
|
||||
<string name="weekend_placeholder">e.g. 1.5 = +50%</string>
|
||||
<string name="overtime_after">Overtime after (hrs)</string>
|
||||
<string name="overtime_multiplier">Overtime \u00D7</string>
|
||||
<string name="bonus_per_shift_label">Bonus per shift ($)</string>
|
||||
<string name="time_based_rules">Time-based coefficient rules</string>
|
||||
<string name="time_rule_example">Example: 00:00\u201306:00 \u2192 1.4 means +40% for hours between midnight and 6 AM</string>
|
||||
<string name="add_rule">Add rule:</string>
|
||||
<string name="from_label">From</string>
|
||||
<string name="to_label">To</string>
|
||||
<string name="untitled_template">Untitled Template</string>
|
||||
|
||||
<!-- Settings Screen -->
|
||||
<string name="settings_title">Settings</string>
|
||||
<string name="theme_mode">Theme Mode</string>
|
||||
<string name="display_mode">Display Mode</string>
|
||||
<string name="currency_label">Currency</string>
|
||||
<string name="tax_rate_label">Tax Rate</string>
|
||||
<string name="tax_rate_hint_label">Tax rate (%)</string>
|
||||
<string name="tax_rate_description">This rate will be used to calculate your after-tax earnings</string>
|
||||
|
||||
<!-- Theme Modes -->
|
||||
<string name="theme_light">LIGHT</string>
|
||||
<string name="theme_dark">DARK</string>
|
||||
<string name="theme_system">SYSTEM</string>
|
||||
|
||||
<!-- Display Modes -->
|
||||
<string name="display_weekly">WEEKLY</string>
|
||||
<string name="display_monthly">MONTHLY</string>
|
||||
|
||||
<!-- Statistics Screen -->
|
||||
<string name="select_period">Select Period</string>
|
||||
<string name="earnings_overview">Earnings Overview</string>
|
||||
<string name="estimated_earnings">Estimated Earnings</string>
|
||||
<string name="estimated_description">Based on scheduled shifts & templates</string>
|
||||
<string name="real_earnings">Real Earnings</string>
|
||||
<string name="real_description">Actual recorded income</string>
|
||||
<string name="after_tax">After Tax</string>
|
||||
<string name="after_tax_description">After %1$s%% tax deduction</string>
|
||||
<string name="tax_amount">Tax amount: %1$s%2$s</string>
|
||||
<string name="income_breakdown">Income Breakdown</string>
|
||||
<string name="shifts_summary">Shifts Summary</string>
|
||||
|
||||
<!-- Periods -->
|
||||
<string name="period_week">Week</string>
|
||||
<string name="period_month">Month</string>
|
||||
<string name="period_year">Year</string>
|
||||
|
||||
<!-- Income Breakdown -->
|
||||
<string name="total">Total</string>
|
||||
<string name="regular">Regular</string>
|
||||
<string name="tips">Tips</string>
|
||||
<string name="bonuses">Bonuses</string>
|
||||
|
||||
<!-- Shifts Summary -->
|
||||
<string name="total_shifts">Total shifts</string>
|
||||
<string name="completed_label">Completed</string>
|
||||
<string name="total_hours">Total hours</string>
|
||||
<string name="avg_hours">Avg hours/shift</string>
|
||||
<string name="hours_format">%.1f hrs</string>
|
||||
|
||||
<!-- Auth Screen -->
|
||||
<string name="email_label">Email</string>
|
||||
<string name="password_label">Password</string>
|
||||
<string name="sign_up">Sign Up</string>
|
||||
<string name="sign_in">Sign In</string>
|
||||
<string name="already_have_account">Already have an account? Sign In</string>
|
||||
<string name="no_account">Don\'t have an account? Sign Up</string>
|
||||
|
||||
<!-- Tax Reminders -->
|
||||
<string name="tax_reminders">Tax Reminders</string>
|
||||
<string name="add_tax_reminder">Add Tax Reminder</string>
|
||||
<string name="due_date">Due: %1$s</string>
|
||||
<string name="description_label">Description</string>
|
||||
<string name="add">Add</string>
|
||||
|
||||
<!-- Currency display names -->
|
||||
<string name="currency_usd">US Dollar</string>
|
||||
<string name="currency_eur">Euro</string>
|
||||
<string name="currency_gbp">British Pound</string>
|
||||
<string name="currency_rub">Russian Ruble</string>
|
||||
<string name="currency_uah">Ukrainian Hryvnia</string>
|
||||
<string name="currency_kzt">Kazakhstani Tenge</string>
|
||||
<string name="currency_jpy">Japanese Yen</string>
|
||||
<string name="currency_cny">Chinese Yuan</string>
|
||||
|
||||
<!-- Today button -->
|
||||
<string name="today">Today</string>
|
||||
|
||||
<!-- Delete confirmation -->
|
||||
<string name="delete_confirm_title">Delete?</string>
|
||||
<string name="delete_shift_message">Delete this shift?</string>
|
||||
<string name="delete_template_message">Delete this template?</string>
|
||||
<string name="delete">Delete</string>
|
||||
|
||||
<!-- Swipe to delete -->
|
||||
<string name="swipe_to_delete">Swipe to delete</string>
|
||||
|
||||
<!-- Earnings chart -->
|
||||
<string name="earnings_chart">Earnings Chart</string>
|
||||
<string name="chart_no_data">No data for chart</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user