Compare commits

...

2 Commits

Author SHA1 Message Date
HagaDalpintoGinting
275a2bdcb6 update readme 2026-01-11 20:45:37 +07:00
HagaDalpintoGinting
1aeaf58b5d update README and WaterReminder.kt 2026-01-11 20:43:28 +07:00
31 changed files with 1145 additions and 362 deletions

254
README.md
View File

@ -1,187 +1,159 @@
Step & Drink
Aplikasi Android untuk tracking langkah harian dan kebutuhan air minum menggunakan Kotlin dan Jetpack Compose.
# 💧🏃 Step & Drink
📋 Deskripsi
Step & Drink adalah aplikasi mobile yang membantu pengguna untuk:
Aplikasi Android untuk tracking langkah harian dan asupan air minum.
Melacak langkah harian menggunakan sensor step counter bawaan smartphone
Mencatat asupan air minum untuk memenuhi kebutuhan hidrasi harian
Menetapkan dan memantau target langkah dan air minum yang dapat disesuaikan
Melihat riwayat aktivitas untuk evaluasi kebiasaan sehat
[![Kotlin](https://img.shields.io/badge/Kotlin-100%25-7F52FF)](https://kotlinlang.org)
[![Jetpack Compose](https://img.shields.io/badge/Jetpack_Compose-4285F4)](https://developer.android.com/jetpack/compose)
[![Min SDK](https://img.shields.io/badge/Min_SDK-26-3DDC84)](https://developer.android.com)
Aplikasi ini dibuat sebagai tugas akhir mata kuliah Pemrograman Bergerak dengan fokus pada penggunaan sensor hardware, database lokal, dan multi-halaman navigation.
---
✨ Fitur
1. Home Screen
## 📱 Tentang
Dashboard dengan ringkasan aktivitas hari ini
Card interaktif untuk langkah dan air minum
Progress bar visual untuk tracking target
Greeting dengan nama pengguna
Step & Drink membantu kamu menjaga kesehatan dengan:
- 🏃 **Step Counter** - Track langkah harian secara real-time
- 💧 **Water Tracker** - Catat asupan air dengan mudah
- ⏰ **Smart Reminder** - Notifikasi otomatis untuk minum teratur
- 📊 **Progress Monitor** - Lihat pencapaian target harian
2. Step Tracker
---
Real-time tracking langkah menggunakan sensor TYPE_STEP_COUNTER
Start/Stop tracking dengan tombol floating action button
Riwayat langkah 7 hari terakhir
Target langkah yang dapat disesuaikan
## ✨ Fitur Utama
3. Water Tracker
### Step Counter
- Real-time tracking dengan hardware sensor
- Target harian (default 10,000 langkah)
- History 7 hari
- Start/Stop control
Quick add buttons (250ml, 500ml, 1000ml)
Input custom untuk jumlah air
Riwayat minum harian dengan timestamp
Hapus record jika salah input
Target air minum yang dapat disesuaikan
### Water Tracker
Pilihan container cepat:
- 🥛 Gelas Kecil (200ml)
- 💧 Gelas Sedang (250ml)
- 🌊 Gelas Besar (350ml)
- 🚰 Botol Aqua (600ml)
- 🫗 Tumbler (500ml)
4. Profile & Settings
Plus input custom & history lengkap.
Edit nama pengguna
Ubah target langkah harian
Ubah target air minum harian
Informasi aplikasi
### Smart Reminder
- Notifikasi otomatis setelah 3 jam tidak minum
- Aktif jam 06:00 - 22:00
- Battery efficient
- Bisa di-toggle ON/OFF
### Profile
- Input nama & berat badan
- Set target langkah & air harian
- Data tersimpan lokal
🛠️ Teknologi
Bahasa & Framework
---
Kotlin - Bahasa pemrograman
Jetpack Compose - UI Framework
Material Design 3 - Design system
## 🛠️ Teknologi
Architecture
**Core:**
- Kotlin 100%
- Jetpack Compose
- Material Design 3
MVVM (Model-View-ViewModel)
Repository Pattern
Clean Architecture
**Architecture:**
- MVVM Pattern
- Room Database
- DataStore Preferences
- WorkManager
- Coroutines & Flow
Database & Storage
---
Room Database - Local database (SQLite)
DataStore Preferences - Settings storage
## 🚀 Instalasi
Components
```bash
# Clone repository
git clone https://github.com/HagaDalpintoGinting/Steps-Drink.git
Navigation Compose - Multi-halaman navigation
Sensor Manager - Akses hardware sensor (Step Counter)
ViewModel - State management
Kotlin Flow - Reactive data stream
Coroutines - Asynchronous operations
# Buka di Android Studio
# Sync Gradle
# Run aplikasi
```
**Requirements:**
- Android Studio (latest)
- Min SDK 26 (Android 8.0)
- Device dengan step counter sensor (untuk fitur langkah)
📦 Struktur Project
stepdrink/
├── data/
│ ├── local/
│ │ ├── entity/ # StepRecord, WaterRecord
│ │ ├── dao/ # StepDao, WaterDao
│ │ ├── database/ # AppDatabase
│ │ └── PreferencesManager.kt
│ └── repository/ # StepRepository, WaterRepository
├── ui/
│ ├── screen/ # HomeScreen, StepsScreen, WaterScreen, ProfileScreen
│ ├── navigation/ # Navigation setup
│ ├── components/ # Reusable UI components
│ └── theme/ # App theming
├── viewmodel/ # StepViewModel, WaterViewModel, ProfileViewModel
├── sensor/ # StepCounterManager
├── util/ # DateUtils
└── MainActivity.kt
---
🗄️ Database
step_records
Menyimpan data langkah harian
## 📖 Penggunaan
id - Primary key
date - Tanggal (yyyy-MM-dd)
steps - Jumlah langkah
timestamp - Waktu pencatatan
### Setup
1. Buka aplikasi
2. Masuk ke Profile
3. Input data pribadi & set target
water_records
Menyimpan data minum air
### Track Langkah
1. Buka halaman Steps
2. Tap tombol Start
3. Mulai berjalan
id - Primary key
date - Tanggal (yyyy-MM-dd)
amount - Jumlah air (ml)
timestamp - Waktu pencatatan
### Track Air
1. Buka halaman Water
2. Tap container (contoh: 🚰 600ml)
3. Atau klik "Input Custom" untuk jumlah lain
Preferences (DataStore)
Menyimpan pengaturan pengguna
### Aktifkan Reminder
1. Buka halaman Water
2. Toggle "Pengingat Minum" ke ON
3. Izinkan permission notifikasi
user_name - Nama pengguna
step_goal - Target langkah harian
water_goal - Target air minum harian (ml)
---
## ⚙️ Konfigurasi
🚀 Instalasi
Requirements
Ubah settings reminder di `WaterReminderManager.kt`:
```kotlin
const val CHECK_INTERVAL_MINUTES = 30L // Interval cek
const val REMIND_AFTER_HOURS = 3 // Reminder setelah X jam
const val ACTIVE_START_HOUR = 6 // Jam mulai
const val ACTIVE_END_HOUR = 22 // Jam selesai
```
Android Studio (versi terbaru)
Minimum SDK: API 26 (Android 8.0)
Kotlin 1.9.22
---
Cara Menjalankan
## 📝 Catatan
Clone atau download project
Buka di Android Studio
Sync Gradle (File → Sync Project with Gradle Files)
Build project (Build → Make Project)
Run di device atau emulator
Izinkan permission ACTIVITY_RECOGNITION saat diminta
**Tested on:** Samsung Galaxy A35 5G (Android 14)
**Limitations:**
- Step counter butuh device fisik (emulator tidak support)
- Data tersimpan lokal saja
- Reminder bergantung pada battery optimization device
📱 Cara Penggunaan
**Troubleshooting:**
- Steps tidak terhitung? Pastikan permission ACTIVITY_RECOGNITION diizinkan
- Reminder tidak muncul? Check permission POST_NOTIFICATIONS dan pastikan jam aktif
Tracking Langkah
---
Buka Step Tracker
Klik tombol Play
Izinkan permission
Mulai berjalan
Data otomatis tersimpan
## 🎓 Project
Tugas Akhir Pemrograman Bergerak dengan requirement:
- ✅ Multi-screen
- ✅ Hardware sensor
- ✅ Local database
- ✅ Kotlin & Jetpack Compose
Catat Air Minum
---
Buka Water Tracker
Klik quick add atau tombol +
Input jumlah air
Data tersimpan dengan timestamp
## 👨‍💻 Developer
**Haga Dalpinto Ginting**
Ubah Target
GitHub: [@HagaDalpintoGinting](https://github.com/HagaDalpintoGinting)
Buka Profile
Klik card yang ingin diubah
Input nilai baru
Simpan
---
## 📄 License
MIT License © 2025
🎯 Tujuan Project
Project ini dibuat untuk memenuhi tugas akhir mata kuliah Pemrograman Bergerak dengan requirements:
✅ Multi-halaman (4 screens)
✅ Penggunaan sensor (Step Counter)
✅ Database lokal (Room)
✅ IDE Android Studio
✅ Bahasa Kotlin
✅ UI Framework Jetpack Compose
👨‍💻 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.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,6 +8,9 @@
<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,18 +4,25 @@ 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.NavGraph
import com.example.stepdrink.ui.navigation.AppNavigation
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()
@ -26,11 +33,9 @@ class MainActivity : ComponentActivity() {
color = MaterialTheme.colorScheme.background
) {
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,
stepViewModel = stepViewModel,
waterViewModel = waterViewModel,

View File

@ -6,18 +6,40 @@ 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 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?>
@Query("SELECT * FROM water_records ORDER BY date DESC LIMIT 7")
/**
* Get last 7 days water records
*/
@Query("SELECT * FROM water_records ORDER BY date DESC, timestamp DESC LIMIT 50")
fun getLast7DaysWater(): Flow<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) {
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>> {
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.composable
import com.example.stepdrink.ui.screen.home.HomeScreen
import com.example.stepdrink.ui.screen.profile.ProfileScreen
import com.example.stepdrink.ui.screen.splash.SplashScreen
import com.example.stepdrink.ui.screen.steps.StepsScreen
import com.example.stepdrink.ui.screen.water.WaterScreen
import com.example.stepdrink.ui.screen.profile.ProfileScreen
import com.example.stepdrink.viewmodel.ProfileViewModel
import com.example.stepdrink.viewmodel.StepViewModel
import com.example.stepdrink.viewmodel.WaterViewModel
import com.example.stepdrink.viewmodel.ProfileViewModel
/**
* AppNavigation - Single NavHost untuk semua screen (termasuk splash)
* Ini menghindari nested NavHost yang menyebabkan ViewModelStore error
*/
@Composable
fun NavGraph(
fun AppNavigation(
navController: NavHostController,
stepViewModel: StepViewModel,
waterViewModel: WaterViewModel,
@ -21,8 +26,14 @@ fun NavGraph(
) {
NavHost(
navController = navController,
startDestination = Screen.Home.route
startDestination = Screen.Splash.route
) {
// Splash Screen
composable(Screen.Splash.route) {
SplashScreen(navController = navController)
}
// Home Screen
composable(Screen.Home.route) {
HomeScreen(
navController = navController,
@ -32,6 +43,7 @@ fun NavGraph(
)
}
// Steps Screen
composable(Screen.Steps.route) {
StepsScreen(
navController = navController,
@ -39,17 +51,20 @@ fun NavGraph(
)
}
// Water Screen
composable(Screen.Water.route) {
WaterScreen(
navController = navController,
viewModel = waterViewModel
)
}
// Profile Screen
composable(Screen.Profile.route) {
ProfileScreen(
navController = navController,
viewModel = profileViewModel )
viewModel = profileViewModel
)
}
}
}

View File

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

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

View File

@ -1,46 +1,214 @@
package com.example.stepdrink.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
private val DarkColorScheme = darkColorScheme(
primary = androidx.compose.ui.graphics.Color(0xFF90CAF9),
secondary = androidx.compose.ui.graphics.Color(0xFF81C784),
tertiary = androidx.compose.ui.graphics.Color(0xFF64B5F6)
// Color palette dari icon
val TealPrimary = Color(0xFF7BC8BC) // Mint/Teal utama
val TealLight = Color(0xFFA8E6CF) // Light mint
val TealDark = Color(0xFF4A9B8E) // Dark teal
val BluePrimary = Color(0xFF5DADE2) // Blue dari gradient
val NavyAccent = Color(0xFF2C3E50) // Dark blue accent
val OrangeAccent = Color(0xFFF39C12) // Orange highlight
val Background = Color(0xFFF5F9F8) // Very light teal background
// Light Theme Colors
private val LightColorScheme = lightColorScheme(
primary = TealPrimary,
onPrimary = Color.White,
primaryContainer = TealLight,
onPrimaryContainer = NavyAccent,
secondary = BluePrimary,
onSecondary = Color.White,
secondaryContainer = Color(0xFFE3F2FD),
onSecondaryContainer = NavyAccent,
tertiary = OrangeAccent,
onTertiary = Color.White,
tertiaryContainer = Color(0xFFFFE6CC),
onTertiaryContainer = Color(0xFF8B5A00),
background = Background,
onBackground = NavyAccent,
surface = Color.White,
onSurface = NavyAccent,
surfaceVariant = Color(0xFFE8F5F3),
onSurfaceVariant = Color(0xFF5A6A6C),
error = Color(0xFFE74C3C),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF93000A),
outline = Color(0xFFB0BEC5),
outlineVariant = Color(0xFFCFD8DC)
)
private val LightColorScheme = lightColorScheme(
primary = androidx.compose.ui.graphics.Color(0xFF1976D2),
secondary = androidx.compose.ui.graphics.Color(0xFF388E3C),
tertiary = androidx.compose.ui.graphics.Color(0xFF0288D1)
// Dark Theme Colors
private val DarkColorScheme = darkColorScheme(
primary = TealPrimary,
onPrimary = NavyAccent,
primaryContainer = TealDark,
onPrimaryContainer = TealLight,
secondary = BluePrimary,
onSecondary = NavyAccent,
secondaryContainer = Color(0xFF1E3A5F),
onSecondaryContainer = Color(0xFFB3D9FF),
tertiary = OrangeAccent,
onTertiary = NavyAccent,
tertiaryContainer = Color(0xFF8B5A00),
onTertiaryContainer = Color(0xFFFFD9A3),
background = Color(0xFF1A2329),
onBackground = Color(0xFFE1E8E8),
surface = Color(0xFF1F2A30),
onSurface = Color(0xFFE1E8E8),
surfaceVariant = Color(0xFF2C3E50),
onSurfaceVariant = Color(0xFFB8C5C8),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
outline = Color(0xFF8B9A9D),
outlineVariant = Color(0xFF3F5054)
)
// Typography untuk Material3
private val AppTypography = androidx.compose.material3.Typography(
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
displayMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 45.sp,
lineHeight = 52.sp,
letterSpacing = 0.sp
),
displaySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 36.sp,
lineHeight = 44.sp,
letterSpacing = 0.sp
),
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp
),
headlineSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
),
titleSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)
@Composable
fun StepDrinkTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false, // Disable dynamic color untuk konsistensi
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
val colorScheme = when {
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography, // Gunakan AppTypography yang sudah didefinisikan
content = content
)
}

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 androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.stepdrink.data.local.database.AppDatabase
import com.example.stepdrink.data.local.PreferencesManager
import com.example.stepdrink.data.local.database.AppDatabase
import com.example.stepdrink.data.local.entity.WaterRecord
import com.example.stepdrink.data.repository.WaterRepository
import com.example.stepdrink.util.DateUtils
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
class WaterViewModel(application: Application) : AndroidViewModel(application) {
private val repository: WaterRepository = WaterRepository(
AppDatabase.getDatabase(application).waterDao()
// 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 preferencesManager = PreferencesManager(application)
val dailyGoal: StateFlow<Int> = preferencesManager.waterGoal
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 2000)
val todayWaterRecords: StateFlow<List<WaterRecord>> =
repository.getWaterRecordsByDate(DateUtils.getCurrentDate())
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())
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
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())
todayTotalWater = todayWaterRecords.map { records ->
records.sumOf { it.amount }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
}
fun addWater(amount: Int) {
viewModelScope.launch {
repository.addWaterRecord(DateUtils.getCurrentDate(), amount)
repository.insertWater(DateUtils.getCurrentDate(), amount)
}
}
fun deleteWaterRecord(record: WaterRecord) {
fun addWaterFromContainer(container: WaterContainer) {
addWater(container.amount)
}
fun deleteWater(record: WaterRecord) {
viewModelScope.launch {
repository.deleteWaterRecord(record)
repository.deleteWater(record)
}
}
fun getProgressPercentage(): Float {
return (todayTotalWater.value.toFloat() / dailyGoal.value.toFloat()).coerceIn(0f, 1f)
}
fun getLastDrinkTime(): Long? {
return todayWaterRecords.value.maxByOrNull { it.timestamp }?.timestamp
}
fun getContainerByAmount(amount: Int): WaterContainer? {
return presetContainers.find { it.amount == amount }
}
}

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"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

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