Compare commits

..

No commits in common. "275a2bdcb6c5b0dedd504187f0e45be2daeebab7" and "a226686431a44a8b74d624c076f67cac97015ca6" have entirely different histories.

31 changed files with 360 additions and 1143 deletions

254
README.md
View File

@ -1,159 +1,187 @@
# 💧🏃 Step & Drink
Step & Drink
Aplikasi Android untuk tracking langkah harian dan kebutuhan air minum menggunakan Kotlin dan Jetpack Compose.
Aplikasi Android untuk tracking langkah harian dan asupan air minum.
📋 Deskripsi
Step & Drink adalah aplikasi mobile yang membantu pengguna untuk:
[![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)
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
---
Aplikasi ini dibuat sebagai tugas akhir mata kuliah Pemrograman Bergerak dengan fokus pada penggunaan sensor hardware, database lokal, dan multi-halaman navigation.
## 📱 Tentang
✨ Fitur
1. Home Screen
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
Dashboard dengan ringkasan aktivitas hari ini
Card interaktif untuk langkah dan air minum
Progress bar visual untuk tracking target
Greeting dengan nama pengguna
---
2. Step Tracker
## ✨ Fitur Utama
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
### Step Counter
- Real-time tracking dengan hardware sensor
- Target harian (default 10,000 langkah)
- History 7 hari
- Start/Stop control
3. Water Tracker
### Water Tracker
Pilihan container cepat:
- 🥛 Gelas Kecil (200ml)
- 💧 Gelas Sedang (250ml)
- 🌊 Gelas Besar (350ml)
- 🚰 Botol Aqua (600ml)
- 🫗 Tumbler (500ml)
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
Plus input custom & history lengkap.
4. Profile & Settings
### Smart Reminder
- Notifikasi otomatis setelah 3 jam tidak minum
- Aktif jam 06:00 - 22:00
- Battery efficient
- Bisa di-toggle ON/OFF
Edit nama pengguna
Ubah target langkah harian
Ubah target air minum harian
Informasi aplikasi
### Profile
- Input nama & berat badan
- Set target langkah & air harian
- Data tersimpan lokal
---
🛠️ Teknologi
Bahasa & Framework
## 🛠️ Teknologi
Kotlin - Bahasa pemrograman
Jetpack Compose - UI Framework
Material Design 3 - Design system
**Core:**
- Kotlin 100%
- Jetpack Compose
- Material Design 3
Architecture
**Architecture:**
- MVVM Pattern
- Room Database
- DataStore Preferences
- WorkManager
- Coroutines & Flow
MVVM (Model-View-ViewModel)
Repository Pattern
Clean Architecture
---
Database & Storage
## 🚀 Instalasi
Room Database - Local database (SQLite)
DataStore Preferences - Settings storage
```bash
# Clone repository
git clone https://github.com/HagaDalpintoGinting/Steps-Drink.git
Components
# Buka di Android Studio
# Sync Gradle
# Run aplikasi
```
Navigation Compose - Multi-halaman navigation
Sensor Manager - Akses hardware sensor (Step Counter)
ViewModel - State management
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
## 📖 Penggunaan
🗄️ Database
step_records
Menyimpan data langkah harian
### Setup
1. Buka aplikasi
2. Masuk ke Profile
3. Input data pribadi & set target
id - Primary key
date - Tanggal (yyyy-MM-dd)
steps - Jumlah langkah
timestamp - Waktu pencatatan
### Track Langkah
1. Buka halaman Steps
2. Tap tombol Start
3. Mulai berjalan
water_records
Menyimpan data minum air
### Track Air
1. Buka halaman Water
2. Tap container (contoh: 🚰 600ml)
3. Atau klik "Input Custom" untuk jumlah lain
id - Primary key
date - Tanggal (yyyy-MM-dd)
amount - Jumlah air (ml)
timestamp - Waktu pencatatan
### Aktifkan Reminder
1. Buka halaman Water
2. Toggle "Pengingat Minum" ke ON
3. Izinkan permission notifikasi
Preferences (DataStore)
Menyimpan pengaturan pengguna
---
user_name - Nama pengguna
step_goal - Target langkah harian
water_goal - Target air minum harian (ml)
## ⚙️ Konfigurasi
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
```
🚀 Instalasi
Requirements
---
Android Studio (versi terbaru)
Minimum SDK: API 26 (Android 8.0)
Kotlin 1.9.22
## 📝 Catatan
Cara Menjalankan
**Tested on:** Samsung Galaxy A35 5G (Android 14)
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
**Limitations:**
- Step counter butuh device fisik (emulator tidak support)
- Data tersimpan lokal saja
- Reminder bergantung pada battery optimization device
**Troubleshooting:**
- Steps tidak terhitung? Pastikan permission ACTIVITY_RECOGNITION diizinkan
- Reminder tidak muncul? Check permission POST_NOTIFICATIONS dan pastikan jam aktif
📱 Cara Penggunaan
---
Tracking Langkah
## 🎓 Project
Buka Step Tracker
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
## 👨‍💻 Developer
Buka Water Tracker
Klik quick add atau tombol +
Input jumlah air
Data tersimpan dengan timestamp
**Haga Dalpinto Ginting**
GitHub: [@HagaDalpintoGinting](https://github.com/HagaDalpintoGinting)
Ubah Target
---
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
👨‍💻 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,8 +51,6 @@ 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")

View File

@ -8,9 +8,6 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<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 -->
<uses-feature

View File

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

View File

@ -6,40 +6,18 @@ import kotlinx.coroutines.flow.Flow
@Dao
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")
fun getWaterByDate(date: String): Flow<List<WaterRecord>>
fun getWaterRecordsByDate(date: String): Flow<List<WaterRecord>>
/**
* Get last 7 days water records
*/
@Query("SELECT * FROM water_records ORDER BY date DESC, timestamp DESC LIMIT 50")
@Query("SELECT SUM(amount) FROM water_records WHERE date = :date")
fun getTotalWaterByDate(date: String): Flow<Int?>
@Query("SELECT * FROM water_records ORDER BY date DESC LIMIT 7")
fun getLast7DaysWater(): Flow<List<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()
@Delete
suspend fun deleteWaterRecord(waterRecord: WaterRecord)
}

View File

@ -6,36 +6,23 @@ import kotlinx.coroutines.flow.Flow
class WaterRepository(private val waterDao: WaterDao) {
/**
* Get all water records for a specific date
*/
fun getWaterByDate(date: String): Flow<List<WaterRecord>> {
return waterDao.getWaterByDate(date)
fun getWaterRecordsByDate(date: String): Flow<List<WaterRecord>> {
return waterDao.getWaterRecordsByDate(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)
fun getTotalWaterByDate(date: String): Flow<Int?> {
return waterDao.getTotalWaterByDate(date)
}
/**
* Delete water record
*/
suspend fun deleteWater(record: WaterRecord) {
waterDao.deleteWater(record)
}
/**
* Get last 7 days water records
*/
fun getLast7DaysWater(): Flow<List<WaterRecord>> {
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,20 +5,15 @@ 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.viewmodel.ProfileViewModel
import com.example.stepdrink.ui.screen.profile.ProfileScreen
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 AppNavigation(
fun NavGraph(
navController: NavHostController,
stepViewModel: StepViewModel,
waterViewModel: WaterViewModel,
@ -26,14 +21,8 @@ fun AppNavigation(
) {
NavHost(
navController = navController,
startDestination = Screen.Splash.route
startDestination = Screen.Home.route
) {
// Splash Screen
composable(Screen.Splash.route) {
SplashScreen(navController = navController)
}
// Home Screen
composable(Screen.Home.route) {
HomeScreen(
navController = navController,
@ -43,7 +32,6 @@ fun AppNavigation(
)
}
// Steps Screen
composable(Screen.Steps.route) {
StepsScreen(
navController = navController,
@ -51,20 +39,17 @@ fun AppNavigation(
)
}
// Water Screen
composable(Screen.Water.route) {
WaterScreen(
navController = navController,
viewModel = waterViewModel
)
}
// Profile Screen
composable(Screen.Profile.route) {
ProfileScreen(
navController = navController,
viewModel = profileViewModel
)
viewModel = profileViewModel )
}
}
}

View File

@ -1,7 +1,6 @@
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")

View File

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

View File

@ -1,214 +1,46 @@
package com.example.stepdrink.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
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
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
// 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)
)
// 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)
primary = androidx.compose.ui.graphics.Color(0xFF90CAF9),
secondary = androidx.compose.ui.graphics.Color(0xFF81C784),
tertiary = androidx.compose.ui.graphics.Color(0xFF64B5F6)
)
// 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
)
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)
)
@Composable
fun StepDrinkTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false, // Disable dynamic color untuk konsistensi
content: @Composable () -> Unit
) {
val colorScheme = when {
darkTheme -> DarkColorScheme
else -> LightColorScheme
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
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography, // Gunakan AppTypography yang sudah didefinisikan
content = content
)
}

View File

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

Before

Width:  |  Height:  |  Size: 472 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 655 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1023 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB