update README and WaterReminder.kt

This commit is contained in:
HagaDalpintoGinting 2026-01-11 20:43:28 +07:00
parent a226686431
commit 1aeaf58b5d
31 changed files with 1148 additions and 359 deletions

254
README.md
View File

@ -1,187 +1,165 @@
Step & Drink # 💧🏃 Step & Drink
Aplikasi Android untuk tracking langkah harian dan kebutuhan air minum menggunakan Kotlin dan Jetpack Compose.
📋 Deskripsi Aplikasi Android untuk tracking langkah harian dan asupan air minum.
Step & Drink adalah aplikasi mobile yang membantu pengguna untuk:
Melacak langkah harian menggunakan sensor step counter bawaan smartphone [![Kotlin](https://img.shields.io/badge/Kotlin-100%25-7F52FF)](https://kotlinlang.org)
Mencatat asupan air minum untuk memenuhi kebutuhan hidrasi harian [![Jetpack Compose](https://img.shields.io/badge/Jetpack_Compose-4285F4)](https://developer.android.com/jetpack/compose)
Menetapkan dan memantau target langkah dan air minum yang dapat disesuaikan [![Min SDK](https://img.shields.io/badge/Min_SDK-26-3DDC84)](https://developer.android.com)
Melihat riwayat aktivitas untuk evaluasi kebiasaan sehat
Aplikasi ini dibuat sebagai tugas akhir mata kuliah Pemrograman Bergerak dengan fokus pada penggunaan sensor hardware, database lokal, dan multi-halaman navigation. ---
✨ Fitur ## 📱 Tentang
1. Home Screen
Dashboard dengan ringkasan aktivitas hari ini Step & Drink membantu kamu menjaga kesehatan dengan:
Card interaktif untuk langkah dan air minum - 🏃 **Step Counter** - Track langkah harian secara real-time
Progress bar visual untuk tracking target - 💧 **Water Tracker** - Catat asupan air dengan mudah
Greeting dengan nama pengguna - ⏰ **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 ## ✨ Fitur Utama
Start/Stop tracking dengan tombol floating action button
Riwayat langkah 7 hari terakhir
Target langkah yang dapat disesuaikan
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) ### Water Tracker
Input custom untuk jumlah air Pilihan container cepat:
Riwayat minum harian dengan timestamp - 🥛 Gelas Kecil (200ml)
Hapus record jika salah input - 💧 Gelas Sedang (250ml)
Target air minum yang dapat disesuaikan - 🌊 Gelas Besar (350ml)
- 🚰 Botol Aqua (600ml)
- 🫗 Tumbler (500ml)
4. Profile & Settings Plus input custom & history lengkap.
Edit nama pengguna ### Smart Reminder
Ubah target langkah harian - Notifikasi otomatis setelah 3 jam tidak minum
Ubah target air minum harian - Aktif jam 06:00 - 22:00
Informasi aplikasi - 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 ## 🛠️ Teknologi
Jetpack Compose - UI Framework
Material Design 3 - Design system
Architecture **Core:**
- Kotlin 100%
- Jetpack Compose
- Material Design 3
MVVM (Model-View-ViewModel) **Architecture:**
Repository Pattern - MVVM Pattern
Clean Architecture - Room Database
- DataStore Preferences
- WorkManager
- Coroutines & Flow
Database & Storage ---
Room Database - Local database (SQLite) ## 🚀 Instalasi
DataStore Preferences - Settings storage
Components ```bash
# Clone repository
git clone https://github.com/HagaDalpintoGinting/Steps-Drink.git
Navigation Compose - Multi-halaman navigation # Buka di Android Studio
Sensor Manager - Akses hardware sensor (Step Counter) # Sync Gradle
ViewModel - State management # Run aplikasi
Kotlin Flow - Reactive data stream ```
Coroutines - Asynchronous operations
**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 ## 📖 Penggunaan
step_records
Menyimpan data langkah harian
id - Primary key ### Setup
date - Tanggal (yyyy-MM-dd) 1. Buka aplikasi
steps - Jumlah langkah 2. Masuk ke Profile
timestamp - Waktu pencatatan 3. Input data pribadi & set target
water_records ### Track Langkah
Menyimpan data minum air 1. Buka halaman Steps
2. Tap tombol Start
3. Mulai berjalan
id - Primary key ### Track Air
date - Tanggal (yyyy-MM-dd) 1. Buka halaman Water
amount - Jumlah air (ml) 2. Tap container (contoh: 🚰 600ml)
timestamp - Waktu pencatatan 3. Atau klik "Input Custom" untuk jumlah lain
Preferences (DataStore) ### Aktifkan Reminder
Menyimpan pengaturan pengguna 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 Ubah settings reminder di `WaterReminderManager.kt`:
Requirements ```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 **Tested on:** Samsung Galaxy A35 5G (Android 14)
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
**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 ## 🎓 Project
Klik tombol Play
Izinkan permission
Mulai berjalan
Data otomatis tersimpan
Tugas Akhir Pemrograman Bergerak dengan requirement:
- ✅ Multi-screen
- ✅ Hardware sensor
- ✅ Local database
- ✅ Kotlin & Jetpack Compose
Catat Air Minum ---
Buka Water Tracker ## 👨‍💻 Developer
Klik quick add atau tombol +
Input jumlah air
Data tersimpan dengan timestamp
**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 <div align="center">
Project ini dibuat untuk memenuhi tugas akhir mata kuliah Pemrograman Bergerak dengan requirements:
✅ Multi-halaman (4 screens) Made with ❤️ using Kotlin & Jetpack Compose
✅ Penggunaan sensor (Step Counter)
✅ Database lokal (Room)
✅ IDE Android Studio
✅ Bahasa Kotlin
✅ UI Framework Jetpack Compose
</div>
👨‍💻 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

View File

@ -51,6 +51,8 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
// WorkManager untuk reminder
implementation("androidx.work:work-runtime-ktx:2.9.0")
// Core Android // Core Android
implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")

View File

@ -8,6 +8,9 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Require step counter sensor --> <!-- Require step counter sensor -->
<uses-feature <uses-feature

View File

@ -4,18 +4,25 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.example.stepdrink.ui.navigation.NavGraph import com.example.stepdrink.ui.navigation.AppNavigation
import com.example.stepdrink.ui.theme.StepDrinkTheme import com.example.stepdrink.ui.theme.StepDrinkTheme
import com.example.stepdrink.viewmodel.ProfileViewModel
import com.example.stepdrink.viewmodel.StepViewModel import com.example.stepdrink.viewmodel.StepViewModel
import com.example.stepdrink.viewmodel.WaterViewModel import com.example.stepdrink.viewmodel.WaterViewModel
import com.example.stepdrink.viewmodel.ProfileViewModel
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
// Inisialisasi ViewModels menggunakan viewModels() delegate
private val stepViewModel: StepViewModel by viewModels()
private val waterViewModel: WaterViewModel by viewModels()
private val profileViewModel: ProfileViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
@ -26,11 +33,9 @@ class MainActivity : ComponentActivity() {
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
val stepViewModel: StepViewModel = viewModel()
val waterViewModel: WaterViewModel = viewModel()
val profileViewModel: ProfileViewModel = viewModel()
NavGraph( // Gunakan AppNavigation yang sudah include splash
AppNavigation(
navController = navController, navController = navController,
stepViewModel = stepViewModel, stepViewModel = stepViewModel,
waterViewModel = waterViewModel, waterViewModel = waterViewModel,

View File

@ -6,18 +6,40 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface WaterDao { interface WaterDao {
@Insert
suspend fun insertWaterRecord(waterRecord: WaterRecord)
/**
* Insert new water record
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertWater(waterRecord: WaterRecord)
/**
* Delete water record
*/
@Delete
suspend fun deleteWater(waterRecord: WaterRecord)
/**
* Get all water records for a specific date
*/
@Query("SELECT * FROM water_records WHERE date = :date ORDER BY timestamp DESC") @Query("SELECT * FROM water_records WHERE date = :date ORDER BY timestamp DESC")
fun getWaterRecordsByDate(date: String): Flow<List<WaterRecord>> fun getWaterByDate(date: String): Flow<List<WaterRecord>>
@Query("SELECT SUM(amount) FROM water_records WHERE date = :date") /**
fun getTotalWaterByDate(date: String): Flow<Int?> * Get last 7 days water records
*/
@Query("SELECT * FROM water_records ORDER BY date DESC LIMIT 7") @Query("SELECT * FROM water_records ORDER BY date DESC, timestamp DESC LIMIT 50")
fun getLast7DaysWater(): Flow<List<WaterRecord>> fun getLast7DaysWater(): Flow<List<WaterRecord>>
@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<List<WaterRecord>>
/**
* Delete all water records (for reset)
*/
@Query("DELETE FROM water_records")
suspend fun deleteAllWater()
} }

View File

@ -6,23 +6,36 @@ import kotlinx.coroutines.flow.Flow
class WaterRepository(private val waterDao: WaterDao) { class WaterRepository(private val waterDao: WaterDao) {
fun getWaterRecordsByDate(date: String): Flow<List<WaterRecord>> { /**
return waterDao.getWaterRecordsByDate(date) * Get all water records for a specific date
*/
fun getWaterByDate(date: String): Flow<List<WaterRecord>> {
return waterDao.getWaterByDate(date)
} }
fun getTotalWaterByDate(date: String): Flow<Int?> { /**
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<List<WaterRecord>> { fun getLast7DaysWater(): Flow<List<WaterRecord>> {
return waterDao.getLast7DaysWater() 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)
}
} }

View File

@ -5,15 +5,20 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.example.stepdrink.ui.screen.home.HomeScreen 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.steps.StepsScreen
import com.example.stepdrink.ui.screen.water.WaterScreen 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.StepViewModel
import com.example.stepdrink.viewmodel.WaterViewModel 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 @Composable
fun NavGraph( fun AppNavigation(
navController: NavHostController, navController: NavHostController,
stepViewModel: StepViewModel, stepViewModel: StepViewModel,
waterViewModel: WaterViewModel, waterViewModel: WaterViewModel,
@ -21,8 +26,14 @@ fun NavGraph(
) { ) {
NavHost( NavHost(
navController = navController, 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) { composable(Screen.Home.route) {
HomeScreen( HomeScreen(
navController = navController, navController = navController,
@ -32,6 +43,7 @@ fun NavGraph(
) )
} }
// Steps Screen
composable(Screen.Steps.route) { composable(Screen.Steps.route) {
StepsScreen( StepsScreen(
navController = navController, navController = navController,
@ -39,17 +51,20 @@ fun NavGraph(
) )
} }
// Water Screen
composable(Screen.Water.route) { composable(Screen.Water.route) {
WaterScreen( WaterScreen(
navController = navController, navController = navController,
viewModel = waterViewModel viewModel = waterViewModel
) )
} }
// Profile Screen
composable(Screen.Profile.route) { composable(Screen.Profile.route) {
ProfileScreen( ProfileScreen(
navController = navController, navController = navController,
viewModel = profileViewModel ) viewModel = profileViewModel
)
}
} }
} }
}

View File

@ -1,6 +1,7 @@
package com.example.stepdrink.ui.navigation package com.example.stepdrink.ui.navigation
sealed class Screen(val route: String) { sealed class Screen(val route: String) {
object Splash : Screen("splash")
object Home : Screen("home") object Home : Screen("home")
object Steps : Screen("steps") object Steps : Screen("steps")
object Water : Screen("water") object Water : Screen("water")

View File

@ -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
)
}
}
}

View File

@ -1,17 +1,30 @@
package com.example.stepdrink.ui.screen.water 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.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import com.example.stepdrink.util.WaterReminderManager
import com.example.stepdrink.viewmodel.WaterViewModel import com.example.stepdrink.viewmodel.WaterViewModel
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@ -22,11 +35,35 @@ fun WaterScreen(
navController: NavController, navController: NavController,
viewModel: WaterViewModel viewModel: WaterViewModel
) { ) {
val todayRecords by viewModel.todayWaterRecords.collectAsState() val context = LocalContext.current
val totalWater by viewModel.todayTotalWater.collectAsState() val reminderManager = remember { WaterReminderManager(context) }
val waterGoal by viewModel.dailyGoal.collectAsState()
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( Scaffold(
topBar = { topBar = {
@ -36,17 +73,15 @@ fun WaterScreen(
IconButton(onClick = { navController.popBackStack() }) { IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.Default.ArrowBack, "Kembali") Icon(Icons.Default.ArrowBack, "Kembali")
} }
}
)
}, },
floatingActionButton = { colors = TopAppBarDefaults.topAppBarColors(
FloatingActionButton( containerColor = MaterialTheme.colorScheme.primaryContainer,
onClick = { showDialog = true } titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
) { )
Icon(Icons.Default.Add, "Tambah") )
}
} }
) { paddingValues -> ) { paddingValues ->
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -54,142 +89,392 @@ fun WaterScreen(
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// Main Progress Card
item { item {
// Water Progress Card
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(containerColor = Color.Transparent),
containerColor = MaterialTheme.colorScheme.tertiaryContainer elevation = CardDefaults.cardElevation(4.dp)
)
) { ) {
Column( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(24.dp), .background(
Brush.verticalGradient(
listOf(Color(0xFF2196F3), Color(0xFF64B5F6))
)
)
.padding(24.dp)
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Icon( Icon(
imageVector = Icons.Default.WaterDrop, Icons.Default.WaterDrop,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(64.dp), modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.tertiary tint = Color.White
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = "${totalWater}ml", text = "${todayWater}ml",
style = MaterialTheme.typography.displayLarge, style = MaterialTheme.typography.displayLarge,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
color = Color.White
) )
Text( Text(
text = "Air Minum Hari Ini", text = "Air Minum Hari Ini",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium,
color = Color.White.copy(alpha = 0.9f)
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
LinearProgressIndicator( LinearProgressIndicator(
progress = { totalWater.toFloat() / waterGoal.toFloat() }, progress = { todayWater.toFloat() / waterGoal.toFloat() },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(8.dp), .height(10.dp)
.clip(MaterialTheme.shapes.small),
color = Color.White,
trackColor = Color.White.copy(alpha = 0.3f)
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Target: ${waterGoal}ml", text = "Target: ${waterGoal}ml",
style = MaterialTheme.typography.bodyMedium 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 { item {
// Quick Add Buttons Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
QuickAddButton( Row(
amount = 250, horizontalArrangement = Arrangement.spacedBy(12.dp),
onClick = { viewModel.addWater(250) }, verticalAlignment = Alignment.CenterVertically
modifier = Modifier.weight(1f) ) {
Icon(
Icons.Default.Notifications,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer
) )
QuickAddButton( Column {
amount = 500, Text(
onClick = { viewModel.addWater(500) }, "Pengingat Minum",
modifier = Modifier.weight(1f) style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
) )
QuickAddButton( Text(
amount = 1000, "Ingatkan jika 3 jam tidak minum",
onClick = { viewModel.addWater(1000) }, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f) color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f)
) )
} }
} }
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 { item {
Text( Text(
text = "Riwayat Hari Ini", text = "Pilih Wadah:",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
} }
items(todayRecords) { record -> item {
WaterRecordItem( Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
amount = record.amount, // Row 1
timestamp = record.timestamp, Row(
onDelete = { viewModel.deleteWaterRecord(record) } 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
) )
} }
} }
} }
if (showDialog) { // History
AddWaterDialog( item {
onDismiss = { showDialog = false }, Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"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) }
)
}
}
}
// 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)
) {
Box(contentAlignment = Alignment.Center) {
Text("💧", style = MaterialTheme.typography.displayLarge)
}
}
}
}
}
if (showCustomDialog) {
CustomAmountDialog(
onDismiss = { showCustomDialog = false },
onConfirm = { amount -> onConfirm = { amount ->
viewModel.addWater(amount) viewModel.addWater(amount)
showDialog = false showCustomDialog = false
} }
) )
} }
} }
@Composable @Composable
fun QuickAddButton( fun ContainerCard(
amount: Int, container: com.example.stepdrink.viewmodel.WaterContainer,
onClick: () -> Unit, modifier: Modifier = Modifier,
modifier: Modifier = Modifier onClick: () -> Unit
) { ) {
OutlinedButton( Card(
onClick = onClick, 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( Column(
horizontalAlignment = Alignment.CenterHorizontally modifier = Modifier
.fillMaxSize()
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) { ) {
Icon(Icons.Default.WaterDrop, contentDescription = null) Text(
Text("${amount}ml") 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 @Composable
fun WaterRecordItem( fun WaterRecordItem(
amount: Int, record: com.example.stepdrink.data.local.entity.WaterRecord,
timestamp: Long, container: com.example.stepdrink.viewmodel.WaterContainer?,
onDelete: () -> Unit onDelete: () -> Unit
) { ) {
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
val time = timeFormat.format(Date(timestamp))
Card( Card(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(1.dp)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -199,31 +484,51 @@ fun WaterRecordItem(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp) verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Surface(
imageVector = Icons.Default.WaterDrop, shape = CircleShape,
contentDescription = null, color = MaterialTheme.colorScheme.primaryContainer,
tint = MaterialTheme.colorScheme.primary modifier = Modifier.size(48.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = container?.emoji ?: "💧",
style = MaterialTheme.typography.titleLarge
) )
}
}
Column { Column {
Text( Text(
text = "${amount}ml", text = container?.name ?: "Custom",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text( Text(
text = time, 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
}
IconButton(onClick = onDelete) { IconButton(onClick = onDelete) {
Icon( Icon(
imageVector = Icons.Default.Delete, Icons.Default.Delete,
contentDescription = "Hapus", contentDescription = "Hapus",
tint = MaterialTheme.colorScheme.error tint = MaterialTheme.colorScheme.error
) )
@ -233,7 +538,7 @@ fun WaterRecordItem(
} }
@Composable @Composable
fun AddWaterDialog( fun CustomAmountDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConfirm: (Int) -> Unit onConfirm: (Int) -> Unit
) { ) {
@ -241,21 +546,24 @@ fun AddWaterDialog(
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Tambah Air Minum") }, icon = { Icon(Icons.Default.WaterDrop, contentDescription = null) },
title = { Text("Input Jumlah Air") },
text = { text = {
OutlinedTextField( OutlinedTextField(
value = amount, value = amount,
onValueChange = { amount = it.filter { char -> char.isDigit() } }, onValueChange = { amount = it.filter { char -> char.isDigit() } },
label = { Text("Jumlah (ml)") }, label = { Text("Jumlah (ml)") },
singleLine = true singleLine = true,
modifier = Modifier.fillMaxWidth(),
suffix = { Text("ml") }
) )
}, },
confirmButton = { confirmButton = {
TextButton( Button(
onClick = { onClick = {
val amountInt = amount.toIntOrNull() val value = amount.toIntOrNull()
if (amountInt != null && amountInt > 0) { if (value != null && value > 0) {
onConfirm(amountInt) onConfirm(value)
} }
} }
) { ) {

View File

@ -1,46 +1,214 @@
package com.example.stepdrink.ui.theme package com.example.stepdrink.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.*
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontFamily
import androidx.core.view.WindowCompat import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
private val DarkColorScheme = darkColorScheme( // Color palette dari icon
primary = androidx.compose.ui.graphics.Color(0xFF90CAF9), val TealPrimary = Color(0xFF7BC8BC) // Mint/Teal utama
secondary = androidx.compose.ui.graphics.Color(0xFF81C784), val TealLight = Color(0xFFA8E6CF) // Light mint
tertiary = androidx.compose.ui.graphics.Color(0xFF64B5F6) 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( // Dark Theme Colors
primary = androidx.compose.ui.graphics.Color(0xFF1976D2), private val DarkColorScheme = darkColorScheme(
secondary = androidx.compose.ui.graphics.Color(0xFF388E3C), primary = TealPrimary,
tertiary = androidx.compose.ui.graphics.Color(0xFF0288D1) 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 @Composable
fun StepDrinkTheme( fun StepDrinkTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false, // Disable dynamic color untuk konsistensi
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme val colorScheme = when {
val view = LocalView.current darkTheme -> DarkColorScheme
else -> LightColorScheme
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
} }
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = AppTypography, // Gunakan AppTypography yang sudah didefinisikan
content = content content = content
) )
} }

View File

@ -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<WaterReminderWorker>(
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)
}
}

View File

@ -3,47 +3,79 @@ package com.example.stepdrink.viewmodel
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope 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.PreferencesManager
import com.example.stepdrink.data.local.database.AppDatabase
import com.example.stepdrink.data.local.entity.WaterRecord import com.example.stepdrink.data.local.entity.WaterRecord
import com.example.stepdrink.data.repository.WaterRepository import com.example.stepdrink.data.repository.WaterRepository
import com.example.stepdrink.util.DateUtils import com.example.stepdrink.util.DateUtils
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch 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) { class WaterViewModel(application: Application) : AndroidViewModel(application) {
private val repository: WaterRepository = WaterRepository( private val repository: WaterRepository
AppDatabase.getDatabase(application).waterDao()
)
private val preferencesManager = PreferencesManager(application) private val preferencesManager = PreferencesManager(application)
val dailyGoal: StateFlow<Int> = preferencesManager.waterGoal val dailyGoal: StateFlow<Int> = preferencesManager.waterGoal
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 2000) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 2000)
val todayWaterRecords: StateFlow<List<WaterRecord>> = val todayWaterRecords: StateFlow<List<WaterRecord>>
repository.getWaterRecordsByDate(DateUtils.getCurrentDate()) val todayTotalWater: StateFlow<Int>
// 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()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val todayTotalWater: StateFlow<Int> = todayTotalWater = todayWaterRecords.map { records ->
repository.getTotalWaterByDate(DateUtils.getCurrentDate()) records.sumOf { it.amount }
.map { it ?: 0 } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) }
val last7DaysWater: StateFlow<List<WaterRecord>> =
repository.getLast7DaysWater()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun addWater(amount: Int) { fun addWater(amount: Int) {
viewModelScope.launch { viewModelScope.launch {
repository.addWaterRecord(DateUtils.getCurrentDate(), amount) repository.insertWater(DateUtils.getCurrentDate(), amount)
} }
} }
fun deleteWaterRecord(record: WaterRecord) { fun addWaterFromContainer(container: WaterContainer) {
viewModelScope.launch { addWater(container.amount)
repository.deleteWaterRecord(record)
} }
fun deleteWater(record: WaterRecord) {
viewModelScope.launch {
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 }
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1023 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 25 KiB