diff --git a/README.md b/README.md index 0b16c38..910fc88 100644 --- a/README.md +++ b/README.md @@ -1,187 +1,165 @@ -Step & Drink -Aplikasi Android untuk tracking langkah harian dan kebutuhan air minum menggunakan Kotlin dan Jetpack Compose. +# 💧🏃 Step & Drink -📋 Deskripsi -Step & Drink adalah aplikasi mobile yang membantu pengguna untuk: +Aplikasi Android untuk tracking langkah harian dan asupan air minum. -Melacak langkah harian menggunakan sensor step counter bawaan smartphone -Mencatat asupan air minum untuk memenuhi kebutuhan hidrasi harian -Menetapkan dan memantau target langkah dan air minum yang dapat disesuaikan -Melihat riwayat aktivitas untuk evaluasi kebiasaan sehat +[![Kotlin](https://img.shields.io/badge/Kotlin-100%25-7F52FF)](https://kotlinlang.org) +[![Jetpack Compose](https://img.shields.io/badge/Jetpack_Compose-4285F4)](https://developer.android.com/jetpack/compose) +[![Min SDK](https://img.shields.io/badge/Min_SDK-26-3DDC84)](https://developer.android.com) -Aplikasi ini dibuat sebagai tugas akhir mata kuliah Pemrograman Bergerak dengan fokus pada penggunaan sensor hardware, database lokal, dan multi-halaman navigation. +--- -✨ Fitur -1. Home Screen +## 📱 Tentang -Dashboard dengan ringkasan aktivitas hari ini -Card interaktif untuk langkah dan air minum -Progress bar visual untuk tracking target -Greeting dengan nama pengguna +Step & Drink membantu kamu menjaga kesehatan dengan: +- 🏃 **Step Counter** - Track langkah harian secara real-time +- 💧 **Water Tracker** - Catat asupan air dengan mudah +- ⏰ **Smart Reminder** - Notifikasi otomatis untuk minum teratur +- 📊 **Progress Monitor** - Lihat pencapaian target harian -2. Step Tracker +--- -Real-time tracking langkah menggunakan sensor TYPE_STEP_COUNTER -Start/Stop tracking dengan tombol floating action button -Riwayat langkah 7 hari terakhir -Target langkah yang dapat disesuaikan +## ✨ Fitur Utama -3. Water Tracker +### Step Counter +- Real-time tracking dengan hardware sensor +- Target harian (default 10,000 langkah) +- History 7 hari +- Start/Stop control -Quick add buttons (250ml, 500ml, 1000ml) -Input custom untuk jumlah air -Riwayat minum harian dengan timestamp -Hapus record jika salah input -Target air minum yang dapat disesuaikan +### Water Tracker +Pilihan container cepat: +- 🥛 Gelas Kecil (200ml) +- 💧 Gelas Sedang (250ml) +- 🌊 Gelas Besar (350ml) +- 🚰 Botol Aqua (600ml) +- 🫗 Tumbler (500ml) -4. Profile & Settings +Plus input custom & history lengkap. -Edit nama pengguna -Ubah target langkah harian -Ubah target air minum harian -Informasi aplikasi +### Smart Reminder +- Notifikasi otomatis setelah 3 jam tidak minum +- Aktif jam 06:00 - 22:00 +- Battery efficient +- Bisa di-toggle ON/OFF +### Profile +- Input nama & berat badan +- Set target langkah & air harian +- Data tersimpan lokal -🛠️ Teknologi -Bahasa & Framework +--- -Kotlin - Bahasa pemrograman -Jetpack Compose - UI Framework -Material Design 3 - Design system +## 🛠️ Teknologi -Architecture +**Core:** +- Kotlin 100% +- Jetpack Compose +- Material Design 3 -MVVM (Model-View-ViewModel) -Repository Pattern -Clean Architecture +**Architecture:** +- MVVM Pattern +- Room Database +- DataStore Preferences +- WorkManager +- Coroutines & Flow -Database & Storage +--- -Room Database - Local database (SQLite) -DataStore Preferences - Settings storage +## 🚀 Instalasi -Components +```bash +# Clone repository +git clone https://github.com/HagaDalpintoGinting/Steps-Drink.git -Navigation Compose - Multi-halaman navigation -Sensor Manager - Akses hardware sensor (Step Counter) -ViewModel - State management -Kotlin Flow - Reactive data stream -Coroutines - Asynchronous operations +# Buka di Android Studio +# Sync Gradle +# Run aplikasi +``` +**Requirements:** +- Android Studio (latest) +- Min SDK 26 (Android 8.0) +- Device dengan step counter sensor (untuk fitur langkah) -📦 Struktur Project -stepdrink/ -├── data/ -│ ├── local/ -│ │ ├── entity/ # StepRecord, WaterRecord -│ │ ├── dao/ # StepDao, WaterDao -│ │ ├── database/ # AppDatabase -│ │ └── PreferencesManager.kt -│ └── repository/ # StepRepository, WaterRepository -├── ui/ -│ ├── screen/ # HomeScreen, StepsScreen, WaterScreen, ProfileScreen -│ ├── navigation/ # Navigation setup -│ ├── components/ # Reusable UI components -│ └── theme/ # App theming -├── viewmodel/ # StepViewModel, WaterViewModel, ProfileViewModel -├── sensor/ # StepCounterManager -├── util/ # DateUtils -└── MainActivity.kt +--- -🗄️ Database -step_records -Menyimpan data langkah harian +## 📖 Penggunaan -id - Primary key -date - Tanggal (yyyy-MM-dd) -steps - Jumlah langkah -timestamp - Waktu pencatatan +### Setup +1. Buka aplikasi +2. Masuk ke Profile +3. Input data pribadi & set target -water_records -Menyimpan data minum air +### Track Langkah +1. Buka halaman Steps +2. Tap tombol Start +3. Mulai berjalan -id - Primary key -date - Tanggal (yyyy-MM-dd) -amount - Jumlah air (ml) -timestamp - Waktu pencatatan +### Track Air +1. Buka halaman Water +2. Tap container (contoh: 🚰 600ml) +3. Atau klik "Input Custom" untuk jumlah lain -Preferences (DataStore) -Menyimpan pengaturan pengguna +### Aktifkan Reminder +1. Buka halaman Water +2. Toggle "Pengingat Minum" ke ON +3. Izinkan permission notifikasi -user_name - Nama pengguna -step_goal - Target langkah harian -water_goal - Target air minum harian (ml) +--- +## ⚙️ Konfigurasi -🚀 Instalasi -Requirements +Ubah settings reminder di `WaterReminderManager.kt`: +```kotlin +const val CHECK_INTERVAL_MINUTES = 30L // Interval cek +const val REMIND_AFTER_HOURS = 3 // Reminder setelah X jam +const val ACTIVE_START_HOUR = 6 // Jam mulai +const val ACTIVE_END_HOUR = 22 // Jam selesai +``` -Android Studio (versi terbaru) -Minimum SDK: API 26 (Android 8.0) -Kotlin 1.9.22 +--- -Cara Menjalankan +## 📝 Catatan -Clone atau download project -Buka di Android Studio -Sync Gradle (File → Sync Project with Gradle Files) -Build project (Build → Make Project) -Run di device atau emulator -Izinkan permission ACTIVITY_RECOGNITION saat diminta +**Tested on:** Samsung Galaxy A35 5G (Android 14) +**Limitations:** +- Step counter butuh device fisik (emulator tidak support) +- Data tersimpan lokal saja +- Reminder bergantung pada battery optimization device -📱 Cara Penggunaan +**Troubleshooting:** +- Steps tidak terhitung? Pastikan permission ACTIVITY_RECOGNITION diizinkan +- Reminder tidak muncul? Check permission POST_NOTIFICATIONS dan pastikan jam aktif -Tracking Langkah +--- -Buka Step Tracker -Klik tombol Play -Izinkan permission -Mulai berjalan -Data otomatis tersimpan +## 🎓 Project +Tugas Akhir Pemrograman Bergerak dengan requirement: +- ✅ Multi-screen +- ✅ Hardware sensor +- ✅ Local database +- ✅ Kotlin & Jetpack Compose -Catat Air Minum +--- -Buka Water Tracker -Klik quick add atau tombol + -Input jumlah air -Data tersimpan dengan timestamp +## 👨‍💻 Developer +**Haga Dalpinto Ginting** -Ubah Target +GitHub: [@HagaDalpintoGinting](https://github.com/HagaDalpintoGinting) -Buka Profile -Klik card yang ingin diubah -Input nilai baru -Simpan +--- +## 📄 License +MIT License © 2025 +--- -🎯 Tujuan Project -Project ini dibuat untuk memenuhi tugas akhir mata kuliah Pemrograman Bergerak dengan requirements: +
-✅ Multi-halaman (4 screens) -✅ Penggunaan sensor (Step Counter) -✅ Database lokal (Room) -✅ IDE Android Studio -✅ Bahasa Kotlin -✅ UI Framework Jetpack Compose +Made with ❤️ using Kotlin & Jetpack Compose - -👨‍💻 Developer -Nama: Haga Dalpinto Ginting - -📝 Catatan - -Aplikasi memerlukan device dengan sensor step counter untuk fitur tracking langkah -Emulator Android biasanya tidak memiliki sensor step counter -Data disimpan secara lokal di device -Aplikasi masih dalam tahap pengembangan dan dapat dikembangkan lebih lanjut - - -📄 License -MIT License - Copyright (c) 2025 - -Made with Kotlin & Jetpack Compose +
\ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 687caca..d1f5e24 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -51,6 +51,8 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) + // WorkManager untuk reminder + implementation("androidx.work:work-runtime-ktx:2.9.0") // Core Android implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dc0ece6..d70af82 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,9 @@ + + + > + fun getWaterByDate(date: String): Flow> - @Query("SELECT SUM(amount) FROM water_records WHERE date = :date") - fun getTotalWaterByDate(date: String): Flow - - @Query("SELECT * FROM water_records ORDER BY date DESC LIMIT 7") + /** + * Get last 7 days water records + */ + @Query("SELECT * FROM water_records ORDER BY date DESC, timestamp DESC LIMIT 50") fun getLast7DaysWater(): Flow> - @Delete - suspend fun deleteWaterRecord(waterRecord: WaterRecord) + /** + * Get all water records (for backup/export) + */ + @Query("SELECT * FROM water_records ORDER BY date DESC, timestamp DESC") + fun getAllWater(): Flow> + + /** + * Delete all water records (for reset) + */ + @Query("DELETE FROM water_records") + suspend fun deleteAllWater() } \ No newline at end of file diff --git a/app/src/main/java/data/repository/WaterRepository.kt b/app/src/main/java/data/repository/WaterRepository.kt index 9dd2cc5..1b75e1c 100644 --- a/app/src/main/java/data/repository/WaterRepository.kt +++ b/app/src/main/java/data/repository/WaterRepository.kt @@ -6,23 +6,36 @@ import kotlinx.coroutines.flow.Flow class WaterRepository(private val waterDao: WaterDao) { - fun getWaterRecordsByDate(date: String): Flow> { - return waterDao.getWaterRecordsByDate(date) + /** + * Get all water records for a specific date + */ + fun getWaterByDate(date: String): Flow> { + return waterDao.getWaterByDate(date) } - fun getTotalWaterByDate(date: String): Flow { - return waterDao.getTotalWaterByDate(date) + /** + * Insert new water record + */ + suspend fun insertWater(date: String, amount: Int) { + val record = WaterRecord( + date = date, + amount = amount, + timestamp = System.currentTimeMillis() + ) + waterDao.insertWater(record) } + /** + * Delete water record + */ + suspend fun deleteWater(record: WaterRecord) { + waterDao.deleteWater(record) + } + + /** + * Get last 7 days water records + */ fun getLast7DaysWater(): Flow> { return waterDao.getLast7DaysWater() } - - suspend fun addWaterRecord(date: String, amount: Int) { - waterDao.insertWaterRecord(WaterRecord(date = date, amount = amount)) - } - - suspend fun deleteWaterRecord(waterRecord: WaterRecord) { - waterDao.deleteWaterRecord(waterRecord) - } } \ No newline at end of file diff --git a/app/src/main/java/ui/navigation/NavGraph.kt b/app/src/main/java/ui/navigation/NavGraph.kt index 79a2a49..7f8c60c 100644 --- a/app/src/main/java/ui/navigation/NavGraph.kt +++ b/app/src/main/java/ui/navigation/NavGraph.kt @@ -5,15 +5,20 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import com.example.stepdrink.ui.screen.home.HomeScreen +import com.example.stepdrink.ui.screen.profile.ProfileScreen +import com.example.stepdrink.ui.screen.splash.SplashScreen import com.example.stepdrink.ui.screen.steps.StepsScreen import com.example.stepdrink.ui.screen.water.WaterScreen -import com.example.stepdrink.ui.screen.profile.ProfileScreen +import com.example.stepdrink.viewmodel.ProfileViewModel import com.example.stepdrink.viewmodel.StepViewModel import com.example.stepdrink.viewmodel.WaterViewModel -import com.example.stepdrink.viewmodel.ProfileViewModel +/** + * AppNavigation - Single NavHost untuk semua screen (termasuk splash) + * Ini menghindari nested NavHost yang menyebabkan ViewModelStore error + */ @Composable -fun NavGraph( +fun AppNavigation( navController: NavHostController, stepViewModel: StepViewModel, waterViewModel: WaterViewModel, @@ -21,8 +26,14 @@ fun NavGraph( ) { NavHost( navController = navController, - startDestination = Screen.Home.route + startDestination = Screen.Splash.route ) { + // Splash Screen + composable(Screen.Splash.route) { + SplashScreen(navController = navController) + } + + // Home Screen composable(Screen.Home.route) { HomeScreen( navController = navController, @@ -32,6 +43,7 @@ fun NavGraph( ) } + // Steps Screen composable(Screen.Steps.route) { StepsScreen( navController = navController, @@ -39,17 +51,20 @@ fun NavGraph( ) } + // Water Screen composable(Screen.Water.route) { WaterScreen( navController = navController, viewModel = waterViewModel ) } + + // Profile Screen composable(Screen.Profile.route) { ProfileScreen( navController = navController, - viewModel = profileViewModel ) + viewModel = profileViewModel + ) + } } -} - } \ No newline at end of file diff --git a/app/src/main/java/ui/navigation/Screen.kt b/app/src/main/java/ui/navigation/Screen.kt index 14ef829..f4bb82a 100644 --- a/app/src/main/java/ui/navigation/Screen.kt +++ b/app/src/main/java/ui/navigation/Screen.kt @@ -1,6 +1,7 @@ package com.example.stepdrink.ui.navigation sealed class Screen(val route: String) { + object Splash : Screen("splash") object Home : Screen("home") object Steps : Screen("steps") object Water : Screen("water") diff --git a/app/src/main/java/ui/screen/splash/SplashScreen.kt b/app/src/main/java/ui/screen/splash/SplashScreen.kt index 60f140d..c0c3586 100644 --- a/app/src/main/java/ui/screen/splash/SplashScreen.kt +++ b/app/src/main/java/ui/screen/splash/SplashScreen.kt @@ -1,2 +1,78 @@ -package ui.screen.splash +package com.example.stepdrink.ui.screen.splash +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import com.example.stepdrink.R +import kotlinx.coroutines.delay + +@Composable +fun SplashScreen(navController: NavController) { + LaunchedEffect(key1 = true) { + delay(2500) // Show splash for 2.5 seconds + navController.navigate("home") { + popUpTo("splash") { inclusive = true } + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color(0xFF7BC8BC), // Mint top + Color(0xFF5DADE2) // Blue bottom + ) + ) + ), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // App Logo - Ganti dengan logo kamu + Image( + painter = painterResource(id = R.drawable.icon_apps_steps_drink), + contentDescription = "App Logo", + modifier = Modifier + .size(180.dp) + .padding(bottom = 32.dp), + contentScale = ContentScale.Fit + ) + + // App Name + Text( + text = "Step & Drink", + fontSize = 40.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + letterSpacing = 1.sp + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Tagline + Text( + text = "Track Your Health Journey", + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = Color.White.copy(alpha = 0.9f), + letterSpacing = 0.5.sp + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ui/screen/water/WaterScreen.kt b/app/src/main/java/ui/screen/water/WaterScreen.kt index 8d31cb0..c75e9db 100644 --- a/app/src/main/java/ui/screen/water/WaterScreen.kt +++ b/app/src/main/java/ui/screen/water/WaterScreen.kt @@ -1,17 +1,30 @@ package com.example.stepdrink.ui.screen.water +import android.Manifest +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import com.example.stepdrink.util.WaterReminderManager import com.example.stepdrink.viewmodel.WaterViewModel import java.text.SimpleDateFormat import java.util.* @@ -22,11 +35,35 @@ fun WaterScreen( navController: NavController, viewModel: WaterViewModel ) { - val todayRecords by viewModel.todayWaterRecords.collectAsState() - val totalWater by viewModel.todayTotalWater.collectAsState() - val waterGoal by viewModel.dailyGoal.collectAsState() + val context = LocalContext.current + val reminderManager = remember { WaterReminderManager(context) } - var showDialog by remember { mutableStateOf(false) } + val todayWater by viewModel.todayTotalWater.collectAsState() + val waterGoal by viewModel.dailyGoal.collectAsState() + val todayRecords by viewModel.todayWaterRecords.collectAsState() + + var showCustomDialog by remember { mutableStateOf(false) } + var showSuccessAnimation by remember { mutableStateOf(false) } + var reminderEnabled by remember { mutableStateOf(false) } + + // Permission launcher untuk notifikasi (Android 13+) + val notificationPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + reminderManager.scheduleReminder() + reminderEnabled = true + } + } + + // Success animation + LaunchedEffect(todayRecords.size) { + if (todayRecords.isNotEmpty()) { + showSuccessAnimation = true + kotlinx.coroutines.delay(800) + showSuccessAnimation = false + } + } Scaffold( topBar = { @@ -36,160 +73,408 @@ fun WaterScreen( IconButton(onClick = { navController.popBackStack() }) { Icon(Icons.Default.ArrowBack, "Kembali") } - } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) ) - }, - floatingActionButton = { - FloatingActionButton( - onClick = { showDialog = true } - ) { - Icon(Icons.Default.Add, "Tambah") - } } ) { paddingValues -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - item { - // Water Progress Card - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Main Progress Card + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color.Transparent), + elevation = CardDefaults.cardElevation(4.dp) ) { - Icon( - imageVector = Icons.Default.WaterDrop, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.tertiary - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = "${totalWater}ml", - style = MaterialTheme.typography.displayLarge, - fontWeight = FontWeight.Bold - ) - - Text( - text = "Air Minum Hari Ini", - style = MaterialTheme.typography.titleMedium - ) - - Spacer(modifier = Modifier.height(16.dp)) - - LinearProgressIndicator( - progress = { totalWater.toFloat() / waterGoal.toFloat() }, + Box( modifier = Modifier .fillMaxWidth() - .height(8.dp), + .background( + Brush.verticalGradient( + listOf(Color(0xFF2196F3), Color(0xFF64B5F6)) + ) + ) + .padding(24.dp) + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + Icons.Default.WaterDrop, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = Color.White + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "${todayWater}ml", + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = Color.White + ) + + Text( + text = "Air Minum Hari Ini", + style = MaterialTheme.typography.titleMedium, + color = Color.White.copy(alpha = 0.9f) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + LinearProgressIndicator( + progress = { todayWater.toFloat() / waterGoal.toFloat() }, + modifier = Modifier + .fillMaxWidth() + .height(10.dp) + .clip(MaterialTheme.shapes.small), + color = Color.White, + trackColor = Color.White.copy(alpha = 0.3f) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Target: ${waterGoal}ml", + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.9f) + ) + + val remaining = waterGoal - todayWater + if (remaining > 0) { + Text( + text = "Sisa ${remaining}ml lagi!", + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.8f) + ) + } else { + Text( + text = "🎉 Target tercapai!", + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } + + // Reminder Settings Card + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Notifications, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + Column { + Text( + "Pengingat Minum", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + "Ingatkan jika 3 jam tidak minum", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) + ) + } + } - Spacer(modifier = Modifier.height(8.dp)) + Switch( + checked = reminderEnabled, + onCheckedChange = { enabled -> + if (enabled) { + // Check & request notification permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + reminderManager.scheduleReminder() + reminderEnabled = true + } + } else { + reminderManager.cancelReminder() + reminderEnabled = false + } + } + ) + } + if (reminderEnabled) { + Spacer(modifier = Modifier.height(8.dp)) + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + shape = MaterialTheme.shapes.small + ) { + Row( + modifier = Modifier.padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Info, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + "Aktif jam 06:00 - 22:00", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + } + } + + // Container selection + item { + Text( + text = "Pilih Wadah:", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + } + + item { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + // Row 1 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + viewModel.presetContainers.take(2).forEach { container -> + ContainerCard( + container = container, + modifier = Modifier.weight(1f), + onClick = { viewModel.addWaterFromContainer(container) } + ) + } + } + + // Row 2 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + viewModel.presetContainers.drop(2).take(2).forEach { container -> + ContainerCard( + container = container, + modifier = Modifier.weight(1f), + onClick = { viewModel.addWaterFromContainer(container) } + ) + } + } + + // Row 3 - Last item centered + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + ContainerCard( + container = viewModel.presetContainers[4], + modifier = Modifier.weight(1f), + onClick = { viewModel.addWaterFromContainer(viewModel.presetContainers[4]) } + ) + Spacer(modifier = Modifier.weight(1f)) + } + } + } + + // Custom amount + item { + OutlinedCard( + onClick = { showCustomDialog = true }, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Settings, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Input Jumlah Custom", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } + } + + // History + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { Text( - text = "Target: ${waterGoal}ml", - style = MaterialTheme.typography.bodyMedium + "Riwayat Hari Ini", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + "${todayRecords.size} kali minum", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + if (todayRecords.isEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("💧", style = MaterialTheme.typography.displayMedium) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Belum ada riwayat hari ini", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + "Yuk mulai minum air!", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } + } + } else { + items(todayRecords.sortedByDescending { it.timestamp }) { record -> + WaterRecordItem( + record = record, + container = viewModel.getContainerByAmount(record.amount), + onDelete = { viewModel.deleteWater(record) } ) } } } - item { - // Quick Add Buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + // Success animation + AnimatedVisibility( + visible = showSuccessAnimation, + enter = scaleIn(initialScale = 0.3f) + fadeIn(), + exit = scaleOut(targetScale = 1.5f) + fadeOut(), + modifier = Modifier.align(Alignment.Center) + ) { + Surface( + shape = CircleShape, + color = Color.White, + shadowElevation = 8.dp, + modifier = Modifier.size(100.dp) ) { - QuickAddButton( - amount = 250, - onClick = { viewModel.addWater(250) }, - modifier = Modifier.weight(1f) - ) - QuickAddButton( - amount = 500, - onClick = { viewModel.addWater(500) }, - modifier = Modifier.weight(1f) - ) - QuickAddButton( - amount = 1000, - onClick = { viewModel.addWater(1000) }, - modifier = Modifier.weight(1f) - ) + Box(contentAlignment = Alignment.Center) { + Text("💧", style = MaterialTheme.typography.displayLarge) + } } } - - item { - Text( - text = "Riwayat Hari Ini", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - } - - items(todayRecords) { record -> - WaterRecordItem( - amount = record.amount, - timestamp = record.timestamp, - onDelete = { viewModel.deleteWaterRecord(record) } - ) - } } } - if (showDialog) { - AddWaterDialog( - onDismiss = { showDialog = false }, + if (showCustomDialog) { + CustomAmountDialog( + onDismiss = { showCustomDialog = false }, onConfirm = { amount -> viewModel.addWater(amount) - showDialog = false + showCustomDialog = false } ) } } @Composable -fun QuickAddButton( - amount: Int, - onClick: () -> Unit, - modifier: Modifier = Modifier +fun ContainerCard( + container: com.example.stepdrink.viewmodel.WaterContainer, + modifier: Modifier = Modifier, + onClick: () -> Unit ) { - OutlinedButton( + Card( onClick = onClick, - modifier = modifier + modifier = modifier.height(120.dp), + colors = CardDefaults.cardColors( + containerColor = Color(container.color).copy(alpha = 0.1f) + ), + elevation = CardDefaults.cardElevation(2.dp) ) { Column( - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier + .fillMaxSize() + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center ) { - Icon(Icons.Default.WaterDrop, contentDescription = null) - Text("${amount}ml") + Text( + text = container.emoji, + style = MaterialTheme.typography.displaySmall + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = container.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = Color(container.color) + ) + Text( + text = "${container.amount}ml", + style = MaterialTheme.typography.bodyMedium, + color = Color(container.color).copy(alpha = 0.8f) + ) } } } @Composable fun WaterRecordItem( - amount: Int, - timestamp: Long, + record: com.example.stepdrink.data.local.entity.WaterRecord, + container: com.example.stepdrink.viewmodel.WaterContainer?, onDelete: () -> Unit ) { - val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } - val time = timeFormat.format(Date(timestamp)) - Card( - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(1.dp) ) { Row( modifier = Modifier @@ -199,31 +484,51 @@ fun WaterRecordItem( verticalAlignment = Alignment.CenterVertically ) { Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically ) { - Icon( - imageVector = Icons.Default.WaterDrop, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(48.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = container?.emoji ?: "💧", + style = MaterialTheme.typography.titleLarge + ) + } + } Column { Text( - text = "${amount}ml", + text = container?.name ?: "Custom", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold ) - Text( - text = time, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${record.amount}ml", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + Text("•", style = MaterialTheme.typography.bodySmall) + Text( + text = SimpleDateFormat("HH:mm", Locale.getDefault()) + .format(Date(record.timestamp)), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } IconButton(onClick = onDelete) { Icon( - imageVector = Icons.Default.Delete, + Icons.Default.Delete, contentDescription = "Hapus", tint = MaterialTheme.colorScheme.error ) @@ -233,7 +538,7 @@ fun WaterRecordItem( } @Composable -fun AddWaterDialog( +fun CustomAmountDialog( onDismiss: () -> Unit, onConfirm: (Int) -> Unit ) { @@ -241,21 +546,24 @@ fun AddWaterDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text("Tambah Air Minum") }, + icon = { Icon(Icons.Default.WaterDrop, contentDescription = null) }, + title = { Text("Input Jumlah Air") }, text = { OutlinedTextField( value = amount, onValueChange = { amount = it.filter { char -> char.isDigit() } }, label = { Text("Jumlah (ml)") }, - singleLine = true + singleLine = true, + modifier = Modifier.fillMaxWidth(), + suffix = { Text("ml") } ) }, confirmButton = { - TextButton( + Button( onClick = { - val amountInt = amount.toIntOrNull() - if (amountInt != null && amountInt > 0) { - onConfirm(amountInt) + val value = amount.toIntOrNull() + if (value != null && value > 0) { + onConfirm(value) } } ) { diff --git a/app/src/main/java/ui/theme/theme.kt b/app/src/main/java/ui/theme/theme.kt index 86930df..4384ee2 100644 --- a/app/src/main/java/ui/theme/theme.kt +++ b/app/src/main/java/ui/theme/theme.kt @@ -1,46 +1,214 @@ package com.example.stepdrink.ui.theme -import android.app.Activity import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalView -import androidx.core.view.WindowCompat +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp -private val DarkColorScheme = darkColorScheme( - primary = androidx.compose.ui.graphics.Color(0xFF90CAF9), - secondary = androidx.compose.ui.graphics.Color(0xFF81C784), - tertiary = androidx.compose.ui.graphics.Color(0xFF64B5F6) +// Color palette dari icon +val TealPrimary = Color(0xFF7BC8BC) // Mint/Teal utama +val TealLight = Color(0xFFA8E6CF) // Light mint +val TealDark = Color(0xFF4A9B8E) // Dark teal +val BluePrimary = Color(0xFF5DADE2) // Blue dari gradient +val NavyAccent = Color(0xFF2C3E50) // Dark blue accent +val OrangeAccent = Color(0xFFF39C12) // Orange highlight +val Background = Color(0xFFF5F9F8) // Very light teal background + +// Light Theme Colors +private val LightColorScheme = lightColorScheme( + primary = TealPrimary, + onPrimary = Color.White, + primaryContainer = TealLight, + onPrimaryContainer = NavyAccent, + + secondary = BluePrimary, + onSecondary = Color.White, + secondaryContainer = Color(0xFFE3F2FD), + onSecondaryContainer = NavyAccent, + + tertiary = OrangeAccent, + onTertiary = Color.White, + tertiaryContainer = Color(0xFFFFE6CC), + onTertiaryContainer = Color(0xFF8B5A00), + + background = Background, + onBackground = NavyAccent, + + surface = Color.White, + onSurface = NavyAccent, + surfaceVariant = Color(0xFFE8F5F3), + onSurfaceVariant = Color(0xFF5A6A6C), + + error = Color(0xFFE74C3C), + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF93000A), + + outline = Color(0xFFB0BEC5), + outlineVariant = Color(0xFFCFD8DC) ) -private val LightColorScheme = lightColorScheme( - primary = androidx.compose.ui.graphics.Color(0xFF1976D2), - secondary = androidx.compose.ui.graphics.Color(0xFF388E3C), - tertiary = androidx.compose.ui.graphics.Color(0xFF0288D1) +// Dark Theme Colors +private val DarkColorScheme = darkColorScheme( + primary = TealPrimary, + onPrimary = NavyAccent, + primaryContainer = TealDark, + onPrimaryContainer = TealLight, + + secondary = BluePrimary, + onSecondary = NavyAccent, + secondaryContainer = Color(0xFF1E3A5F), + onSecondaryContainer = Color(0xFFB3D9FF), + + tertiary = OrangeAccent, + onTertiary = NavyAccent, + tertiaryContainer = Color(0xFF8B5A00), + onTertiaryContainer = Color(0xFFFFD9A3), + + background = Color(0xFF1A2329), + onBackground = Color(0xFFE1E8E8), + + surface = Color(0xFF1F2A30), + onSurface = Color(0xFFE1E8E8), + surfaceVariant = Color(0xFF2C3E50), + onSurfaceVariant = Color(0xFFB8C5C8), + + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), + + outline = Color(0xFF8B9A9D), + outlineVariant = Color(0xFF3F5054) +) + +// Typography untuk Material3 +private val AppTypography = androidx.compose.material3.Typography( + displayLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp + ), + displayMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp + ), + displaySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp + ), + headlineLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp + ), + headlineSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + titleMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + titleSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + bodySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp + ), + labelLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + labelMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) ) @Composable fun StepDrinkTheme( darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = false, // Disable dynamic color untuk konsistensi content: @Composable () -> Unit ) { - val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme - val view = LocalView.current - - if (!view.isInEditMode) { - SideEffect { - val window = (view.context as Activity).window - window.statusBarColor = colorScheme.primary.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme - } + val colorScheme = when { + darkTheme -> DarkColorScheme + else -> LightColorScheme } MaterialTheme( colorScheme = colorScheme, + typography = AppTypography, // Gunakan AppTypography yang sudah didefinisikan content = content ) } \ No newline at end of file diff --git a/app/src/main/java/util/WaterReminder.kt b/app/src/main/java/util/WaterReminder.kt new file mode 100644 index 0000000..a9778bc --- /dev/null +++ b/app/src/main/java/util/WaterReminder.kt @@ -0,0 +1,168 @@ +package com.example.stepdrink.util + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.work.* +import com.example.stepdrink.MainActivity +import com.example.stepdrink.R +import com.example.stepdrink.data.local.database.AppDatabase +import com.example.stepdrink.data.repository.WaterRepository +import kotlinx.coroutines.flow.first +import java.util.concurrent.TimeUnit + +/** + * WaterReminderManager - Mengelola reminder untuk minum air + * + * Fitur: + * - Cek otomatis setiap 30 menit + * - Kirim notifikasi jika sudah 3 jam tidak minum + * - Hanya aktif jam 6 pagi - 10 malam + * - Motivational messages yang bervariasi + */ +class WaterReminderManager( val context: Context) { + + companion object { + const val WORK_NAME = "WaterReminderWork" + const val CHANNEL_ID = "water_reminder_channel" + const val NOTIFICATION_ID = 1001 + + // Reminder settings + const val CHECK_INTERVAL_MINUTES = 30L // Cek setiap 30 menit + const val REMIND_AFTER_HOURS = 3 // Remind setelah 3 jam tidak minum + const val ACTIVE_START_HOUR = 6 // Mulai jam 6 pagi + const val ACTIVE_END_HOUR = 22 // Sampai jam 10 malam + } + + fun scheduleReminder() { + createNotificationChannel() + + val constraints = Constraints.Builder() + .setRequiresBatteryNotLow(true) // Jangan ganggu kalau battery low + .build() + + val reminderWork = PeriodicWorkRequestBuilder( + CHECK_INTERVAL_MINUTES, TimeUnit.MINUTES + ) + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, // Keep existing jika sudah ada + reminderWork + ) + } + + fun cancelReminder() { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Pengingat Minum Air", + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = "Notifikasi pengingat untuk minum air" + enableVibration(true) + } + + val notificationManager = context.getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + } + } +} + +/** + * Worker yang menjalankan logic reminder + */ +class WaterReminderWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + return try { + checkAndSendReminder() + Result.success() + } catch (e: Exception) { + Result.retry() + } + } + + private suspend fun checkAndSendReminder() { + // Cek apakah dalam jam aktif + val currentHour = java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY) + if (currentHour < WaterReminderManager.ACTIVE_START_HOUR || + currentHour >= WaterReminderManager.ACTIVE_END_HOUR) { + return // Jangan ganggu di luar jam aktif + } + + // Get last drink time dari database + val database = AppDatabase.getDatabase(applicationContext) + val repository = WaterRepository(database.waterDao()) + val todayRecords = repository.getWaterByDate(DateUtils.getCurrentDate()).first() + + if (todayRecords.isEmpty()) { + // Belum minum sama sekali hari ini + if (currentHour >= 8) { // Kalau udah jam 8 pagi + sendReminderNotification( + "Yuk Mulai Minum Air! 💧", + "Kamu belum minum air hari ini. Tubuhmu butuh hidrasi!" + ) + } + return + } + + // Cek waktu terakhir minum + val lastDrinkTime = todayRecords.maxOf { it.timestamp } + val hoursSinceLastDrink = (System.currentTimeMillis() - lastDrinkTime) / (1000 * 60 * 60) + + if (hoursSinceLastDrink >= WaterReminderManager.REMIND_AFTER_HOURS) { + // Sudah 3 jam tidak minum! + val message = getMotivationalMessage(hoursSinceLastDrink.toInt()) + sendReminderNotification("Waktunya Minum Air! 💧", message) + } + } + + private fun getMotivationalMessage(hoursSince: Int): String { + return when { + hoursSince >= 5 -> "Sudah ${hoursSince} jam tidak minum! Tubuhmu sangat butuh air sekarang! 🚨" + hoursSince >= 4 -> "Sudah ${hoursSince} jam tidak minum. Yuk hidrasi sekarang! ⏰" + else -> "Sudah ${hoursSince} jam tidak minum. Jangan lupa minum air ya! 💙" + } + } + + private fun sendReminderNotification(title: String, message: String) { + val intent = Intent(applicationContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + putExtra("open_water_screen", true) // Optional: buka langsung water screen + } + + val pendingIntent = PendingIntent.getActivity( + applicationContext, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(applicationContext, WaterReminderManager.CHANNEL_ID) + .setSmallIcon(R.drawable.ic_water_notification) + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setVibrate(longArrayOf(0, 500, 200, 500)) // Vibration pattern + .build() + + val notificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(WaterReminderManager.NOTIFICATION_ID, notification) + } +} \ No newline at end of file diff --git a/app/src/main/java/viewmodel/WaterViewModel.kt b/app/src/main/java/viewmodel/WaterViewModel.kt index 017bac0..e6fc8db 100644 --- a/app/src/main/java/viewmodel/WaterViewModel.kt +++ b/app/src/main/java/viewmodel/WaterViewModel.kt @@ -3,47 +3,79 @@ package com.example.stepdrink.viewmodel import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import com.example.stepdrink.data.local.database.AppDatabase import com.example.stepdrink.data.local.PreferencesManager +import com.example.stepdrink.data.local.database.AppDatabase import com.example.stepdrink.data.local.entity.WaterRecord import com.example.stepdrink.data.repository.WaterRepository import com.example.stepdrink.util.DateUtils import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +// Data class untuk preset containers +data class WaterContainer( + val emoji: String, + val name: String, + val amount: Int, + val color: Long = 0xFF2196F3 +) + class WaterViewModel(application: Application) : AndroidViewModel(application) { - private val repository: WaterRepository = WaterRepository( - AppDatabase.getDatabase(application).waterDao() - ) - + private val repository: WaterRepository private val preferencesManager = PreferencesManager(application) val dailyGoal: StateFlow = preferencesManager.waterGoal .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 2000) - val todayWaterRecords: StateFlow> = - repository.getWaterRecordsByDate(DateUtils.getCurrentDate()) + val todayWaterRecords: StateFlow> + val todayTotalWater: StateFlow + + // SIMPLE CONTAINERS - Kategori yang clear & sederhana + val presetContainers = listOf( + WaterContainer("🥛", "Gelas Kecil", 200, 0xFF64B5F6), // Small glass + WaterContainer("💧", "Gelas Sedang", 250, 0xFF42A5F5), // Medium glass (STANDARD) + WaterContainer("🌊", "Gelas Besar", 350, 0xFF2196F3), // Large glass + WaterContainer("🚰", "Botol Aqua", 600, 0xFF1976D2), // Aqua bottle (MOST COMMON) + WaterContainer("🫗", "Tumbler", 500, 0xFF1565C0), // Tumbler + ) + + init { + val database = AppDatabase.getDatabase(application) + repository = WaterRepository(database.waterDao()) + + todayWaterRecords = repository.getWaterByDate(DateUtils.getCurrentDate()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - val todayTotalWater: StateFlow = - repository.getTotalWaterByDate(DateUtils.getCurrentDate()) - .map { it ?: 0 } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) - - val last7DaysWater: StateFlow> = - repository.getLast7DaysWater() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + todayTotalWater = todayWaterRecords.map { records -> + records.sumOf { it.amount } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + } fun addWater(amount: Int) { viewModelScope.launch { - repository.addWaterRecord(DateUtils.getCurrentDate(), amount) + repository.insertWater(DateUtils.getCurrentDate(), amount) } } - fun deleteWaterRecord(record: WaterRecord) { + fun addWaterFromContainer(container: WaterContainer) { + addWater(container.amount) + } + + fun deleteWater(record: WaterRecord) { viewModelScope.launch { - repository.deleteWaterRecord(record) + repository.deleteWater(record) } } + + fun getProgressPercentage(): Float { + return (todayTotalWater.value.toFloat() / dailyGoal.value.toFloat()).coerceIn(0f, 1f) + } + + fun getLastDrinkTime(): Long? { + return todayWaterRecords.value.maxByOrNull { it.timestamp }?.timestamp + } + + fun getContainerByAmount(amount: Int): WaterContainer? { + return presetContainers.find { it.amount == amount } + } } \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/ic_water_notification.png b/app/src/main/res/drawable-hdpi/ic_water_notification.png new file mode 100644 index 0000000..6d14329 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_water_notification.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_water_notification.png b/app/src/main/res/drawable-mdpi/ic_water_notification.png new file mode 100644 index 0000000..ba42936 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_water_notification.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_water_notification.png b/app/src/main/res/drawable-xhdpi/ic_water_notification.png new file mode 100644 index 0000000..3bbf725 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_water_notification.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_water_notification.png b/app/src/main/res/drawable-xxhdpi/ic_water_notification.png new file mode 100644 index 0000000..3f55c5f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_water_notification.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_water_notification.png b/app/src/main/res/drawable-xxxhdpi/ic_water_notification.png new file mode 100644 index 0000000..41dae26 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_water_notification.png differ diff --git a/app/src/main/res/drawable/icon_apps_steps_drink.jpeg b/app/src/main/res/drawable/icon_apps_steps_drink.jpeg new file mode 100644 index 0000000..3651f5f Binary files /dev/null and b/app/src/main/res/drawable/icon_apps_steps_drink.jpeg differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 6f3b755..036d09b 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,5 @@ - - - + + \ 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 index 6f3b755..036d09b 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,6 +1,5 @@ - - - + + \ 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 index c209e78..8f9f52d 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp 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 index b2dfe3d..5f3a584 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp 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 index 4f0f1d6..92bf645 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp 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 index 62b611d..9a11665 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp 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 index 948a307..24a024a 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp 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 index 1b9a695..c1df355 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp 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 index 28d4b77..70406f3 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp 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 index 9287f50..e261f3c 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp 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 index aa7d642..e0cbe12 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp 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 index 9126ae3..735359c 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