This commit is contained in:
2026-06-05 00:15:02 +03:00
parent 1d8dbe1a6b
commit 2e5c9b7123
11 changed files with 1139 additions and 181 deletions
@@ -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(
text = currentMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault()) + text = currentMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault()) +
" ${currentMonth.year}", " ${currentMonth.year}",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold 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)
}
}
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,39 +135,43 @@ 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 ->
SwipeToDeleteContainer(onDelete = { shiftToDelete = shift }) {
ShiftCard( ShiftCard(
shift = shift, shift = shift,
templates = templates, templates = templates,
currencySymbol = currency.symbol,
onClick = { onClick = {
editingShift = shift editingShift = shift
showAddDialog = true showAddDialog = true
}, },
onDelete = { viewModel.deleteShift(shift) } onDelete = { shiftToDelete = shift }
) )
} }
} }
} }
} }
}
if (showAddDialog) { if (showAddDialog) {
AddShiftDialog( AddShiftDialog(
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,19 +74,22 @@ fun ShiftTemplatesScreen(viewModel: ShiftViewModel = hiltViewModel()) {
} else { } else {
LazyColumn(modifier = Modifier.fillMaxWidth()) { LazyColumn(modifier = Modifier.fillMaxWidth()) {
items(templates) { template -> items(templates) { template ->
SwipeToDeleteContainer(onDelete = { templateToDelete = template }) {
TemplateCard( TemplateCard(
template = template, template = template,
currencySymbol = currency.symbol,
onEdit = { onEdit = {
editingTemplate = template editingTemplate = template
showDialog = true showDialog = true
}, },
onDelete = { viewModel.deleteTemplate(template) } onDelete = { templateToDelete = template }
) )
} }
} }
} }
} }
} }
}
if (showDialog) { if (showDialog) {
TemplateDialog( TemplateDialog(
@@ -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:0006: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 }
} }
Column(modifier = Modifier.padding(16.dp)) { // Cleanup on recomposition
try {
awaitCancellation()
} finally {
incomeLiveData.removeObserver(incomeObserver)
shiftsJob.cancel()
}
}
// 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
private fun EarningsCard(
title: String,
amount: Double,
currencySymbol: String,
description: String,
color: Color
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.1f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Color strip
Box(
modifier = Modifier
.width(4.dp)
.height(48.dp)
.background(color, MaterialTheme.shapes.small)
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = color
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = "${currencySymbol}${String.format(Locale.getDefault(), "%.2f", amount)}",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = color
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PeriodSelector(selectedPeriod: StatisticsPeriod, onPeriodSelected: (StatisticsPeriod) -> Unit) { fun PeriodSelector(selectedPeriod: StatisticsPeriod, onPeriodSelected: (StatisticsPeriod) -> Unit) {
Row { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
StatisticsPeriod.entries.forEach { period -> StatisticsPeriod.entries.forEach { period ->
Button( FilterChip(
selected = period == selectedPeriod,
onClick = { onPeriodSelected(period) }, onClick = { onPeriodSelected(period) },
modifier = Modifier.padding(4.dp), label = {
colors = ButtonDefaults.buttonColors( Text(
containerColor = if (period == selectedPeriod) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary when (period) {
StatisticsPeriod.WEEK -> stringResource(R.string.period_week)
StatisticsPeriod.MONTH -> stringResource(R.string.period_month)
StatisticsPeriod.YEAR -> stringResource(R.string.period_year)
}
) )
) {
Text(period.name)
} }
)
} }
} }
} }
@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
@@ -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))
} }
} }
) )
+163
View File
@@ -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 (&gt;%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 &amp; 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>