commit 1d8dbe1a6b5f8b16e68b04256d7869b138ead4ad Author: Yaroslav Date: Thu Jun 4 18:19:46 2026 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/SweepConfig.xml b/.idea/SweepConfig.xml new file mode 100644 index 0000000..1b3a926 --- /dev/null +++ b/.idea/SweepConfig.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..4aee339 --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,54 @@ + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..006afa0 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..c4a282e --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,62 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..fdf8d99 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/other.xml b/.idea/other.xml new file mode 100644 index 0000000..49481ad --- /dev/null +++ b/.idea/other.xml @@ -0,0 +1,329 @@ + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..1f48444 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,99 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.google.gms.google.services) + id 'kotlin-kapt' + id 'dagger.hilt.android.plugin' + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = 'com.yaros.shiftmanager' + compileSdk 36 + + defaultConfig { + applicationId "com.yaros.shiftmanager" + minSdk 24 + targetSdk 34 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + coreLibraryDesugaringEnabled = true + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + buildFeatures { + compose = true + } + packaging { + resources { + excludes += '/META-INF/{AL2.0,LGPL2.1}' + } + } +} + +dependencies { + + implementation libs.androidx.room.runtime + + // Hilt + implementation("com.google.dagger:hilt-android:2.59.2") // ← Update this + kapt("com.google.dagger:hilt-compiler:2.59.2") // ← Update this + + // Room (ensure versions are consistent) + + implementation("androidx.room:room-runtime:2.8.4") + implementation("androidx.room:room-ktx:2.8.4") + kapt("androidx.room:room-compiler:2.8.4") + implementation 'com.google.code.gson:gson:2.14.0' + + // If using Hilt with Room, also add: + implementation("androidx.hilt:hilt-common:1.3.0") + kapt("androidx.hilt:hilt-compiler:1.3.0") + implementation libs.androidx.room.ktx + implementation libs.androidx.datastore.preferences + implementation(libs.vico.compose) + coreLibraryDesugaring libs.desugar.jdk.libs + implementation("com.kizitonwose.calendar:view:2.10.1") + implementation("com.kizitonwose.calendar:compose:2.10.1") + implementation(libs.vico.compose.m2) + implementation(libs.vico.compose.m3) + implementation(libs.vico.core) + implementation(libs.vico.views) + implementation libs.hilt.android + kapt libs.dagger.hilt.compiler + implementation libs.androidx.core.ktx + implementation libs.androidx.lifecycle.runtime.ktx + implementation libs.androidx.activity.compose + implementation platform(libs.androidx.compose.bom) + implementation(libs.androidx.navigation.compose) + implementation libs.androidx.ui + implementation libs.androidx.ui.graphics + implementation libs.androidx.ui.tooling.preview + implementation libs.androidx.material3 + implementation libs.firebase.auth + implementation("androidx.hilt:hilt-navigation-compose:1.2.0") + testImplementation libs.junit + androidTestImplementation libs.androidx.junit + androidTestImplementation libs.androidx.espresso.core + androidTestImplementation platform(libs.androidx.compose.bom) + androidTestImplementation libs.androidx.ui.test.junit4 + debugImplementation libs.androidx.ui.tooling + debugImplementation libs.androidx.ui.test.manifest +} \ No newline at end of file diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 0000000..7b581dc --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "221091718064", + "project_id": "shiftmanageryaroshumster", + "storage_bucket": "shiftmanageryaroshumster.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:221091718064:android:c5ca694f79c4f1d6f02c75", + "android_client_info": { + "package_name": "com.yaros.shiftmanager" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyDMJMZrKLkVNujMh71dqh0J3_z3qtpQkBY" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/yaros/shiftmanager/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/yaros/shiftmanager/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..35f0842 --- /dev/null +++ b/app/src/androidTest/java/com/yaros/shiftmanager/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.yaros.shiftmanager + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.yaros.shiftmanager", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e2dfb42 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/yaros/shiftmanager/AppDatabase.kt b/app/src/main/java/com/yaros/shiftmanager/AppDatabase.kt new file mode 100644 index 0000000..f82b8d8 --- /dev/null +++ b/app/src/main/java/com/yaros/shiftmanager/AppDatabase.kt @@ -0,0 +1,116 @@ +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 +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.util.Date + +@Database( + entities = [Income::class, Shift::class, TaxReminder::class, ShiftTemplate::class], + version = 2, + exportSchema = false +) +@TypeConverters(Converters::class) +abstract class AppDatabase : RoomDatabase() { + abstract fun incomeDao(): IncomeDao + abstract fun shiftDao(): ShiftDao + abstract fun taxReminderDao(): TaxReminderDao + 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") + db.execSQL("ALTER TABLE shifts ADD COLUMN hourlyRate REAL NOT NULL DEFAULT 0.0") + db.execSQL("ALTER TABLE shifts ADD COLUMN isCompleted INTEGER NOT NULL DEFAULT 0") + db.execSQL("ALTER TABLE shifts ADD COLUMN templateId TEXT DEFAULT NULL") + db.execSQL("""CREATE TABLE IF NOT EXISTS shift_templates ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + hourlyRate REAL NOT NULL, + coefficientRules TEXT NOT NULL DEFAULT '[]' + )""") + } + } + + 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 + } + } + } +} + +// Add this class to handle Date conversions for Room +class Converters { + private val gson = Gson() + + // --- 1. Ваши старые конвертеры для Date --- + @TypeConverter + fun fromTimestamp(value: Long?): Date? { + return value?.let { Date(it) } + } + + @TypeConverter + fun dateToTimestamp(date: Date?): Long? { + return date?.time + } + + // --- 2. Конвертеры для Enum ShiftType --- + // Room не умеет сохранять enum напрямую, поэтому сохраняем его имя (String) + @TypeConverter + fun fromShiftType(value: ShiftType?): String? { + return value?.name + } + + @TypeConverter + fun toShiftType(value: String?): ShiftType? { + return value?.let { ShiftType.valueOf(it) } + } + + // --- 3. Новые конвертеры для сложного объекта RecurringPattern --- + // Преобразуем объект в JSON-строку для сохранения в БД + @TypeConverter + fun fromRecurringPattern(pattern: RecurringPattern?): String? { + return gson.toJson(pattern) + } + + // Преобразуем JSON-строку из БД обратно в объект + @TypeConverter + fun toRecurringPattern(patternString: String?): RecurringPattern? { + if (patternString == null) return null + val type = object : TypeToken() {}.type + return gson.fromJson(patternString, type) + } + + // --- 4. Конвертеры для List --- + @TypeConverter + fun fromCoefficientRules(rules: List?): String? { + return gson.toJson(rules ?: emptyList()) + } + + @TypeConverter + fun toCoefficientRules(json: String?): List { + if (json == null) return emptyList() + val type = object : TypeToken>() {}.type + return gson.fromJson(json, type) ?: emptyList() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yaros/shiftmanager/AuthManager.kt b/app/src/main/java/com/yaros/shiftmanager/AuthManager.kt new file mode 100644 index 0000000..3a243a7 --- /dev/null +++ b/app/src/main/java/com/yaros/shiftmanager/AuthManager.kt @@ -0,0 +1,123 @@ +package com.yaros.shiftmanager + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import javax.inject.Inject + +class AuthRepository @Inject constructor() { + private val auth: FirebaseAuth = FirebaseAuth.getInstance() + + suspend fun signUp(email: String, password: String): Result { + return try { + val result = auth.createUserWithEmailAndPassword(email, password).await() + Result.success(result.user!!) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun signIn(email: String, password: String): Result { + return try { + val result = auth.signInWithEmailAndPassword(email, password).await() + Result.success(result.user!!) + } catch (e: Exception) { + Result.failure(e) + } + } + + fun signOut() { + auth.signOut() + } + + fun getCurrentUser(): FirebaseUser? { + return auth.currentUser + } +} + +@HiltViewModel +class AuthViewModel @Inject constructor(private val repository: AuthRepository) : ViewModel() { + private val _user = MutableStateFlow(repository.getCurrentUser()) + val user: StateFlow = _user.asStateFlow() + + fun signUp(email: String, password: String) = viewModelScope.launch { + repository.signUp(email, password).onSuccess { _user.value = it } + } + + fun signIn(email: String, password: String) = viewModelScope.launch { + repository.signIn(email, password).onSuccess { _user.value = it } + } + + fun signOut() { + repository.signOut() + _user.value = null + } +} + +@Composable +fun AuthScreen(viewModel: AuthViewModel, onAuthSuccess: () -> Unit) { + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var isSignUp by remember { mutableStateOf(false) } + + val user by viewModel.user.collectAsState() + + LaunchedEffect(user) { + if (user != null) { + onAuthSuccess() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center + ) { + TextField( + value = email, + onValueChange = { email = it }, + label = { Text("Email") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + TextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { + if (isSignUp) { + viewModel.signUp(email, password) + } else { + viewModel.signIn(email, password) + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(if (isSignUp) "Sign Up" else "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") + } + } +} \ 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 new file mode 100644 index 0000000..23169ca --- /dev/null +++ b/app/src/main/java/com/yaros/shiftmanager/CalendarScreen.kt @@ -0,0 +1,551 @@ +package com.yaros.shiftmanager + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.shape.CircleShape +import androidx.compose.foundation.verticalScroll +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.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.text.style.TextAlign +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth +import java.time.ZoneId +import java.time.format.TextStyle +import java.time.temporal.TemporalAdjusters +import java.time.temporal.WeekFields +import java.util.* +import kotlin.math.roundToInt + +// ─────────────────── Calendar Screen ─────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalendarScreen(viewModel: ShiftViewModel = hiltViewModel()) { + val currentMonth by viewModel.currentMonth.collectAsState() + val monthShifts by viewModel.monthShifts.collectAsState() + val templates by viewModel.allTemplates.collectAsState() + + var selectedDate by remember { mutableStateOf(LocalDate.now()) } + var showAddDialog by remember { mutableStateOf(false) } + var editingShift by remember { mutableStateOf(null) } + + Scaffold( + floatingActionButton = { + FloatingActionButton(onClick = { + editingShift = null + showAddDialog = true + }) { + Icon(Icons.Default.Add, contentDescription = "Add Shift") + } + } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + // ── Month navigation header ── + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { viewModel.previousMonth() }) { + Icon(Icons.Default.ArrowBack, contentDescription = "Previous month") + } + 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") + } + } + + // ── Day-of-week headers ── + val firstDayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek + val orderedDays = DayOfWeek.entries.let { all -> + val idx = all.indexOf(firstDayOfWeek) + all.drop(idx) + all.take(idx) + } + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)) { + orderedDays.forEach { dow -> + Text( + text = dow.getDisplayName(TextStyle.SHORT, Locale.getDefault()), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + + // ── Calendar grid ── + CalendarGrid( + yearMonth = currentMonth, + shifts = monthShifts, + selectedDate = selectedDate, + onDateSelected = { selectedDate = it } + ) + + // ── Shifts for selected date ── + val shiftsForSelected = monthShifts.filter { shift -> + shift.startTime.toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDate() == selectedDate + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + Text( + text = "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", + 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) } + ) + } + } + } + } + + if (showAddDialog) { + AddShiftDialog( + selectedDate = selectedDate, + templates = templates, + existingShift = editingShift, + onDismiss = { showAddDialog = false }, + onSave = { shift -> + if (editingShift != null) viewModel.updateShift(shift) + else viewModel.insertShift(shift) + showAddDialog = false + } + ) + } +} + +// ─────────────────── Calendar Grid ─────────────────── + +@Composable +private fun CalendarGrid( + yearMonth: YearMonth, + shifts: List, + selectedDate: LocalDate, + onDateSelected: (LocalDate) -> Unit +) { + val firstDayOfMonth = yearMonth.atDay(1) + val firstDayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek + val gridStart = firstDayOfMonth.with(TemporalAdjusters.previousOrSame(firstDayOfWeek)) + + // 6 weeks × 7 days covers any month + val gridDays = (0 until 42).map { gridStart.plusDays(it.toLong()) } + + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)) { + gridDays.chunked(7).forEach { week -> + Row(modifier = Modifier.fillMaxWidth()) { + week.forEach { date -> + val isCurrentMonth = date.monthValue == yearMonth.monthValue && + date.year == yearMonth.year + val dayShifts = shifts.filter { shift -> + shift.startTime.toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDate() == date + } + DayCell( + date = date, + shifts = dayShifts, + isSelected = date == selectedDate, + isInMonth = isCurrentMonth, + modifier = Modifier.weight(1f), + onClick = { if (isCurrentMonth) onDateSelected(date) } + ) + } + } + } + } +} + +// ─────────────────── Day Cell ─────────────────── + +@Composable +private fun DayCell( + date: LocalDate, + shifts: List, + isSelected: Boolean, + isInMonth: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + val hasDayShift = shifts.any { it.type == ShiftType.DAY } + val hasNightShift = shifts.any { it.type == ShiftType.NIGHT } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .aspectRatio(1f) + .padding(2.dp) + .clickable(enabled = isInMonth) { onClick() } + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background( + if (isSelected) MaterialTheme.colorScheme.primaryContainer + else Color.Transparent, + CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = date.dayOfMonth.toString(), + color = when { + !isInMonth -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f) + isSelected -> MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.onSurface + }, + style = MaterialTheme.typography.bodySmall + ) + } + + // Shift type indicators + if (isInMonth && shifts.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.height(8.dp) + ) { + if (hasDayShift) { + Box( + modifier = Modifier + .size(5.dp) + .background(Color(0xFFFFC107), CircleShape) + ) + Spacer(Modifier.width(2.dp)) + } + if (hasNightShift) { + Box( + modifier = Modifier + .size(5.dp) + .background(Color(0xFF2196F3), CircleShape) + ) + } + } + } else { + Spacer(Modifier.height(8.dp)) + } + } +} + +// ─────────────────── Shift Card ─────────────────── + +@Composable +private fun ShiftCard( + shift: Shift, + templates: List, + 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" + } + val typeColor = when (shift.type) { + ShiftType.DAY -> Color(0xFFFFC107) + ShiftType.NIGHT -> Color(0xFF2196F3) + ShiftType.WEEKEND -> Color(0xFF9C27B0) + } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp) + .clickable { onClick() }, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + // Color strip + Box( + modifier = Modifier + .width(4.dp) + .height(48.dp) + .background(typeColor, MaterialTheme.shapes.small) + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = template?.name ?: shift.title.ifBlank { typeLabel }, + style = MaterialTheme.typography.titleSmall + ) + Text( + text = "${String.format(Locale.getDefault(), "%.1f", shift.hoursWorked)} h · $typeLabel", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Column(horizontalAlignment = Alignment.End) { + val earnings = if (template != null) { + ShiftViewModel.calculateEarnings( + shift.hourlyRate, shift.hoursWorked, template.coefficientRules + ) + } else { + shift.hourlyRate * shift.hoursWorked + } + Text( + text = "$${String.format(Locale.getDefault(), "%.2f", earnings)}", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + if (shift.isCompleted) { + Text( + text = "Completed", + style = MaterialTheme.typography.labelSmall, + color = Color(0xFF4CAF50) + ) + } + } + IconButton(onClick = onDelete) { + Text("✕", color = MaterialTheme.colorScheme.error) + } + } + } +} + +// ─────────────────── Add / Edit Shift Dialog ─────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AddShiftDialog( + selectedDate: LocalDate, + templates: List, + existingShift: Shift?, + onDismiss: () -> Unit, + onSave: (Shift) -> Unit +) { + var title by remember { mutableStateOf(existingShift?.title ?: "") } + var selectedTemplateId by remember { mutableStateOf(existingShift?.templateId) } + var hoursText by remember { + mutableStateOf( + if (existingShift != null && existingShift.hoursWorked > 0) + existingShift.hoursWorked.toString() else "" + ) + } + var rateText by remember { + mutableStateOf( + if (existingShift != null && existingShift.hourlyRate > 0) + existingShift.hourlyRate.toString() else "" + ) + } + var shiftType by remember { mutableStateOf(existingShift?.type ?: ShiftType.DAY) } + var isCompleted by remember { mutableStateOf(existingShift?.isCompleted ?: true) } + var startTime by remember { + mutableStateOf( + existingShift?.startTime?.toInstant()?.atZone(ZoneId.systemDefault())?.toLocalTime() + ?: java.time.LocalTime.of(9, 0) + ) + } + var showTimePicker by remember { mutableStateOf(false) } + + val selectedTemplate = templates.find { it.id == selectedTemplateId } + + // Auto-fill rate from template + LaunchedEffect(selectedTemplate) { + selectedTemplate?.let { rateText = it.hourlyRate.toString() } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (existingShift != null) "Edit Shift" else "Add Shift") }, + 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() + ) { + templates.forEach { tmpl -> + FilterChip( + selected = selectedTemplateId == tmpl.id, + onClick = { + selectedTemplateId = + if (selectedTemplateId == tmpl.id) null else tmpl.id + }, + label = { Text(tmpl.name, style = MaterialTheme.typography.labelSmall) } + ) + } + } + } + + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("Title (optional)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + // Shift type + Text("Type", style = MaterialTheme.typography.labelMedium) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + ShiftType.entries.forEach { type -> + FilterChip( + selected = shiftType == type, + onClick = { shiftType = type }, + label = { + Text( + when (type) { + ShiftType.DAY -> "Day" + ShiftType.NIGHT -> "Night" + ShiftType.WEEKEND -> "Weekend" + } + ) + } + ) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = hoursText, + onValueChange = { hoursText = it }, + label = { Text("Hours") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + OutlinedTextField( + value = rateText, + onValueChange = { rateText = it }, + label = { Text("$/hour") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + } + + // Start time button + OutlinedButton(onClick = { showTimePicker = true }) { + Text( + "Start: ${ + String.format( + Locale.getDefault(), + "%02d:%02d", + startTime.hour, + startTime.minute + ) + }" + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = isCompleted, onCheckedChange = { isCompleted = it }) + Text("Completed") + } + + // Earnings preview + val hours = hoursText.toDoubleOrNull() ?: 0.0 + val rate = rateText.toDoubleOrNull() ?: 0.0 + val earnings = if (selectedTemplate != null) { + ShiftViewModel.calculateEarnings(rate, hours, selectedTemplate.coefficientRules) + } else { + rate * hours + } + if (hours > 0 && rate > 0) { + Text( + text = "Earnings: $${String.format(Locale.getDefault(), "%.2f", earnings)}", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } + }, + confirmButton = { + Button(onClick = { + val hours = hoursText.toDoubleOrNull() ?: 0.0 + val rate = rateText.toDoubleOrNull() ?: 0.0 + val startDateTime = selectedDate.atTime(startTime) + val endDateTime = startDateTime.plusHours(hours.toLong()) + .plusMinutes(((hours % 1) * 60).roundToInt().toLong()) + val zone = ZoneId.systemDefault() + val shift = Shift( + id = existingShift?.id ?: UUID.randomUUID().toString(), + title = title.ifBlank { + when (shiftType) { + ShiftType.DAY -> "Day Shift" + ShiftType.NIGHT -> "Night Shift" + ShiftType.WEEKEND -> "Weekend Shift" + } + }, + startTime = Date.from(startDateTime.atZone(zone).toInstant()), + endTime = Date.from(endDateTime.atZone(zone).toInstant()), + type = shiftType, + hoursWorked = hours, + hourlyRate = rate, + isCompleted = isCompleted, + templateId = selectedTemplateId + ) + onSave(shift) + }) { Text("Save") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } + ) + + if (showTimePicker) { + val pickerState = rememberTimePickerState( + initialHour = startTime.hour, + initialMinute = startTime.minute, + is24Hour = true + ) + AlertDialog( + onDismissRequest = { showTimePicker = false }, + title = { Text("Shift start time") }, + text = { TimePicker(state = pickerState) }, + confirmButton = { + Button(onClick = { + startTime = java.time.LocalTime.of(pickerState.hour, pickerState.minute) + showTimePicker = false + }) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = { showTimePicker = false }) { Text("Cancel") } + } + ) + } +} diff --git a/app/src/main/java/com/yaros/shiftmanager/DatabaseModule.kt b/app/src/main/java/com/yaros/shiftmanager/DatabaseModule.kt new file mode 100644 index 0000000..9f073e2 --- /dev/null +++ b/app/src/main/java/com/yaros/shiftmanager/DatabaseModule.kt @@ -0,0 +1,39 @@ +package com.yaros.shiftmanager + +import android.content.Context +import androidx.room.Room +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): AppDatabase { + return Room.databaseBuilder( + context, + AppDatabase::class.java, + "shift_manager_database" + ) + .addMigrations(AppDatabase.MIGRATION_1_2) + .build() + } + + @Provides + fun provideIncomeDao(database: AppDatabase): IncomeDao = database.incomeDao() + + @Provides + fun provideShiftDao(database: AppDatabase): ShiftDao = database.shiftDao() + + @Provides + fun provideTaxReminderDao(database: AppDatabase): TaxReminderDao = database.taxReminderDao() + + @Provides + fun provideShiftTemplateDao(database: AppDatabase): ShiftTemplateDao = database.shiftTemplateDao() +} diff --git a/app/src/main/java/com/yaros/shiftmanager/Incommer.kt b/app/src/main/java/com/yaros/shiftmanager/Incommer.kt new file mode 100644 index 0000000..ceba08c --- /dev/null +++ b/app/src/main/java/com/yaros/shiftmanager/Incommer.kt @@ -0,0 +1,86 @@ +package com.yaros.shiftmanager + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import androidx.room.* +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import android.content.Context +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import java.util.* +import javax.inject.Inject + +@Entity(tableName = "incomes") +data class Income( + @PrimaryKey val id: String = UUID.randomUUID().toString(), + val shiftId: String, + val amount: Double, + val type: IncomeType, + val date: Date +) + +enum class IncomeType { + REGULAR, TIPS, BONUS +} + +@Dao +interface IncomeDao { + @Query("SELECT * FROM incomes") + fun getAllIncomes(): Flow> + + @Query("SELECT * FROM incomes WHERE date BETWEEN :startDate AND :endDate") + fun getIncomesForPeriod(startDate: Date, endDate: Date): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertIncome(income: Income) + + @Update + suspend fun updateIncome(income: Income) + + @Delete + suspend fun deleteIncome(income: Income) +} + +class IncomeRepository @Inject constructor(private val incomeDao: IncomeDao) { + val allIncomes: Flow> = incomeDao.getAllIncomes() + + fun getIncomesForPeriod(startDate: Date, endDate: Date): Flow> { + return incomeDao.getIncomesForPeriod(startDate, endDate) + } + + suspend fun insertIncome(income: Income) { + incomeDao.insertIncome(income) + } + + suspend fun updateIncome(income: Income) { + incomeDao.updateIncome(income) + } + + suspend fun deleteIncome(income: Income) { + incomeDao.deleteIncome(income) + } +} + +@HiltViewModel +class IncomeViewModel @Inject constructor(private val repository: IncomeRepository) : ViewModel() { + val allIncomes: LiveData> = repository.allIncomes.asLiveData() + + fun getIncomesForPeriod(startDate: Date, endDate: Date): LiveData> { + return repository.getIncomesForPeriod(startDate, endDate).asLiveData() + } + + fun insertIncome(income: Income) = viewModelScope.launch { + repository.insertIncome(income) + } + + fun updateIncome(income: Income) = viewModelScope.launch { + repository.updateIncome(income) + } + + fun deleteIncome(income: Income) = viewModelScope.launch { + repository.deleteIncome(income) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yaros/shiftmanager/MainActivity.kt b/app/src/main/java/com/yaros/shiftmanager/MainActivity.kt new file mode 100644 index 0000000..4cc3631 --- /dev/null +++ b/app/src/main/java/com/yaros/shiftmanager/MainActivity.kt @@ -0,0 +1,86 @@ +package com.yaros.shiftmanager + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + private val settingsViewModel: SettingsViewModel by viewModels() + private val incomeViewModel: IncomeViewModel by viewModels() + private val shiftViewModel: ShiftViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + val themeMode by settingsViewModel.themeMode.collectAsState() + + AppTheme(themeMode = themeMode) { + MainScreen( + shiftViewModel = shiftViewModel, + incomeViewModel = incomeViewModel, + settingsViewModel = settingsViewModel + ) + } + } + } +} + +@Composable +fun MainScreen( + shiftViewModel: ShiftViewModel, + incomeViewModel: IncomeViewModel, + settingsViewModel: SettingsViewModel +) { + val navigationItems = listOf("Shifts", "Templates", "Income", "Settings") + val (selectedItem, setSelectedItem) = remember { mutableStateOf(0) } + + Scaffold( + bottomBar = { + NavigationBar { + navigationItems.forEachIndexed { index, item -> + NavigationBarItem( + icon = { + Text( + when (index) { + 0 -> "📅" + 1 -> "📋" + 2 -> "💰" + 3 -> "⚙️" + else -> "📌" + } + ) + }, + label = { Text(item) }, + selected = selectedItem == index, + onClick = { setSelectedItem(index) } + ) + } + } + } + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues)) { + when (selectedItem) { + 0 -> CalendarScreen(shiftViewModel) + 1 -> ShiftTemplatesScreen(shiftViewModel) + 2 -> StatisticsScreen(incomeViewModel) + 3 -> SettingsScreen(settingsViewModel) + } + } + } +} diff --git a/app/src/main/java/com/yaros/shiftmanager/NotificationManager.kt b/app/src/main/java/com/yaros/shiftmanager/NotificationManager.kt new file mode 100644 index 0000000..1a7ade4 --- /dev/null +++ b/app/src/main/java/com/yaros/shiftmanager/NotificationManager.kt @@ -0,0 +1,75 @@ +//package com.yaros.shiftmanager +// +//import android.app.NotificationChannel +//import android.app.NotificationManager +//import android.content.Context +//import android.os.Build +//import androidx.core.app.NotificationCompat +//import androidx.core.app.NotificationManagerCompat +//import androidx.work.* +//import com.yaros.shiftmanager.* +//import java.util.concurrent.TimeUnit +// +//class NotificationHelper(private val context: Context) { +// private val channelId = "ShiftManagerChannel" +// private val notificationId = 1 +// +// init { +// createNotificationChannel() +// } +// +// private fun createNotificationChannel() { +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { +// val name = "Shift Manager" +// val descriptionText = "Notifications for upcoming shifts" +// val importance = NotificationManager.IMPORTANCE_DEFAULT +// val channel = NotificationChannel(channelId, name, importance).apply { +// description = descriptionText +// } +// val notificationManager: NotificationManager = +// context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager +// notificationManager.createNotificationChannel(channel) +// } +// } +// +// fun showShiftNotification(shift: Shift) { +// val builder = NotificationCompat.Builder(context, channelId) +// .setSmallIcon(R.drawable.ic_notification) +// .setContentTitle("Upcoming Shift") +// .setContentText("You have a ${shift.type} shift starting at ${shift.startTime}") +// .setPriority(NotificationCompat.PRIORITY_DEFAULT) +// .setAutoCancel(true) +// +// with(NotificationManagerCompat.from(context)) { +// notify(notificationId, builder.build()) +// } +// } +//} +// +//class ShiftNotificationWorker( +// context: Context, +// params: WorkerParameters +//) : Worker(context, params) { +// override fun doWork(): Result { +// val shiftId = inputData.getString("shiftId") ?: return Result.failure() +// val shift = // Retrieve shift from database using shiftId +// NotificationHelper(applicationContext).showShiftNotification(shift) +// return Result.success() +// } +// +// companion object { +// fun scheduleNotification(context: Context, shift: Shift) { +// val notificationWork = OneTimeWorkRequestBuilder() +// .setInputData(workDataOf("shiftId" to shift.id)) +// .setInitialDelay(calculateInitialDelay(shift), TimeUnit.MILLISECONDS) +// .build() +// +// WorkManager.getInstance(context).enqueue(notificationWork) +// } +// +// private fun calculateInitialDelay(shift: Shift): Long { +// val currentTime = System.currentTimeMillis() +// return shift.startTime.time - currentTime - TimeUnit.HOURS.toMillis(1) // Notify 1 hour before shift +// } +// } +//} \ No newline at end of file diff --git a/app/src/main/java/com/yaros/shiftmanager/SettingsMenu.kt b/app/src/main/java/com/yaros/shiftmanager/SettingsMenu.kt new file mode 100644 index 0000000..b128c8c --- /dev/null +++ b/app/src/main/java/com/yaros/shiftmanager/SettingsMenu.kt @@ -0,0 +1,156 @@ +package com.yaros.shiftmanager + +import android.content.Context +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.* +import androidx.datastore.preferences.preferencesDataStore +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + +class SettingsRepository @Inject constructor(@ApplicationContext private val context: Context) { + private val dataStore = context.dataStore + + val themeFlow: Flow = dataStore.data.map { preferences -> + ThemeMode.valueOf(preferences[PreferencesKeys.THEME_MODE] ?: ThemeMode.SYSTEM.name) + } + + val displayModeFlow: Flow = dataStore.data.map { preferences -> + DisplayMode.valueOf(preferences[PreferencesKeys.DISPLAY_MODE] ?: DisplayMode.WEEKLY.name) + } + + suspend fun setThemeMode(themeMode: ThemeMode) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.THEME_MODE] = themeMode.name + } + } + + suspend fun setDisplayMode(displayMode: DisplayMode) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.DISPLAY_MODE] = displayMode.name + } + } + + private object PreferencesKeys { + val THEME_MODE = stringPreferencesKey("theme_mode") + val DISPLAY_MODE = stringPreferencesKey("display_mode") + } +} + +enum class ThemeMode { + LIGHT, DARK, SYSTEM +} + +enum class DisplayMode { + WEEKLY, MONTHLY +} + +@HiltViewModel +class SettingsViewModel @Inject constructor(private val repository: SettingsRepository) : ViewModel() { + val themeMode: StateFlow = repository.themeFlow.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + ThemeMode.SYSTEM + ) + + val displayMode: StateFlow = repository.displayModeFlow.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + DisplayMode.WEEKLY + ) + + fun setThemeMode(themeMode: ThemeMode) = viewModelScope.launch { + repository.setThemeMode(themeMode) + } + + fun setDisplayMode(displayMode: DisplayMode) = viewModelScope.launch { + repository.setDisplayMode(displayMode) + } +} + +@Composable +fun SettingsScreen(viewModel: SettingsViewModel) { + val themeMode by viewModel.themeMode.collectAsState() + val displayMode by viewModel.displayMode.collectAsState() + + Column(modifier = Modifier.padding(16.dp)) { + Text("Settings", 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) } + } +} + +@Composable +fun ThemeModeSelector(currentMode: ThemeMode, onModeSelected: (ThemeMode) -> Unit) { + Column { + Text("Theme Mode", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + ThemeMode.values().forEach { mode -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(mode.name) + RadioButton( + selected = mode == currentMode, + onClick = { onModeSelected(mode) } + ) + } + } + } +} + +@Composable +fun DisplayModeSelector(currentMode: DisplayMode, onModeSelected: (DisplayMode) -> Unit) { + Column { + Text("Display Mode", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + DisplayMode.values().forEach { mode -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(mode.name) + RadioButton( + selected = mode == currentMode, + onClick = { onModeSelected(mode) } + ) + } + } + } +} + +@Composable +fun AppTheme( + themeMode: ThemeMode, + content: @Composable () -> Unit +) { + val darkTheme = when (themeMode) { + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true + ThemeMode.SYSTEM -> isSystemInDarkTheme() + } + + MaterialTheme( + colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme(), + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/yaros/shiftmanager/ShiftHelper.kt b/app/src/main/java/com/yaros/shiftmanager/ShiftHelper.kt new file mode 100644 index 0000000..4b816c4 --- /dev/null +++ b/app/src/main/java/com/yaros/shiftmanager/ShiftHelper.kt @@ -0,0 +1,177 @@ +package com.yaros.shiftmanager + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.room.* +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.YearMonth +import java.time.ZoneId +import java.util.* +import javax.inject.Inject + +// ─────────────────── Shift Entity ─────────────────── + +@Entity(tableName = "shifts") +data class Shift( + @PrimaryKey val id: String = UUID.randomUUID().toString(), + val title: String, + val startTime: Date, + val endTime: Date, + val type: ShiftType, + val isRecurring: Boolean = false, + val recurringPattern: RecurringPattern? = null, + val hoursWorked: Double = 0.0, + val hourlyRate: Double = 0.0, + val isCompleted: Boolean = false, + val templateId: String? = null +) + +enum class ShiftType { + DAY, NIGHT, WEEKEND +} + +data class RecurringPattern( + val frequency: RecurringFrequency, + val interval: Int, + val daysOfWeek: List? = null, + val endDate: Date? = null +) + +enum class RecurringFrequency { + DAILY, WEEKLY, MONTHLY +} + +// ─────────────────── ShiftTemplate Entity ─────────────────── + +@Entity(tableName = "shift_templates") +data class ShiftTemplate( + @PrimaryKey val id: String = UUID.randomUUID().toString(), + val name: String, + val hourlyRate: Double, + val coefficientRules: List = emptyList() +) + +data class CoefficientRule( + val startHour: Int, // 0-23 + val endHour: Int, // 0-23 (next day if < startHour) + val coefficient: Double // e.g. 1.4 = +40% +) + +// ─────────────────── DAOs ─────────────────── + +@Dao +interface ShiftDao { + @Query("SELECT * FROM shifts") + fun getAllShifts(): Flow> + + @Query("SELECT * FROM shifts WHERE startTime >= :start AND startTime < :end ORDER BY startTime ASC") + fun getShiftsForMonth(start: Date, end: Date): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertShift(shift: Shift) + + @Update + suspend fun updateShift(shift: Shift) + + @Delete + suspend fun deleteShift(shift: Shift) +} + +@Dao +interface ShiftTemplateDao { + @Query("SELECT * FROM shift_templates") + fun getAllTemplates(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertTemplate(template: ShiftTemplate) + + @Update + suspend fun updateTemplate(template: ShiftTemplate) + + @Delete + suspend fun deleteTemplate(template: ShiftTemplate) +} + +// ─────────────────── Repositories ─────────────────── + +class ShiftRepository @Inject constructor(private val shiftDao: ShiftDao) { + val allShifts: Flow> = shiftDao.getAllShifts() + + fun getShiftsForMonth(yearMonth: YearMonth): Flow> { + val start = yearMonth.atDay(1).atStartOfDay(ZoneId.systemDefault()).toInstant().let { Date.from(it) } + val end = yearMonth.plusMonths(1).atDay(1).atStartOfDay(ZoneId.systemDefault()).toInstant().let { Date.from(it) } + return shiftDao.getShiftsForMonth(start, end) + } + + suspend fun insertShift(shift: Shift) = shiftDao.insertShift(shift) + suspend fun updateShift(shift: Shift) = shiftDao.updateShift(shift) + suspend fun deleteShift(shift: Shift) = shiftDao.deleteShift(shift) +} + +class ShiftTemplateRepository @Inject constructor(private val dao: ShiftTemplateDao) { + val allTemplates: Flow> = dao.getAllTemplates() + suspend fun insertTemplate(template: ShiftTemplate) = dao.insertTemplate(template) + suspend fun updateTemplate(template: ShiftTemplate) = dao.updateTemplate(template) + suspend fun deleteTemplate(template: ShiftTemplate) = dao.deleteTemplate(template) +} + +// ─────────────────── ViewModels ─────────────────── + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class ShiftViewModel @Inject constructor( + private val repository: ShiftRepository, + private val templateRepository: ShiftTemplateRepository +) : ViewModel() { + + private val _currentMonth = MutableStateFlow(YearMonth.now()) + val currentMonth: StateFlow = _currentMonth.asStateFlow() + + val monthShifts: StateFlow> = _currentMonth + .flatMapLatest { month -> repository.getShiftsForMonth(month) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val allTemplates: StateFlow> = templateRepository.allTemplates + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + fun setMonth(month: YearMonth) { _currentMonth.value = month } + fun nextMonth() { _currentMonth.value = _currentMonth.value.plusMonths(1) } + fun previousMonth() { _currentMonth.value = _currentMonth.value.minusMonths(1) } + + fun insertShift(shift: Shift) = viewModelScope.launch { repository.insertShift(shift) } + fun updateShift(shift: Shift) = viewModelScope.launch { repository.updateShift(shift) } + fun deleteShift(shift: Shift) = viewModelScope.launch { repository.deleteShift(shift) } + + fun insertTemplate(template: ShiftTemplate) = viewModelScope.launch { templateRepository.insertTemplate(template) } + fun updateTemplate(template: ShiftTemplate) = viewModelScope.launch { templateRepository.updateTemplate(template) } + fun deleteTemplate(template: ShiftTemplate) = viewModelScope.launch { templateRepository.deleteTemplate(template) } + + fun shiftsForDate(date: LocalDate): List { + return monthShifts.value.filter { shift -> + shift.startTime.toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDate() == date + } + } + + 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)) { + 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) + } + return hoursByCoefficient.sumOf { baseRate * it } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yaros/shiftmanager/ShiftManagerAplication.kt b/app/src/main/java/com/yaros/shiftmanager/ShiftManagerAplication.kt new file mode 100644 index 0000000..b20ed2f --- /dev/null +++ b/app/src/main/java/com/yaros/shiftmanager/ShiftManagerAplication.kt @@ -0,0 +1,7 @@ +package com.yaros.shiftmanager + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class ShiftManagerApplication : Application() \ 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 new file mode 100644 index 0000000..4bd9b4f --- /dev/null +++ b/app/src/main/java/com/yaros/shiftmanager/ShiftTemplatesScreen.kt @@ -0,0 +1,285 @@ +package com.yaros.shiftmanager + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import java.util.Locale +import java.util.UUID + +// ─────────────────── Templates Screen ─────────────────── + +@Composable +fun ShiftTemplatesScreen(viewModel: ShiftViewModel = hiltViewModel()) { + val templates by viewModel.allTemplates.collectAsState() + + var showDialog by remember { mutableStateOf(false) } + var editingTemplate by remember { mutableStateOf(null) } + + Scaffold( + floatingActionButton = { + FloatingActionButton(onClick = { + editingTemplate = null + showDialog = true + }) { + Icon(Icons.Default.Add, contentDescription = "Add Template") + } + } + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues).fillMaxSize()) { + Text( + text = "Shift Templates", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(16.dp) + ) + Text( + text = "Create templates with hourly rates and night/weekend coefficients", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + + if (templates.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "No templates yet", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(8.dp)) + Text( + "Tap + to create your first shift template", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(templates) { template -> + TemplateCard( + template = template, + onEdit = { + editingTemplate = template + showDialog = true + }, + onDelete = { viewModel.deleteTemplate(template) } + ) + } + } + } + } + } + + if (showDialog) { + TemplateDialog( + existingTemplate = editingTemplate, + onDismiss = { showDialog = false }, + onSave = { template -> + if (editingTemplate != null) viewModel.updateTemplate(template) + else viewModel.insertTemplate(template) + showDialog = false + } + ) + } +} + +// ─────────────────── Template Card ─────────────────── + +@Composable +private fun TemplateCard( + template: ShiftTemplate, + onEdit: () -> Unit, + onDelete: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + onClick = onEdit + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(template.name, style = MaterialTheme.typography.titleMedium) + IconButton(onClick = onDelete) { + Text("✕", color = MaterialTheme.colorScheme.error) + } + } + + Text( + text = "Base rate: $${String.format(Locale.getDefault(), "%.2f", template.hourlyRate)}/hr", + style = MaterialTheme.typography.bodyMedium + ) + + if (template.coefficientRules.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Text("Coefficient rules:", style = MaterialTheme.typography.labelMedium) + template.coefficientRules.forEach { rule -> + val percent = ((rule.coefficient - 1.0) * 100).toInt() + val sign = if (percent > 0) "+" else "" + Text( + text = " ${String.format(Locale.getDefault(), "%02d:00", rule.startHour)} – " + + "${String.format(Locale.getDefault(), "%02d:00", rule.endHour)} → " + + "${sign}${percent}% (×${String.format(Locale.getDefault(), "%.1f", rule.coefficient)})", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } +} + +// ─────────────────── Template Dialog ─────────────────── + +@Composable +private fun TemplateDialog( + existingTemplate: ShiftTemplate?, + onDismiss: () -> Unit, + onSave: (ShiftTemplate) -> Unit +) { + var name by remember { mutableStateOf(existingTemplate?.name ?: "") } + var rateText by remember { + mutableStateOf( + if (existingTemplate != null) existingTemplate.hourlyRate.toString() else "" + ) + } + val rules = remember { + mutableStateListOf().also { + it.addAll(existingTemplate?.coefficientRules ?: emptyList()) + } + } + + // New rule fields + var newStartHour by remember { mutableStateOf("") } + var newEndHour by remember { mutableStateOf("") } + var newCoefficient by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (existingTemplate != null) "Edit Template" else "New Template") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Template name (e.g. Day Shift, Night Shift)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + OutlinedTextField( + value = rateText, + onValueChange = { rateText = it }, + label = { Text("Base hourly rate ($)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Divider() + Text("Coefficient rules", style = MaterialTheme.typography.labelLarge) + Text( + "Example: 00:00–06:00 → 1.4 means +40% for hours between midnight and 6 AM", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Existing rules + rules.forEachIndexed { index, rule -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "${String.format(Locale.getDefault(), "%02d:00", rule.startHour)}–" + + "${String.format(Locale.getDefault(), "%02d:00", rule.endHour)} → " + + "×${String.format(Locale.getDefault(), "%.1f", rule.coefficient)}", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { rules.removeAt(index) }) { + Text("✕", color = MaterialTheme.colorScheme.error) + } + } + } + + // Add new rule row + Text("Add rule:", style = MaterialTheme.typography.labelSmall) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = newStartHour, + onValueChange = { newStartHour = it.filter { c -> c.isDigit() }.take(2) }, + label = { Text("From") }, + placeholder = { Text("00") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + Text("–") + OutlinedTextField( + value = newEndHour, + onValueChange = { newEndHour = it.filter { c -> c.isDigit() }.take(2) }, + label = { Text("To") }, + placeholder = { Text("06") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + OutlinedTextField( + value = newCoefficient, + onValueChange = { newCoefficient = it }, + label = { Text("×") }, + placeholder = { Text("1.4") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + FilledTonalButton( + onClick = { + val start = newStartHour.toIntOrNull() ?: return@FilledTonalButton + val end = newEndHour.toIntOrNull() ?: return@FilledTonalButton + val coeff = newCoefficient.toDoubleOrNull() ?: return@FilledTonalButton + if (start !in 0..23 || end !in 0..23) return@FilledTonalButton + rules.add(CoefficientRule(start, end, coeff)) + newStartHour = "" + newEndHour = "" + newCoefficient = "" + }, + modifier = Modifier.size(40.dp), + contentPadding = PaddingValues(0.dp) + ) { + Text("+") + } + } + } + }, + confirmButton = { + Button(onClick = { + val rate = rateText.toDoubleOrNull() ?: return@Button + val template = ShiftTemplate( + id = existingTemplate?.id ?: UUID.randomUUID().toString(), + name = name.ifBlank { "Untitled Template" }, + hourlyRate = rate, + coefficientRules = rules.toList() + ) + onSave(template) + }) { Text("Save") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} diff --git a/app/src/main/java/com/yaros/shiftmanager/Staticanager.kt b/app/src/main/java/com/yaros/shiftmanager/Staticanager.kt new file mode 100644 index 0000000..3a05c40 --- /dev/null +++ b/app/src/main/java/com/yaros/shiftmanager/Staticanager.kt @@ -0,0 +1,97 @@ +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.ui.Modifier +import androidx.compose.ui.unit.dp +import java.util.Calendar +import java.util.Date +import java.util.Locale + + +@Composable +fun StatisticsScreen(incomeViewModel: IncomeViewModel) { + var selectedPeriod by remember { mutableStateOf(StatisticsPeriod.MONTH) } + var incomes by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(selectedPeriod) { + val (startDate, endDate) = getDateRangeForPeriod(selectedPeriod) + incomeViewModel.getIncomesForPeriod(startDate, endDate).observeForever { incomes = it } + } + + Column(modifier = Modifier.padding(16.dp)) { + PeriodSelector(selectedPeriod) { selectedPeriod = it } + Spacer(modifier = Modifier.height(16.dp)) + IncomeChart(incomes) + Spacer(modifier = Modifier.height(16.dp)) + IncomeBreakdown(incomes) + } +} + +@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 + + ) + ) { + Text(period.name) + } + } + } +} + +@Composable +fun IncomeChart(incomes: List) { + // Placeholder for chart - Vico chart implementation + Text("Income Chart: ${incomes.size} entries") +} + +@Composable +fun IncomeBreakdown(incomes: List) { + 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) + } +} + +enum class StatisticsPeriod { + WEEK, MONTH, YEAR +} + +fun getDateRangeForPeriod(period: StatisticsPeriod): Pair { + val calendar = Calendar.getInstance() + val endDate = calendar.time + calendar.add(when (period) { + StatisticsPeriod.WEEK -> Calendar.WEEK_OF_YEAR + StatisticsPeriod.MONTH -> Calendar.MONTH + StatisticsPeriod.YEAR -> Calendar.YEAR + }, -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 new file mode 100644 index 0000000..9ce52bb --- /dev/null +++ b/app/src/main/java/com/yaros/shiftmanager/TaxReminder.kt @@ -0,0 +1,186 @@ +package com.yaros.shiftmanager + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.Arrangement.Absolute.SpaceBetween +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.* +import androidx.compose.ui.unit.dp +import androidx.lifecycle.* +import androidx.room.* +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import java.text.SimpleDateFormat +import java.util.* +import javax.inject.Inject + +@Entity(tableName = "tax_reminders") +data class TaxReminder( + @PrimaryKey val id: String = UUID.randomUUID().toString(), + val dueDate: Date, + val description: String, + val isCompleted: Boolean = false +) + +@Dao +interface TaxReminderDao { + @Query("SELECT * FROM tax_reminders") + fun getAllTaxReminders(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertTaxReminder(taxReminder: TaxReminder) + + @Update + suspend fun updateTaxReminder(taxReminder: TaxReminder) + + @Delete + suspend fun deleteTaxReminder(taxReminder: TaxReminder) +} + +class TaxReminderRepository @Inject constructor(private val taxReminderDao: TaxReminderDao) { + val allTaxReminders: Flow> = taxReminderDao.getAllTaxReminders() + + suspend fun insertTaxReminder(taxReminder: TaxReminder) { + taxReminderDao.insertTaxReminder(taxReminder) + } + + suspend fun updateTaxReminder(taxReminder: TaxReminder) { + taxReminderDao.updateTaxReminder(taxReminder) + } + + suspend fun deleteTaxReminder(taxReminder: TaxReminder) { + taxReminderDao.deleteTaxReminder(taxReminder) + } +} + +@HiltViewModel +class TaxReminderViewModel @Inject constructor(private val repository: TaxReminderRepository) : ViewModel() { + val allTaxReminders: StateFlow> = repository.allTaxReminders.stateIn( + viewModelScope, + SharingStarted.Lazily, + emptyList() + ) + + fun insertTaxReminder(taxReminder: TaxReminder) = viewModelScope.launch { + repository.insertTaxReminder(taxReminder) + } + + fun updateTaxReminder(taxReminder: TaxReminder) = viewModelScope.launch { + repository.updateTaxReminder(taxReminder) + } + + fun deleteTaxReminder(taxReminder: TaxReminder) = viewModelScope.launch { + repository.deleteTaxReminder(taxReminder) + } +} + +@Composable +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) + Spacer(modifier = Modifier.height(16.dp)) + LazyColumn { + items(taxReminders) { reminder -> + TaxReminderItem(reminder, viewModel) + } + } + Spacer(modifier = Modifier.height(16.dp)) + AddTaxReminderButton(viewModel) + } +} + +@Composable +fun TaxReminderItem(reminder: TaxReminder, viewModel: TaxReminderViewModel) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + horizontalArrangement = SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text(reminder.description, style = MaterialTheme.typography.bodyLarge) + Text( + "Due: ${SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()).format(reminder.dueDate)}", + style = MaterialTheme.typography.bodyMedium + ) + } + Checkbox( + checked = reminder.isCompleted, + onCheckedChange = { isChecked -> + viewModel.updateTaxReminder(reminder.copy(isCompleted = isChecked)) + } + ) + } + } +} + +@Composable +fun AddTaxReminderButton(viewModel: TaxReminderViewModel) { + var showDialog by remember { mutableStateOf(false) } + + Button(onClick = { showDialog = true }) { + Text("Add Tax Reminder") + } + + if (showDialog) { + AddTaxReminderDialog( + onDismiss = { showDialog = false }, + onAdd = { description, dueDate -> + viewModel.insertTaxReminder(TaxReminder(description = description, dueDate = dueDate)) + showDialog = false + } + ) + } +} + +@Composable +fun AddTaxReminderDialog(onDismiss: () -> Unit, onAdd: (String, Date) -> Unit) { + var description by remember { mutableStateOf("") } + var dueDate by remember { mutableStateOf(Date()) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Tax Reminder") }, + text = { + Column { + TextField( + value = description, + onValueChange = { description = it }, + label = { Text("Description") } + ) + Spacer(modifier = Modifier.height(8.dp)) + // Add a DatePicker here for selecting dueDate + } + }, + confirmButton = { + Button(onClick = { onAdd(description, dueDate) }) { + Text("Add") + } + }, + dismissButton = { + Button(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..88bbe47 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + ShiftManager + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..c02afee --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +