1
This commit is contained in:
@@ -1,8 +1,6 @@
|
|||||||
package com.yaros.shiftmanager
|
package com.yaros.shiftmanager
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.Room
|
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
import androidx.room.TypeConverter
|
import androidx.room.TypeConverter
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
@@ -14,7 +12,7 @@ import java.util.Date
|
|||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [Income::class, Shift::class, TaxReminder::class, ShiftTemplate::class],
|
entities = [Income::class, Shift::class, TaxReminder::class, ShiftTemplate::class],
|
||||||
version = 2,
|
version = 3,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
@@ -25,9 +23,6 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun shiftTemplateDao(): ShiftTemplateDao
|
abstract fun shiftTemplateDao(): ShiftTemplateDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@Volatile
|
|
||||||
private var INSTANCE: AppDatabase? = null
|
|
||||||
|
|
||||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
db.execSQL("ALTER TABLE shifts ADD COLUMN hoursWorked REAL NOT NULL DEFAULT 0.0")
|
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 {
|
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||||
return INSTANCE ?: synchronized(this) {
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
val instance = Room.databaseBuilder(
|
db.execSQL("ALTER TABLE shift_templates ADD COLUMN weekendCoefficient REAL NOT NULL DEFAULT 1.0")
|
||||||
context.applicationContext,
|
db.execSQL("ALTER TABLE shift_templates ADD COLUMN overtimeCoefficient REAL NOT NULL DEFAULT 1.5")
|
||||||
AppDatabase::class.java,
|
db.execSQL("ALTER TABLE shift_templates ADD COLUMN overtimeThreshold REAL NOT NULL DEFAULT 8.0")
|
||||||
"shift_manager_database"
|
db.execSQL("ALTER TABLE shift_templates ADD COLUMN bonusPerShift REAL NOT NULL DEFAULT 0.0")
|
||||||
)
|
|
||||||
.addMigrations(MIGRATION_1_2)
|
|
||||||
.build()
|
|
||||||
INSTANCE = instance
|
|
||||||
instance
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
@@ -89,14 +90,14 @@ fun AuthScreen(viewModel: AuthViewModel, onAuthSuccess: () -> Unit) {
|
|||||||
TextField(
|
TextField(
|
||||||
value = email,
|
value = email,
|
||||||
onValueChange = { email = it },
|
onValueChange = { email = it },
|
||||||
label = { Text("Email") },
|
label = { Text(stringResource(R.string.email_label)) },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
TextField(
|
TextField(
|
||||||
value = password,
|
value = password,
|
||||||
onValueChange = { password = it },
|
onValueChange = { password = it },
|
||||||
label = { Text("Password") },
|
label = { Text(stringResource(R.string.password_label)) },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -110,14 +111,14 @@ fun AuthScreen(viewModel: AuthViewModel, onAuthSuccess: () -> Unit) {
|
|||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth()
|
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))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { isSignUp = !isSignUp },
|
onClick = { isSignUp = !isSignUp },
|
||||||
modifier = Modifier.fillMaxWidth()
|
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.Add
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.ArrowForward
|
import androidx.compose.material.icons.filled.ArrowForward
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
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.graphics.Color
|
||||||
|
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
|
||||||
@@ -35,14 +37,19 @@ import kotlin.math.roundToInt
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarScreen(viewModel: ShiftViewModel = hiltViewModel()) {
|
fun CalendarScreen(
|
||||||
|
viewModel: ShiftViewModel = hiltViewModel(),
|
||||||
|
settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
val currentMonth by viewModel.currentMonth.collectAsState()
|
val currentMonth by viewModel.currentMonth.collectAsState()
|
||||||
val monthShifts by viewModel.monthShifts.collectAsState()
|
val monthShifts by viewModel.monthShifts.collectAsState()
|
||||||
val templates by viewModel.allTemplates.collectAsState()
|
val templates by viewModel.allTemplates.collectAsState()
|
||||||
|
val currency by settingsViewModel.currency.collectAsState()
|
||||||
|
|
||||||
var selectedDate by remember { mutableStateOf(LocalDate.now()) }
|
var selectedDate by remember { mutableStateOf(LocalDate.now()) }
|
||||||
var showAddDialog by remember { mutableStateOf(false) }
|
var showAddDialog by remember { mutableStateOf(false) }
|
||||||
var editingShift by remember { mutableStateOf<Shift?>(null) }
|
var editingShift by remember { mutableStateOf<Shift?>(null) }
|
||||||
|
var shiftToDelete by remember { mutableStateOf<Shift?>(null) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
@@ -50,7 +57,7 @@ fun CalendarScreen(viewModel: ShiftViewModel = hiltViewModel()) {
|
|||||||
editingShift = null
|
editingShift = null
|
||||||
showAddDialog = true
|
showAddDialog = true
|
||||||
}) {
|
}) {
|
||||||
Icon(Icons.Default.Add, contentDescription = "Add Shift")
|
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_shift))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
@@ -67,16 +74,28 @@ fun CalendarScreen(viewModel: ShiftViewModel = hiltViewModel()) {
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = { viewModel.previousMonth() }) {
|
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() }) {
|
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))
|
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||||
|
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (shiftsForSelected.isEmpty()) {
|
if (shiftsForSelected.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = "No shifts — tap + to add one",
|
text = stringResource(R.string.no_shifts_hint),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(16.dp)
|
modifier = Modifier.padding(16.dp)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
shiftsForSelected.forEach { shift ->
|
shiftsForSelected.forEach { shift ->
|
||||||
ShiftCard(
|
SwipeToDeleteContainer(onDelete = { shiftToDelete = shift }) {
|
||||||
shift = shift,
|
ShiftCard(
|
||||||
templates = templates,
|
shift = shift,
|
||||||
onClick = {
|
templates = templates,
|
||||||
editingShift = shift
|
currencySymbol = currency.symbol,
|
||||||
showAddDialog = true
|
onClick = {
|
||||||
},
|
editingShift = shift
|
||||||
onDelete = { viewModel.deleteShift(shift) }
|
showAddDialog = true
|
||||||
)
|
},
|
||||||
|
onDelete = { shiftToDelete = shift }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,6 +171,7 @@ fun CalendarScreen(viewModel: ShiftViewModel = hiltViewModel()) {
|
|||||||
selectedDate = selectedDate,
|
selectedDate = selectedDate,
|
||||||
templates = templates,
|
templates = templates,
|
||||||
existingShift = editingShift,
|
existingShift = editingShift,
|
||||||
|
currencySymbol = currency.symbol,
|
||||||
onDismiss = { showAddDialog = false },
|
onDismiss = { showAddDialog = false },
|
||||||
onSave = { shift ->
|
onSave = { shift ->
|
||||||
if (editingShift != null) viewModel.updateShift(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 ───────────────────
|
// ─────────────────── Calendar Grid ───────────────────
|
||||||
@@ -277,14 +322,15 @@ private fun DayCell(
|
|||||||
private fun ShiftCard(
|
private fun ShiftCard(
|
||||||
shift: Shift,
|
shift: Shift,
|
||||||
templates: List<ShiftTemplate>,
|
templates: List<ShiftTemplate>,
|
||||||
|
currencySymbol: String,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onDelete: () -> Unit
|
onDelete: () -> 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) {
|
||||||
ShiftType.DAY -> "Day"
|
ShiftType.DAY -> stringResource(R.string.type_day)
|
||||||
ShiftType.NIGHT -> "Night"
|
ShiftType.NIGHT -> stringResource(R.string.type_night)
|
||||||
ShiftType.WEEKEND -> "Weekend"
|
ShiftType.WEEKEND -> stringResource(R.string.type_weekend)
|
||||||
}
|
}
|
||||||
val typeColor = when (shift.type) {
|
val typeColor = when (shift.type) {
|
||||||
ShiftType.DAY -> Color(0xFFFFC107)
|
ShiftType.DAY -> Color(0xFFFFC107)
|
||||||
@@ -321,20 +367,31 @@ private fun ShiftCard(
|
|||||||
}
|
}
|
||||||
Column(horizontalAlignment = Alignment.End) {
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
val earnings = if (template != null) {
|
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(
|
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 {
|
} else {
|
||||||
shift.hourlyRate * shift.hoursWorked
|
shift.hourlyRate * shift.hoursWorked
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "$${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 = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
if (shift.isCompleted) {
|
if (shift.isCompleted) {
|
||||||
Text(
|
Text(
|
||||||
text = "Completed",
|
text = stringResource(R.string.completed),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = Color(0xFF4CAF50)
|
color = Color(0xFF4CAF50)
|
||||||
)
|
)
|
||||||
@@ -355,6 +412,7 @@ private fun AddShiftDialog(
|
|||||||
selectedDate: LocalDate,
|
selectedDate: LocalDate,
|
||||||
templates: List<ShiftTemplate>,
|
templates: List<ShiftTemplate>,
|
||||||
existingShift: Shift?,
|
existingShift: Shift?,
|
||||||
|
currencySymbol: String,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSave: (Shift) -> Unit
|
onSave: (Shift) -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -384,47 +442,102 @@ private fun AddShiftDialog(
|
|||||||
|
|
||||||
val selectedTemplate = templates.find { it.id == selectedTemplateId }
|
val selectedTemplate = templates.find { it.id == selectedTemplateId }
|
||||||
|
|
||||||
// Auto-fill rate from template
|
|
||||||
LaunchedEffect(selectedTemplate) {
|
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(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
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 = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
|
||||||
// Template picker
|
// Template picker
|
||||||
if (templates.isNotEmpty()) {
|
if (templates.isNotEmpty()) {
|
||||||
Text("Template", style = MaterialTheme.typography.labelMedium)
|
Text(
|
||||||
Row(
|
stringResource(R.string.select_shift_template),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
style = MaterialTheme.typography.titleSmall,
|
||||||
modifier = Modifier.fillMaxWidth()
|
color = MaterialTheme.colorScheme.primary
|
||||||
) {
|
)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
templates.forEach { tmpl ->
|
templates.forEach { tmpl ->
|
||||||
FilterChip(
|
val isSelected = selectedTemplateId == tmpl.id
|
||||||
selected = selectedTemplateId == tmpl.id,
|
Card(
|
||||||
onClick = {
|
onClick = {
|
||||||
selectedTemplateId =
|
selectedTemplateId =
|
||||||
if (selectedTemplateId == tmpl.id) null else tmpl.id
|
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(
|
OutlinedTextField(
|
||||||
value = title,
|
value = title,
|
||||||
onValueChange = { title = it },
|
onValueChange = { title = it },
|
||||||
label = { Text("Title (optional)") },
|
label = { Text(stringResource(R.string.title_optional)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true
|
singleLine = true
|
||||||
)
|
)
|
||||||
|
|
||||||
// Shift type
|
// Shift type
|
||||||
Text("Type", style = MaterialTheme.typography.labelMedium)
|
Text(stringResource(R.string.shift_type), style = MaterialTheme.typography.labelMedium)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
ShiftType.entries.forEach { type ->
|
ShiftType.entries.forEach { type ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
@@ -433,9 +546,9 @@ private fun AddShiftDialog(
|
|||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
when (type) {
|
when (type) {
|
||||||
ShiftType.DAY -> "Day"
|
ShiftType.DAY -> stringResource(R.string.type_day)
|
||||||
ShiftType.NIGHT -> "Night"
|
ShiftType.NIGHT -> stringResource(R.string.type_night)
|
||||||
ShiftType.WEEKEND -> "Weekend"
|
ShiftType.WEEKEND -> stringResource(R.string.type_weekend)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -447,14 +560,14 @@ private fun AddShiftDialog(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = hoursText,
|
value = hoursText,
|
||||||
onValueChange = { hoursText = it },
|
onValueChange = { hoursText = it },
|
||||||
label = { Text("Hours") },
|
label = { Text(stringResource(R.string.hours_label)) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
singleLine = true
|
singleLine = true
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = rateText,
|
value = rateText,
|
||||||
onValueChange = { rateText = it },
|
onValueChange = { rateText = it },
|
||||||
label = { Text("$/hour") },
|
label = { Text(stringResource(R.string.rate_per_hour)) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
singleLine = true
|
singleLine = true
|
||||||
)
|
)
|
||||||
@@ -463,33 +576,45 @@ private fun AddShiftDialog(
|
|||||||
// Start time button
|
// Start time button
|
||||||
OutlinedButton(onClick = { showTimePicker = true }) {
|
OutlinedButton(onClick = { showTimePicker = true }) {
|
||||||
Text(
|
Text(
|
||||||
"Start: ${
|
stringResource(R.string.start_time,
|
||||||
String.format(
|
String.format(
|
||||||
Locale.getDefault(),
|
Locale.getDefault(),
|
||||||
"%02d:%02d",
|
"%02d:%02d",
|
||||||
startTime.hour,
|
startTime.hour,
|
||||||
startTime.minute
|
startTime.minute
|
||||||
)
|
)
|
||||||
}"
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Checkbox(checked = isCompleted, onCheckedChange = { isCompleted = it })
|
Checkbox(checked = isCompleted, onCheckedChange = { isCompleted = it })
|
||||||
Text("Completed")
|
Text(stringResource(R.string.completed_checkbox))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Earnings preview
|
// Earnings preview
|
||||||
val hours = hoursText.toDoubleOrNull() ?: 0.0
|
val hours = hoursText.toDoubleOrNull() ?: 0.0
|
||||||
val rate = rateText.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) {
|
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 {
|
} else {
|
||||||
rate * hours
|
rate * hours
|
||||||
}
|
}
|
||||||
if (hours > 0 && rate > 0) {
|
if (hours > 0 && rate > 0) {
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
@@ -497,6 +622,9 @@ private fun AddShiftDialog(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
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 = {
|
Button(onClick = {
|
||||||
val hours = hoursText.toDoubleOrNull() ?: 0.0
|
val hours = hoursText.toDoubleOrNull() ?: 0.0
|
||||||
val rate = rateText.toDoubleOrNull() ?: 0.0
|
val rate = rateText.toDoubleOrNull() ?: 0.0
|
||||||
@@ -508,9 +636,9 @@ private fun AddShiftDialog(
|
|||||||
id = existingShift?.id ?: UUID.randomUUID().toString(),
|
id = existingShift?.id ?: UUID.randomUUID().toString(),
|
||||||
title = title.ifBlank {
|
title = title.ifBlank {
|
||||||
when (shiftType) {
|
when (shiftType) {
|
||||||
ShiftType.DAY -> "Day Shift"
|
ShiftType.DAY -> defaultDayTitle
|
||||||
ShiftType.NIGHT -> "Night Shift"
|
ShiftType.NIGHT -> defaultNightTitle
|
||||||
ShiftType.WEEKEND -> "Weekend Shift"
|
ShiftType.WEEKEND -> defaultWeekendTitle
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
startTime = Date.from(startDateTime.atZone(zone).toInstant()),
|
startTime = Date.from(startDateTime.atZone(zone).toInstant()),
|
||||||
@@ -522,9 +650,9 @@ private fun AddShiftDialog(
|
|||||||
templateId = selectedTemplateId
|
templateId = selectedTemplateId
|
||||||
)
|
)
|
||||||
onSave(shift)
|
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) {
|
if (showTimePicker) {
|
||||||
@@ -535,16 +663,16 @@ private fun AddShiftDialog(
|
|||||||
)
|
)
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showTimePicker = false },
|
onDismissRequest = { showTimePicker = false },
|
||||||
title = { Text("Shift start time") },
|
title = { Text(stringResource(R.string.shift_start_time)) },
|
||||||
text = { TimePicker(state = pickerState) },
|
text = { TimePicker(state = pickerState) },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
startTime = java.time.LocalTime.of(pickerState.hour, pickerState.minute)
|
startTime = java.time.LocalTime.of(pickerState.hour, pickerState.minute)
|
||||||
showTimePicker = false
|
showTimePicker = false
|
||||||
}) { Text("OK") }
|
}) { Text(stringResource(R.string.ok)) }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
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,
|
AppDatabase::class.java,
|
||||||
"shift_manager_database"
|
"shift_manager_database"
|
||||||
)
|
)
|
||||||
.addMigrations(AppDatabase.MIGRATION_1_2)
|
.addMigrations(AppDatabase.MIGRATION_1_2, AppDatabase.MIGRATION_2_3)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -47,7 +48,12 @@ fun MainScreen(
|
|||||||
incomeViewModel: IncomeViewModel,
|
incomeViewModel: IncomeViewModel,
|
||||||
settingsViewModel: SettingsViewModel
|
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) }
|
val (selectedItem, setSelectedItem) = remember { mutableStateOf(0) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -76,9 +82,9 @@ fun MainScreen(
|
|||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Column(modifier = Modifier.padding(paddingValues)) {
|
Column(modifier = Modifier.padding(paddingValues)) {
|
||||||
when (selectedItem) {
|
when (selectedItem) {
|
||||||
0 -> CalendarScreen(shiftViewModel)
|
0 -> CalendarScreen(shiftViewModel, settingsViewModel)
|
||||||
1 -> ShiftTemplatesScreen(shiftViewModel)
|
1 -> ShiftTemplatesScreen(shiftViewModel, settingsViewModel)
|
||||||
2 -> StatisticsScreen(incomeViewModel)
|
2 -> StatisticsScreen(incomeViewModel, shiftViewModel, settingsViewModel)
|
||||||
3 -> SettingsScreen(settingsViewModel)
|
3 -> SettingsScreen(settingsViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ package com.yaros.shiftmanager
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.*
|
import androidx.datastore.preferences.core.*
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
@@ -16,6 +20,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
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)
|
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) {
|
suspend fun setThemeMode(themeMode: ThemeMode) {
|
||||||
dataStore.edit { preferences ->
|
dataStore.edit { preferences ->
|
||||||
preferences[PreferencesKeys.THEME_MODE] = themeMode.name
|
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 {
|
private object PreferencesKeys {
|
||||||
val THEME_MODE = stringPreferencesKey("theme_mode")
|
val THEME_MODE = stringPreferencesKey("theme_mode")
|
||||||
val DISPLAY_MODE = stringPreferencesKey("display_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
|
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
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(private val repository: SettingsRepository) : ViewModel() {
|
class SettingsViewModel @Inject constructor(private val repository: SettingsRepository) : ViewModel() {
|
||||||
val themeMode: StateFlow<ThemeMode> = repository.themeFlow.stateIn(
|
val themeMode: StateFlow<ThemeMode> = repository.themeFlow.stateIn(
|
||||||
@@ -71,6 +109,18 @@ class SettingsViewModel @Inject constructor(private val repository: SettingsRepo
|
|||||||
DisplayMode.WEEKLY
|
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 {
|
fun setThemeMode(themeMode: ThemeMode) = viewModelScope.launch {
|
||||||
repository.setThemeMode(themeMode)
|
repository.setThemeMode(themeMode)
|
||||||
}
|
}
|
||||||
@@ -78,26 +128,44 @@ class SettingsViewModel @Inject constructor(private val repository: SettingsRepo
|
|||||||
fun setDisplayMode(displayMode: DisplayMode) = viewModelScope.launch {
|
fun setDisplayMode(displayMode: DisplayMode) = viewModelScope.launch {
|
||||||
repository.setDisplayMode(displayMode)
|
repository.setDisplayMode(displayMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setCurrency(currency: Currency) = viewModelScope.launch {
|
||||||
|
repository.setCurrency(currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTaxRate(taxRate: Float) = viewModelScope.launch {
|
||||||
|
repository.setTaxRate(taxRate)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(viewModel: SettingsViewModel) {
|
fun SettingsScreen(viewModel: SettingsViewModel) {
|
||||||
val themeMode by viewModel.themeMode.collectAsState()
|
val themeMode by viewModel.themeMode.collectAsState()
|
||||||
val displayMode by viewModel.displayMode.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)) {
|
Column(
|
||||||
Text("Settings", style = MaterialTheme.typography.titleLarge)
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.settings_title), style = MaterialTheme.typography.titleLarge)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
ThemeModeSelector(themeMode) { viewModel.setThemeMode(it) }
|
ThemeModeSelector(themeMode) { viewModel.setThemeMode(it) }
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
DisplayModeSelector(displayMode) { viewModel.setDisplayMode(it) }
|
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
|
@Composable
|
||||||
fun ThemeModeSelector(currentMode: ThemeMode, onModeSelected: (ThemeMode) -> Unit) {
|
fun ThemeModeSelector(currentMode: ThemeMode, onModeSelected: (ThemeMode) -> Unit) {
|
||||||
Column {
|
Column {
|
||||||
Text("Theme Mode", style = MaterialTheme.typography.titleMedium)
|
Text(stringResource(R.string.theme_mode), style = MaterialTheme.typography.titleMedium)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
ThemeMode.values().forEach { mode ->
|
ThemeMode.values().forEach { mode ->
|
||||||
Row(
|
Row(
|
||||||
@@ -106,7 +174,13 @@ fun ThemeModeSelector(currentMode: ThemeMode, onModeSelected: (ThemeMode) -> Uni
|
|||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
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(
|
RadioButton(
|
||||||
selected = mode == currentMode,
|
selected = mode == currentMode,
|
||||||
onClick = { onModeSelected(mode) }
|
onClick = { onModeSelected(mode) }
|
||||||
@@ -119,7 +193,7 @@ fun ThemeModeSelector(currentMode: ThemeMode, onModeSelected: (ThemeMode) -> Uni
|
|||||||
@Composable
|
@Composable
|
||||||
fun DisplayModeSelector(currentMode: DisplayMode, onModeSelected: (DisplayMode) -> Unit) {
|
fun DisplayModeSelector(currentMode: DisplayMode, onModeSelected: (DisplayMode) -> Unit) {
|
||||||
Column {
|
Column {
|
||||||
Text("Display Mode", style = MaterialTheme.typography.titleMedium)
|
Text(stringResource(R.string.display_mode), style = MaterialTheme.typography.titleMedium)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
DisplayMode.values().forEach { mode ->
|
DisplayMode.values().forEach { mode ->
|
||||||
Row(
|
Row(
|
||||||
@@ -128,7 +202,12 @@ fun DisplayModeSelector(currentMode: DisplayMode, onModeSelected: (DisplayMode)
|
|||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Text(mode.name)
|
Text(
|
||||||
|
when (mode) {
|
||||||
|
DisplayMode.WEEKLY -> stringResource(R.string.display_weekly)
|
||||||
|
DisplayMode.MONTHLY -> stringResource(R.string.display_monthly)
|
||||||
|
}
|
||||||
|
)
|
||||||
RadioButton(
|
RadioButton(
|
||||||
selected = mode == currentMode,
|
selected = mode == currentMode,
|
||||||
onClick = { onModeSelected(mode) }
|
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
|
@Composable
|
||||||
fun AppTheme(
|
fun AppTheme(
|
||||||
themeMode: ThemeMode,
|
themeMode: ThemeMode,
|
||||||
|
|||||||
@@ -52,7 +52,11 @@ data class ShiftTemplate(
|
|||||||
@PrimaryKey val id: String = UUID.randomUUID().toString(),
|
@PrimaryKey val id: String = UUID.randomUUID().toString(),
|
||||||
val name: String,
|
val name: String,
|
||||||
val hourlyRate: Double,
|
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(
|
data class CoefficientRule(
|
||||||
@@ -71,6 +75,9 @@ interface ShiftDao {
|
|||||||
@Query("SELECT * FROM shifts WHERE startTime >= :start AND startTime < :end ORDER BY startTime ASC")
|
@Query("SELECT * FROM shifts WHERE startTime >= :start AND startTime < :end ORDER BY startTime ASC")
|
||||||
fun getShiftsForMonth(start: Date, end: Date): Flow<List<Shift>>
|
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)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertShift(shift: Shift)
|
suspend fun insertShift(shift: Shift)
|
||||||
|
|
||||||
@@ -107,6 +114,10 @@ class ShiftRepository @Inject constructor(private val shiftDao: ShiftDao) {
|
|||||||
return shiftDao.getShiftsForMonth(start, end)
|
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 insertShift(shift: Shift) = shiftDao.insertShift(shift)
|
||||||
suspend fun updateShift(shift: Shift) = shiftDao.updateShift(shift)
|
suspend fun updateShift(shift: Shift) = shiftDao.updateShift(shift)
|
||||||
suspend fun deleteShift(shift: Shift) = shiftDao.deleteShift(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 setMonth(month: YearMonth) { _currentMonth.value = month }
|
||||||
fun nextMonth() { _currentMonth.value = _currentMonth.value.plusMonths(1) }
|
fun nextMonth() { _currentMonth.value = _currentMonth.value.plusMonths(1) }
|
||||||
fun previousMonth() { _currentMonth.value = _currentMonth.value.minusMonths(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 insertShift(shift: Shift) = viewModelScope.launch { repository.insertShift(shift) }
|
||||||
fun updateShift(shift: Shift) = viewModelScope.launch { repository.updateShift(shift) }
|
fun updateShift(shift: Shift) = viewModelScope.launch { repository.updateShift(shift) }
|
||||||
@@ -159,19 +175,58 @@ class ShiftViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun calculateEarnings(baseRate: Double, hoursWorked: Double, rules: List<CoefficientRule>): Double {
|
fun calculateEarnings(
|
||||||
if (rules.isEmpty()) return baseRate * hoursWorked
|
baseRate: Double,
|
||||||
val hoursByCoefficient = mutableListOf<Double>()
|
hoursWorked: Double,
|
||||||
for (i in 0 until hoursWorked.toInt().coerceAtMost(24)) {
|
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 ->
|
val coeff = rules.firstOrNull { rule ->
|
||||||
if (rule.startHour <= rule.endHour)
|
if (rule.startHour <= rule.endHour)
|
||||||
i >= rule.startHour && i < rule.endHour
|
i >= rule.startHour && i < rule.endHour
|
||||||
else
|
else
|
||||||
i >= rule.startHour || i < rule.endHour
|
i >= rule.startHour || i < rule.endHour
|
||||||
}?.coefficient ?: 1.0
|
}?.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.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.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
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
@@ -10,6 +12,7 @@ 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.res.stringResource
|
||||||
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.util.Locale
|
import java.util.Locale
|
||||||
@@ -18,11 +21,16 @@ import java.util.UUID
|
|||||||
// ─────────────────── Templates Screen ───────────────────
|
// ─────────────────── Templates Screen ───────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ShiftTemplatesScreen(viewModel: ShiftViewModel = hiltViewModel()) {
|
fun ShiftTemplatesScreen(
|
||||||
|
viewModel: ShiftViewModel = hiltViewModel(),
|
||||||
|
settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
val templates by viewModel.allTemplates.collectAsState()
|
val templates by viewModel.allTemplates.collectAsState()
|
||||||
|
val currency by settingsViewModel.currency.collectAsState()
|
||||||
|
|
||||||
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) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
@@ -30,18 +38,18 @@ fun ShiftTemplatesScreen(viewModel: ShiftViewModel = hiltViewModel()) {
|
|||||||
editingTemplate = null
|
editingTemplate = null
|
||||||
showDialog = true
|
showDialog = true
|
||||||
}) {
|
}) {
|
||||||
Icon(Icons.Default.Add, contentDescription = "Add Template")
|
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_template))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Column(modifier = Modifier.padding(paddingValues).fillMaxSize()) {
|
Column(modifier = Modifier.padding(paddingValues).fillMaxSize()) {
|
||||||
Text(
|
Text(
|
||||||
text = "Shift Templates",
|
text = stringResource(R.string.shift_templates_title),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
modifier = Modifier.padding(16.dp)
|
modifier = Modifier.padding(16.dp)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Create templates with hourly rates and night/weekend coefficients",
|
text = stringResource(R.string.templates_subtitle),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
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) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(
|
Text(
|
||||||
"No templates yet",
|
stringResource(R.string.no_templates_yet),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
"Tap + to create your first shift template",
|
stringResource(R.string.create_first_template),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -66,14 +74,17 @@ fun ShiftTemplatesScreen(viewModel: ShiftViewModel = hiltViewModel()) {
|
|||||||
} else {
|
} else {
|
||||||
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||||
items(templates) { template ->
|
items(templates) { template ->
|
||||||
TemplateCard(
|
SwipeToDeleteContainer(onDelete = { templateToDelete = template }) {
|
||||||
template = template,
|
TemplateCard(
|
||||||
onEdit = {
|
template = template,
|
||||||
editingTemplate = template
|
currencySymbol = currency.symbol,
|
||||||
showDialog = true
|
onEdit = {
|
||||||
},
|
editingTemplate = template
|
||||||
onDelete = { viewModel.deleteTemplate(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 ───────────────────
|
// ─────────────────── Template Card ───────────────────
|
||||||
@@ -98,6 +131,7 @@ fun ShiftTemplatesScreen(viewModel: ShiftViewModel = hiltViewModel()) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun TemplateCard(
|
private fun TemplateCard(
|
||||||
template: ShiftTemplate,
|
template: ShiftTemplate,
|
||||||
|
currencySymbol: String,
|
||||||
onEdit: () -> Unit,
|
onEdit: () -> Unit,
|
||||||
onDelete: () -> Unit
|
onDelete: () -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -120,13 +154,34 @@ private fun TemplateCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
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
|
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()) {
|
if (template.coefficientRules.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(8.dp))
|
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 ->
|
template.coefficientRules.forEach { rule ->
|
||||||
val percent = ((rule.coefficient - 1.0) * 100).toInt()
|
val percent = ((rule.coefficient - 1.0) * 100).toInt()
|
||||||
val sign = if (percent > 0) "+" else ""
|
val sign = if (percent > 0) "+" else ""
|
||||||
@@ -162,6 +217,30 @@ private fun TemplateDialog(
|
|||||||
it.addAll(existingTemplate?.coefficientRules ?: emptyList())
|
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
|
// New rule fields
|
||||||
var newStartHour by remember { mutableStateOf("") }
|
var newStartHour by remember { mutableStateOf("") }
|
||||||
@@ -170,28 +249,74 @@ private fun TemplateDialog(
|
|||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
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 = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
label = { Text("Template name (e.g. Day Shift, Night Shift)") },
|
label = { Text(stringResource(R.string.template_name_hint)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true
|
singleLine = true
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = rateText,
|
value = rateText,
|
||||||
onValueChange = { rateText = it },
|
onValueChange = { rateText = it },
|
||||||
label = { Text("Base hourly rate ($)") },
|
label = { Text(stringResource(R.string.base_hourly_rate)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true
|
singleLine = true
|
||||||
)
|
)
|
||||||
|
|
||||||
Divider()
|
HorizontalDivider()
|
||||||
Text("Coefficient rules", style = MaterialTheme.typography.labelLarge)
|
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(
|
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,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -217,7 +342,7 @@ private fun TemplateDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add new rule row
|
// Add new rule row
|
||||||
Text("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),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
@@ -225,7 +350,7 @@ private fun TemplateDialog(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = newStartHour,
|
value = newStartHour,
|
||||||
onValueChange = { newStartHour = it.filter { c -> c.isDigit() }.take(2) },
|
onValueChange = { newStartHour = it.filter { c -> c.isDigit() }.take(2) },
|
||||||
label = { Text("From") },
|
label = { Text(stringResource(R.string.from_label)) },
|
||||||
placeholder = { Text("00") },
|
placeholder = { Text("00") },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
singleLine = true
|
singleLine = true
|
||||||
@@ -234,7 +359,7 @@ private fun TemplateDialog(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = newEndHour,
|
value = newEndHour,
|
||||||
onValueChange = { newEndHour = it.filter { c -> c.isDigit() }.take(2) },
|
onValueChange = { newEndHour = it.filter { c -> c.isDigit() }.take(2) },
|
||||||
label = { Text("To") },
|
label = { Text(stringResource(R.string.to_label)) },
|
||||||
placeholder = { Text("06") },
|
placeholder = { Text("06") },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
singleLine = true
|
singleLine = true
|
||||||
@@ -267,19 +392,28 @@ private fun TemplateDialog(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
|
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 overtimeCoeff = overtimeCoeffText.toDoubleOrNull() ?: 1.5
|
||||||
|
val overtimeThreshold = overtimeThresholdText.toDoubleOrNull() ?: 8.0
|
||||||
|
val bonus = bonusText.toDoubleOrNull() ?: 0.0
|
||||||
val template = ShiftTemplate(
|
val template = ShiftTemplate(
|
||||||
id = existingTemplate?.id ?: UUID.randomUUID().toString(),
|
id = existingTemplate?.id ?: UUID.randomUUID().toString(),
|
||||||
name = name.ifBlank { "Untitled Template" },
|
name = name.ifBlank { untitledName },
|
||||||
hourlyRate = rate,
|
hourlyRate = rate,
|
||||||
coefficientRules = rules.toList()
|
coefficientRules = rules.toList(),
|
||||||
|
weekendCoefficient = weekendCoeff,
|
||||||
|
overtimeCoefficient = overtimeCoeff,
|
||||||
|
overtimeThreshold = overtimeThreshold,
|
||||||
|
bonusPerShift = bonus
|
||||||
)
|
)
|
||||||
onSave(template)
|
onSave(template)
|
||||||
}) { Text("Save") }
|
}) { Text(stringResource(R.string.save)) }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,82 +1,327 @@
|
|||||||
package com.yaros.shiftmanager
|
package com.yaros.shiftmanager
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.ui.Alignment
|
||||||
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.ui.Modifier
|
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 androidx.compose.ui.unit.dp
|
||||||
import java.util.Calendar
|
import kotlinx.coroutines.awaitCancellation
|
||||||
import java.util.Date
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Locale
|
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
|
@Composable
|
||||||
fun StatisticsScreen(incomeViewModel: IncomeViewModel) {
|
fun StatisticsScreen(
|
||||||
|
incomeViewModel: IncomeViewModel,
|
||||||
|
shiftViewModel: ShiftViewModel,
|
||||||
|
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 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) {
|
LaunchedEffect(selectedPeriod) {
|
||||||
val (startDate, endDate) = getDateRangeForPeriod(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 }
|
PeriodSelector(selectedPeriod) { selectedPeriod = it }
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
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))
|
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
|
@Composable
|
||||||
fun PeriodSelector(selectedPeriod: StatisticsPeriod, onPeriodSelected: (StatisticsPeriod) -> Unit) {
|
private fun EarningsCard(
|
||||||
Row {
|
title: String,
|
||||||
StatisticsPeriod.entries.forEach { period ->
|
amount: Double,
|
||||||
Button(
|
currencySymbol: String,
|
||||||
onClick = { onPeriodSelected(period) },
|
description: String,
|
||||||
modifier = Modifier.padding(4.dp),
|
color: Color
|
||||||
colors = ButtonDefaults.buttonColors(
|
) {
|
||||||
containerColor = if (period == selectedPeriod) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary
|
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
|
@Composable
|
||||||
fun IncomeChart(incomes: List<Income>) {
|
fun IncomeBreakdown(incomes: List<Income>, currencySymbol: String) {
|
||||||
// Placeholder for chart - Vico chart implementation
|
|
||||||
Text("Income Chart: ${incomes.size} entries")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun IncomeBreakdown(incomes: List<Income>) {
|
|
||||||
val totalIncome = incomes.sumOf { it.amount }
|
val totalIncome = incomes.sumOf { it.amount }
|
||||||
val regularIncome = incomes.filter { it.type == IncomeType.REGULAR }.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 tipsIncome = incomes.filter { it.type == IncomeType.TIPS }.sumOf { it.amount }
|
||||||
val bonusIncome = incomes.filter { it.type == IncomeType.BONUS }.sumOf { it.amount }
|
val bonusIncome = incomes.filter { it.type == IncomeType.BONUS }.sumOf { it.amount }
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
Text("Total Income: $${String.format(Locale.US, "%.2f", totalIncome)}", style = MaterialTheme.typography.titleLarge)
|
BreakdownRow(stringResource(R.string.total), totalIncome, currencySymbol, MaterialTheme.typography.titleMedium)
|
||||||
Text("Regular Income: $${String.format(Locale.US, "%.2f", regularIncome)}", style = MaterialTheme.typography.bodyLarge)
|
BreakdownRow(stringResource(R.string.regular), regularIncome, currencySymbol, MaterialTheme.typography.bodyMedium)
|
||||||
Text("Tips: $${String.format(Locale.US, "%.2f", tipsIncome)}", style = MaterialTheme.typography.bodyLarge)
|
BreakdownRow(stringResource(R.string.tips), tipsIncome, currencySymbol, MaterialTheme.typography.bodyMedium)
|
||||||
Text("Bonuses: $${String.format(Locale.US, "%.2f", bonusIncome)}", style = MaterialTheme.typography.bodyLarge)
|
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
|
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> {
|
fun getDateRangeForPeriod(period: StatisticsPeriod): Pair<Date, Date> {
|
||||||
val calendar = Calendar.getInstance()
|
val calendar = Calendar.getInstance()
|
||||||
val endDate = calendar.time
|
val endDate = calendar.time
|
||||||
@@ -94,4 +420,4 @@ fun getDateRangeForPeriod(period: StatisticsPeriod): Pair<Date, Date> {
|
|||||||
}, -1)
|
}, -1)
|
||||||
val startDate = calendar.time
|
val startDate = calendar.time
|
||||||
return Pair(startDate, endDate)
|
return Pair(startDate, endDate)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.*
|
import androidx.compose.ui.*
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.*
|
import androidx.lifecycle.*
|
||||||
import androidx.room.*
|
import androidx.room.*
|
||||||
@@ -91,7 +92,7 @@ fun TaxReminderScreen(viewModel: TaxReminderViewModel) {
|
|||||||
val taxReminders by viewModel.allTaxReminders.collectAsState(initial = emptyList())
|
val taxReminders by viewModel.allTaxReminders.collectAsState(initial = emptyList())
|
||||||
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
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))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
items(taxReminders) { reminder ->
|
items(taxReminders) { reminder ->
|
||||||
@@ -120,7 +121,7 @@ fun TaxReminderItem(reminder: TaxReminder, viewModel: TaxReminderViewModel) {
|
|||||||
Column {
|
Column {
|
||||||
Text(reminder.description, style = MaterialTheme.typography.bodyLarge)
|
Text(reminder.description, style = MaterialTheme.typography.bodyLarge)
|
||||||
Text(
|
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
|
style = MaterialTheme.typography.bodyMedium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -139,7 +140,7 @@ fun AddTaxReminderButton(viewModel: TaxReminderViewModel) {
|
|||||||
var showDialog by remember { mutableStateOf(false) }
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Button(onClick = { showDialog = true }) {
|
Button(onClick = { showDialog = true }) {
|
||||||
Text("Add Tax Reminder")
|
Text(stringResource(R.string.add_tax_reminder))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showDialog) {
|
if (showDialog) {
|
||||||
@@ -160,13 +161,13 @@ fun AddTaxReminderDialog(onDismiss: () -> Unit, onAdd: (String, Date) -> Unit) {
|
|||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("Add Tax Reminder") },
|
title = { Text(stringResource(R.string.add_tax_reminder)) },
|
||||||
text = {
|
text = {
|
||||||
Column {
|
Column {
|
||||||
TextField(
|
TextField(
|
||||||
value = description,
|
value = description,
|
||||||
onValueChange = { description = it },
|
onValueChange = { description = it },
|
||||||
label = { Text("Description") }
|
label = { Text(stringResource(R.string.description_label)) }
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
// Add a DatePicker here for selecting dueDate
|
// Add a DatePicker here for selecting dueDate
|
||||||
@@ -174,12 +175,12 @@ fun AddTaxReminderDialog(onDismiss: () -> Unit, onAdd: (String, Date) -> Unit) {
|
|||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
Button(onClick = { onAdd(description, dueDate) }) {
|
Button(onClick = { onAdd(description, dueDate) }) {
|
||||||
Text("Add")
|
Text(stringResource(R.string.add))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
Button(onClick = onDismiss) {
|
Button(onClick = onDismiss) {
|
||||||
Text("Cancel")
|
Text(stringResource(R.string.cancel))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,166 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">ShiftManager</string>
|
<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>
|
</resources>
|
||||||
Reference in New Issue
Block a user