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) Melacak langkah harian menggunakan sensor step counter bawaan smartphone
[![Jetpack Compose](https://img.shields.io/badge/Jetpack_Compose-4285F4)](https://developer.android.com/jetpack/compose) Mencatat asupan air minum untuk memenuhi kebutuhan hidrasi harian
[![Min SDK](https://img.shields.io/badge/Min_SDK-26-3DDC84)](https://developer.android.com) 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: Dashboard dengan ringkasan aktivitas hari ini
- 🏃 **Step Counter** - Track langkah harian secara real-time Card interaktif untuk langkah dan air minum
- 💧 **Water Tracker** - Catat asupan air dengan mudah Progress bar visual untuk tracking target
- ⏰ **Smart Reminder** - Notifikasi otomatis untuk minum teratur Greeting dengan nama pengguna
- 📊 **Progress Monitor** - Lihat pencapaian target harian
--- 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 3. Water Tracker
- Real-time tracking dengan hardware sensor
- Target harian (default 10,000 langkah)
- History 7 hari
- Start/Stop control
### Water Tracker Quick add buttons (250ml, 500ml, 1000ml)
Pilihan container cepat: Input custom untuk jumlah air
- 🥛 Gelas Kecil (200ml) Riwayat minum harian dengan timestamp
- 💧 Gelas Sedang (250ml) Hapus record jika salah input
- 🌊 Gelas Besar (350ml) Target air minum yang dapat disesuaikan
- 🚰 Botol Aqua (600ml)
- 🫗 Tumbler (500ml)
Plus input custom & history lengkap. 4. Profile & Settings
### Smart Reminder Edit nama pengguna
- Notifikasi otomatis setelah 3 jam tidak minum Ubah target langkah harian
- Aktif jam 06:00 - 22:00 Ubah target air minum harian
- Battery efficient Informasi aplikasi
- Bisa di-toggle ON/OFF
### 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:** Architecture
- Kotlin 100%
- Jetpack Compose
- Material Design 3
**Architecture:** MVVM (Model-View-ViewModel)
- MVVM Pattern Repository Pattern
- Room Database Clean Architecture
- DataStore Preferences
- WorkManager
- Coroutines & Flow
--- Database & Storage
## 🚀 Instalasi Room Database - Local database (SQLite)
DataStore Preferences - Settings storage
```bash Components
# Clone repository
git clone https://github.com/HagaDalpintoGinting/Steps-Drink.git
# Buka di Android Studio Navigation Compose - Multi-halaman navigation
# Sync Gradle Sensor Manager - Akses hardware sensor (Step Counter)
# Run aplikasi 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 id - Primary key
1. Buka aplikasi date - Tanggal (yyyy-MM-dd)
2. Masuk ke Profile steps - Jumlah langkah
3. Input data pribadi & set target timestamp - Waktu pencatatan
### Track Langkah water_records
1. Buka halaman Steps Menyimpan data minum air
2. Tap tombol Start
3. Mulai berjalan
### Track Air id - Primary key
1. Buka halaman Water date - Tanggal (yyyy-MM-dd)
2. Tap container (contoh: 🚰 600ml) amount - Jumlah air (ml)
3. Atau klik "Input Custom" untuk jumlah lain timestamp - Waktu pencatatan
### Aktifkan Reminder Preferences (DataStore)
1. Buka halaman Water Menyimpan pengaturan pengguna
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
Ubah settings reminder di `WaterReminderManager.kt`: 🚀 Instalasi
```kotlin Requirements
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
## 📝 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:** 📱 Cara Penggunaan
- Steps tidak terhitung? Pastikan permission ACTIVITY_RECOGNITION diizinkan
- Reminder tidak muncul? Check permission POST_NOTIFICATIONS dan pastikan jam aktif
--- 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.graphics)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
// WorkManager untuk reminder
implementation("androidx.work:work-runtime-ktx:2.9.0")
// Core Android // Core Android
implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,214 +1,46 @@
package com.example.stepdrink.ui.theme package com.example.stepdrink.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.* import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.runtime.SideEffect
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight import androidx.core.view.WindowCompat
import androidx.compose.ui.unit.sp
// 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( private val DarkColorScheme = darkColorScheme(
primary = TealPrimary, primary = androidx.compose.ui.graphics.Color(0xFF90CAF9),
onPrimary = NavyAccent, secondary = androidx.compose.ui.graphics.Color(0xFF81C784),
primaryContainer = TealDark, tertiary = androidx.compose.ui.graphics.Color(0xFF64B5F6)
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 LightColorScheme = lightColorScheme(
private val AppTypography = androidx.compose.material3.Typography( primary = androidx.compose.ui.graphics.Color(0xFF1976D2),
displayLarge = TextStyle( secondary = androidx.compose.ui.graphics.Color(0xFF388E3C),
fontFamily = FontFamily.Default, tertiary = androidx.compose.ui.graphics.Color(0xFF0288D1)
fontWeight = FontWeight.Bold,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
displayMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 45.sp,
lineHeight = 52.sp,
letterSpacing = 0.sp
),
displaySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 36.sp,
lineHeight = 44.sp,
letterSpacing = 0.sp
),
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp
),
headlineSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
),
titleSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
) )
@Composable @Composable
fun StepDrinkTheme( fun StepDrinkTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false, // Disable dynamic color untuk konsistensi
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = when { val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
darkTheme -> DarkColorScheme val view = LocalView.current
else -> LightColorScheme
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
} }
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = AppTypography, // Gunakan AppTypography yang sudah didefinisikan
content = content content = content
) )
} }

