diff --git a/app/src/main/java/com/yaros/shiftmanager/AppDatabase.kt b/app/src/main/java/com/yaros/shiftmanager/AppDatabase.kt index f82b8d8..ceb5e27 100644 --- a/app/src/main/java/com/yaros/shiftmanager/AppDatabase.kt +++ b/app/src/main/java/com/yaros/shiftmanager/AppDatabase.kt @@ -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") } } } diff --git a/app/src/main/java/com/yaros/shiftmanager/AuthManager.kt b/app/src/main/java/com/yaros/shiftmanager/AuthManager.kt index 3a243a7..170a914 100644 --- a/app/src/main/java/com/yaros/shiftmanager/AuthManager.kt +++ b/app/src/main/java/com/yaros/shiftmanager/AuthManager.kt @@ -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)) } } } \ No newline at end of file diff --git a/app/src/main/java/com/yaros/shiftmanager/CalendarScreen.kt b/app/src/main/java/com/yaros/shiftmanager/CalendarScreen.kt index 23169ca..b823ac4 100644 --- a/app/src/main/java/com/yaros/shiftmanager/CalendarScreen.kt +++ b/app/src/main/java/com/yaros/shiftmanager/CalendarScreen.kt @@ -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(null) } + var shiftToDelete by remember { mutableStateOf(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, + 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, 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)) } } ) } diff --git a/app/src/main/java/com/yaros/shiftmanager/DatabaseModule.kt b/app/src/main/java/com/yaros/shiftmanager/DatabaseModule.kt index 9f073e2..58a9fd5 100644 --- a/app/src/main/java/com/yaros/shiftmanager/DatabaseModule.kt +++ b/app/src/main/java/com/yaros/shiftmanager/DatabaseModule.kt @@ -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() } diff --git a/app/src/main/java/com/yaros/shiftmanager/MainActivity.kt b/app/src/main/java/com/yaros/shiftmanager/MainActivity.kt index 4cc3631..bfb7b2a 100644 --- a/app/src/main/java/com/yaros/shiftmanager/MainActivity.kt +++ b/app/src/main/java/com/yaros/shiftmanager/MainActivity.kt @@ -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) } } diff --git a/app/src/main/java/com/yaros/shiftmanager/SettingsMenu.kt b/app/src/main/java/com/yaros/shiftmanager/SettingsMenu.kt index b128c8c..dda4c80 100644 --- a/app/src/main/java/com/yaros/shiftmanager/SettingsMenu.kt +++ b/app/src/main/java/com/yaros/shiftmanager/SettingsMenu.kt @@ -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 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 = dataStore.data.map { preferences -> + Currency.valueOf(preferences[PreferencesKeys.CURRENCY] ?: Currency.USD.name) + } + + val taxRateFlow: Flow = 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 = repository.themeFlow.stateIn( @@ -71,6 +109,18 @@ class SettingsViewModel @Inject constructor(private val repository: SettingsRepo DisplayMode.WEEKLY ) + val currency: StateFlow = repository.currencyFlow.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + Currency.USD + ) + + val taxRate: StateFlow = 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, diff --git a/app/src/main/java/com/yaros/shiftmanager/ShiftHelper.kt b/app/src/main/java/com/yaros/shiftmanager/ShiftHelper.kt index 4b816c4..93fc8b3 100644 --- a/app/src/main/java/com/yaros/shiftmanager/ShiftHelper.kt +++ b/app/src/main/java/com/yaros/shiftmanager/ShiftHelper.kt @@ -52,7 +52,11 @@ data class ShiftTemplate( @PrimaryKey val id: String = UUID.randomUUID().toString(), val name: String, val hourlyRate: Double, - val coefficientRules: List = emptyList() + val coefficientRules: List = 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> + @Query("SELECT * FROM shifts WHERE startTime >= :start AND startTime < :end ORDER BY startTime ASC") + fun getShiftsForDateRange(start: Date, end: Date): Flow> + @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> { + 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> { + 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): Double { - if (rules.isEmpty()) return baseRate * hoursWorked - val hoursByCoefficient = mutableListOf() - for (i in 0 until hoursWorked.toInt().coerceAtMost(24)) { + fun calculateEarnings( + baseRate: Double, + hoursWorked: Double, + rules: List, + 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 } } } \ No newline at end of file diff --git a/app/src/main/java/com/yaros/shiftmanager/ShiftTemplatesScreen.kt b/app/src/main/java/com/yaros/shiftmanager/ShiftTemplatesScreen.kt index 4bd9b4f..80b655a 100644 --- a/app/src/main/java/com/yaros/shiftmanager/ShiftTemplatesScreen.kt +++ b/app/src/main/java/com/yaros/shiftmanager/ShiftTemplatesScreen.kt @@ -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(null) } + var templateToDelete by remember { mutableStateOf(null) } Scaffold( floatingActionButton = { @@ -30,18 +38,18 @@ fun ShiftTemplatesScreen(viewModel: ShiftViewModel = hiltViewModel()) { editingTemplate = null showDialog = true }) { - Icon(Icons.Default.Add, contentDescription = "Add Template") + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_template)) } } ) { paddingValues -> Column(modifier = Modifier.padding(paddingValues).fillMaxSize()) { Text( - text = "Shift Templates", + text = stringResource(R.string.shift_templates_title), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(16.dp) ) Text( - text = "Create templates with hourly rates and night/weekend coefficients", + text = stringResource(R.string.templates_subtitle), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) @@ -51,13 +59,13 @@ fun ShiftTemplatesScreen(viewModel: ShiftViewModel = hiltViewModel()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - "No templates yet", + stringResource(R.string.no_templates_yet), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(Modifier.height(8.dp)) Text( - "Tap + to create your first shift template", + stringResource(R.string.create_first_template), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -66,14 +74,17 @@ fun ShiftTemplatesScreen(viewModel: ShiftViewModel = hiltViewModel()) { } else { LazyColumn(modifier = Modifier.fillMaxWidth()) { items(templates) { template -> - TemplateCard( - template = template, - onEdit = { - editingTemplate = template - showDialog = true - }, - onDelete = { viewModel.deleteTemplate(template) } - ) + SwipeToDeleteContainer(onDelete = { templateToDelete = template }) { + TemplateCard( + template = template, + currencySymbol = currency.symbol, + onEdit = { + editingTemplate = template + showDialog = true + }, + onDelete = { templateToDelete = template } + ) + } } } } @@ -91,6 +102,28 @@ fun ShiftTemplatesScreen(viewModel: ShiftViewModel = hiltViewModel()) { } ) } + + if (templateToDelete != null) { + AlertDialog( + onDismissRequest = { templateToDelete = null }, + title = { Text(stringResource(R.string.delete_confirm_title)) }, + text = { Text(stringResource(R.string.delete_template_message)) }, + confirmButton = { + Button( + onClick = { + viewModel.deleteTemplate(templateToDelete!!) + templateToDelete = null + }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + ) { Text(stringResource(R.string.delete)) } + }, + dismissButton = { + TextButton(onClick = { templateToDelete = null }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } } // ─────────────────── Template Card ─────────────────── @@ -98,6 +131,7 @@ fun ShiftTemplatesScreen(viewModel: ShiftViewModel = hiltViewModel()) { @Composable private fun TemplateCard( template: ShiftTemplate, + currencySymbol: String, onEdit: () -> Unit, onDelete: () -> Unit ) { @@ -120,13 +154,34 @@ private fun TemplateCard( } Text( - text = "Base rate: $${String.format(Locale.getDefault(), "%.2f", template.hourlyRate)}/hr", + text = stringResource(R.string.base_rate, currencySymbol, String.format(Locale.getDefault(), "%.2f", template.hourlyRate)), style = MaterialTheme.typography.bodyMedium ) + if (template.weekendCoefficient != 1.0) { + Text( + text = stringResource(R.string.weekend_coeff_short, String.format(Locale.getDefault(), "%.1f", template.weekendCoefficient)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary + ) + } + if (template.overtimeThreshold > 0 && template.overtimeCoefficient != 1.0) { + Text( + text = stringResource(R.string.overtime_label, String.format(Locale.getDefault(), "%.0f", template.overtimeThreshold), String.format(Locale.getDefault(), "%.1f", template.overtimeCoefficient)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary + ) + } + if (template.bonusPerShift > 0) { + Text( + text = stringResource(R.string.bonus_per_shift, currencySymbol, String.format(Locale.getDefault(), "%.2f", template.bonusPerShift)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } if (template.coefficientRules.isNotEmpty()) { Spacer(Modifier.height(8.dp)) - Text("Coefficient rules:", style = MaterialTheme.typography.labelMedium) + Text(stringResource(R.string.coefficient_rules), style = MaterialTheme.typography.labelMedium) template.coefficientRules.forEach { rule -> val percent = ((rule.coefficient - 1.0) * 100).toInt() val sign = if (percent > 0) "+" else "" @@ -162,6 +217,30 @@ private fun TemplateDialog( it.addAll(existingTemplate?.coefficientRules ?: emptyList()) } } + var weekendCoeffText by remember { + mutableStateOf( + if (existingTemplate != null && existingTemplate.weekendCoefficient != 1.0) + existingTemplate.weekendCoefficient.toString() else "" + ) + } + var overtimeCoeffText by remember { + mutableStateOf( + if (existingTemplate != null && existingTemplate.overtimeCoefficient != 1.5) + existingTemplate.overtimeCoefficient.toString() else "" + ) + } + var overtimeThresholdText by remember { + mutableStateOf( + if (existingTemplate != null && existingTemplate.overtimeThreshold != 8.0) + existingTemplate.overtimeThreshold.toString() else "" + ) + } + var bonusText by remember { + mutableStateOf( + if (existingTemplate != null && existingTemplate.bonusPerShift > 0) + existingTemplate.bonusPerShift.toString() else "" + ) + } // New rule fields var newStartHour by remember { mutableStateOf("") } @@ -170,28 +249,74 @@ private fun TemplateDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text(if (existingTemplate != null) "Edit Template" else "New Template") }, + title = { Text(if (existingTemplate != null) stringResource(R.string.edit_template) else stringResource(R.string.new_template)) }, text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text("Template name (e.g. Day Shift, Night Shift)") }, + label = { Text(stringResource(R.string.template_name_hint)) }, modifier = Modifier.fillMaxWidth(), singleLine = true ) OutlinedTextField( value = rateText, onValueChange = { rateText = it }, - label = { Text("Base hourly rate ($)") }, + label = { Text(stringResource(R.string.base_hourly_rate)) }, modifier = Modifier.fillMaxWidth(), singleLine = true ) - Divider() - Text("Coefficient rules", style = MaterialTheme.typography.labelLarge) + HorizontalDivider() + Text(stringResource(R.string.additional_rules), style = MaterialTheme.typography.labelLarge) + + OutlinedTextField( + value = weekendCoeffText, + onValueChange = { weekendCoeffText = it }, + label = { Text(stringResource(R.string.weekend_coefficient)) }, + placeholder = { Text(stringResource(R.string.weekend_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = overtimeThresholdText, + onValueChange = { overtimeThresholdText = it }, + label = { Text(stringResource(R.string.overtime_after)) }, + placeholder = { Text("8") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + OutlinedTextField( + value = overtimeCoeffText, + onValueChange = { overtimeCoeffText = it }, + label = { Text(stringResource(R.string.overtime_multiplier)) }, + placeholder = { Text("1.5") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + } + + OutlinedTextField( + value = bonusText, + onValueChange = { bonusText = it }, + label = { Text(stringResource(R.string.bonus_per_shift_label)) }, + placeholder = { Text("0") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + HorizontalDivider() + Text(stringResource(R.string.time_based_rules), style = MaterialTheme.typography.labelLarge) Text( - "Example: 00:00–06:00 → 1.4 means +40% for hours between midnight and 6 AM", + stringResource(R.string.time_rule_example), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -217,7 +342,7 @@ private fun TemplateDialog( } // Add new rule row - Text("Add rule:", style = MaterialTheme.typography.labelSmall) + Text(stringResource(R.string.add_rule), style = MaterialTheme.typography.labelSmall) Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically @@ -225,7 +350,7 @@ private fun TemplateDialog( OutlinedTextField( value = newStartHour, onValueChange = { newStartHour = it.filter { c -> c.isDigit() }.take(2) }, - label = { Text("From") }, + label = { Text(stringResource(R.string.from_label)) }, placeholder = { Text("00") }, modifier = Modifier.weight(1f), singleLine = true @@ -234,7 +359,7 @@ private fun TemplateDialog( OutlinedTextField( value = newEndHour, onValueChange = { newEndHour = it.filter { c -> c.isDigit() }.take(2) }, - label = { Text("To") }, + label = { Text(stringResource(R.string.to_label)) }, placeholder = { Text("06") }, modifier = Modifier.weight(1f), singleLine = true @@ -267,19 +392,28 @@ private fun TemplateDialog( } }, confirmButton = { + val untitledName = stringResource(R.string.untitled_template) Button(onClick = { val rate = rateText.toDoubleOrNull() ?: return@Button + val weekendCoeff = weekendCoeffText.toDoubleOrNull() ?: 1.0 + val overtimeCoeff = overtimeCoeffText.toDoubleOrNull() ?: 1.5 + val overtimeThreshold = overtimeThresholdText.toDoubleOrNull() ?: 8.0 + val bonus = bonusText.toDoubleOrNull() ?: 0.0 val template = ShiftTemplate( id = existingTemplate?.id ?: UUID.randomUUID().toString(), - name = name.ifBlank { "Untitled Template" }, + name = name.ifBlank { untitledName }, hourlyRate = rate, - coefficientRules = rules.toList() + coefficientRules = rules.toList(), + weekendCoefficient = weekendCoeff, + overtimeCoefficient = overtimeCoeff, + overtimeThreshold = overtimeThreshold, + bonusPerShift = bonus ) onSave(template) - }) { Text("Save") } + }) { Text(stringResource(R.string.save)) } }, dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } } ) } diff --git a/app/src/main/java/com/yaros/shiftmanager/Staticanager.kt b/app/src/main/java/com/yaros/shiftmanager/Staticanager.kt index 3a05c40..a3bca04 100644 --- a/app/src/main/java/com/yaros/shiftmanager/Staticanager.kt +++ b/app/src/main/java/com/yaros/shiftmanager/Staticanager.kt @@ -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>(emptyList()) } + var shifts by remember { mutableStateOf>(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> { 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) { - // Placeholder for chart - Vico chart implementation - Text("Income Chart: ${incomes.size} entries") -} - -@Composable -fun IncomeBreakdown(incomes: List) { +fun IncomeBreakdown(incomes: List, 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, 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, + templates: List, + period: StatisticsPeriod +): List { + 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): 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 { val calendar = Calendar.getInstance() val endDate = calendar.time @@ -94,4 +420,4 @@ fun getDateRangeForPeriod(period: StatisticsPeriod): Pair { }, -1) val startDate = calendar.time return Pair(startDate, endDate) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/yaros/shiftmanager/TaxReminder.kt b/app/src/main/java/com/yaros/shiftmanager/TaxReminder.kt index 9ce52bb..9f46dbd 100644 --- a/app/src/main/java/com/yaros/shiftmanager/TaxReminder.kt +++ b/app/src/main/java/com/yaros/shiftmanager/TaxReminder.kt @@ -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)) } } ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 88bbe47..8f06de0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,166 @@ ShiftManager + + + Shifts + Templates + Income + Settings + + + Add Shift + Previous month + Next month + Shifts on %1$d %2$s + No shifts — tap + to add one + + + Day + Night + Weekend + Completed + + + Edit Shift + Add Shift + Select Shift Template + No templates — create one in the Templates tab + Title (optional) + Type + Hours + Rate/hour + Start: %1$s + Completed + Earnings: %1$s%2$s + Save + Cancel + Shift start time + OK + +%1$s%2$s bonus + %1$s%2$s/hr + + + Day Shift + Night Shift + Weekend Shift + + + Shift Templates + Create templates with hourly rates and night/weekend coefficients + No templates yet + Tap + to create your first shift template + Add Template + Base rate: %1$s%2$s/hr + Weekend: \u00D7%1$s + Overtime (>%1$sh): \u00D7%2$s + Bonus/shift: %1$s%2$s + Coefficient rules: + + + Edit Template + New Template + Template name (e.g. Day Shift, Night Shift) + Base hourly rate ($) + Additional Rules + Weekend coefficient + e.g. 1.5 = +50% + Overtime after (hrs) + Overtime \u00D7 + Bonus per shift ($) + Time-based coefficient rules + Example: 00:00\u201306:00 \u2192 1.4 means +40% for hours between midnight and 6 AM + Add rule: + From + To + Untitled Template + + + Settings + Theme Mode + Display Mode + Currency + Tax Rate + Tax rate (%) + This rate will be used to calculate your after-tax earnings + + + LIGHT + DARK + SYSTEM + + + WEEKLY + MONTHLY + + + Select Period + Earnings Overview + Estimated Earnings + Based on scheduled shifts & templates + Real Earnings + Actual recorded income + After Tax + After %1$s%% tax deduction + Tax amount: %1$s%2$s + Income Breakdown + Shifts Summary + + + Week + Month + Year + + + Total + Regular + Tips + Bonuses + + + Total shifts + Completed + Total hours + Avg hours/shift + %.1f hrs + + + Email + Password + Sign Up + Sign In + Already have an account? Sign In + Don\'t have an account? Sign Up + + + Tax Reminders + Add Tax Reminder + Due: %1$s + Description + Add + + + US Dollar + Euro + British Pound + Russian Ruble + Ukrainian Hryvnia + Kazakhstani Tenge + Japanese Yen + Chinese Yuan + + + Today + + + Delete? + Delete this shift? + Delete this template? + Delete + + + Swipe to delete + + + Earnings Chart + No data for chart \ No newline at end of file