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