View File

@ -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 android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.stepdrink.data.local.PreferencesManager
import com.example.stepdrink.data.local.database.AppDatabase 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.local.entity.WaterRecord
import com.example.stepdrink.data.repository.WaterRepository import com.example.stepdrink.data.repository.WaterRepository
import com.example.stepdrink.util.DateUtils import com.example.stepdrink.util.DateUtils
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// Data class untuk preset containers
data class WaterContainer(
val emoji: String,
val name: String,
val amount: Int,
val color: Long = 0xFF2196F3
)
class WaterViewModel(application: Application) : AndroidViewModel(application) { class WaterViewModel(application: Application) : AndroidViewModel(application) {
private val repository: WaterRepository private val repository: WaterRepository = WaterRepository(
AppDatabase.getDatabase(application).waterDao()
)
private val preferencesManager = PreferencesManager(application) private val preferencesManager = PreferencesManager(application)
val dailyGoal: StateFlow<Int> = preferencesManager.waterGoal val dailyGoal: StateFlow<Int> = preferencesManager.waterGoal
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 2000) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 2000)
val todayWaterRecords: StateFlow<List<WaterRecord>> val todayWaterRecords: StateFlow<List<WaterRecord>> =
val todayTotalWater: StateFlow<Int> repository.getWaterRecordsByDate(DateUtils.getCurrentDate())
// SIMPLE CONTAINERS - Kategori yang clear & sederhana
val presetContainers = listOf(
WaterContainer("🥛", "Gelas Kecil", 200, 0xFF64B5F6), // Small glass
WaterContainer("💧", "Gelas Sedang", 250, 0xFF42A5F5), // Medium glass (STANDARD)
WaterContainer("🌊", "Gelas Besar", 350, 0xFF2196F3), // Large glass
WaterContainer("🚰", "Botol Aqua", 600, 0xFF1976D2), // Aqua bottle (MOST COMMON)
WaterContainer("🫗", "Tumbler", 500, 0xFF1565C0), // Tumbler
)
init {
val database = AppDatabase.getDatabase(application)
repository = WaterRepository(database.waterDao())
todayWaterRecords = repository.getWaterByDate(DateUtils.getCurrentDate())
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
todayTotalWater = todayWaterRecords.map { records -> val todayTotalWater: StateFlow<Int> =
records.sumOf { it.amount } repository.getTotalWaterByDate(DateUtils.getCurrentDate())
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) .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) { fun addWater(amount: Int) {
viewModelScope.launch { viewModelScope.launch {
repository.insertWater(DateUtils.getCurrentDate(), amount) repository.addWaterRecord(DateUtils.getCurrentDate(), amount)
} }
} }
fun addWaterFromContainer(container: WaterContainer) { fun deleteWaterRecord(record: WaterRecord) {
addWater(container.amount)
}
fun deleteWater(record: WaterRecord) {
viewModelScope.launch { 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"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>

View File

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