update README and WaterReminder.kt
254
README.md
@ -1,187 +1,165 @@
|
|||||||
Step & Drink
|
# 💧🏃 Step & Drink
|
||||||
Aplikasi Android untuk tracking langkah harian dan kebutuhan air minum menggunakan Kotlin dan Jetpack Compose.
|
|
||||||
|
|
||||||
📋 Deskripsi
|
Aplikasi Android untuk tracking langkah harian dan asupan air minum.
|
||||||
Step & Drink adalah aplikasi mobile yang membantu pengguna untuk:
|
|
||||||
|
|
||||||
Melacak langkah harian menggunakan sensor step counter bawaan smartphone
|
[](https://kotlinlang.org)
|
||||||
Mencatat asupan air minum untuk memenuhi kebutuhan hidrasi harian
|
[](https://developer.android.com/jetpack/compose)
|
||||||
Menetapkan dan memantau target langkah dan air minum yang dapat disesuaikan
|
[](https://developer.android.com)
|
||||||
Melihat riwayat aktivitas untuk evaluasi kebiasaan sehat
|
|
||||||
|
|
||||||
Aplikasi ini dibuat sebagai tugas akhir mata kuliah Pemrograman Bergerak dengan fokus pada penggunaan sensor hardware, database lokal, dan multi-halaman navigation.
|
---
|
||||||
|
|
||||||
✨ Fitur
|
## 📱 Tentang
|
||||||
1. Home Screen
|
|
||||||
|
|
||||||
Dashboard dengan ringkasan aktivitas hari ini
|
Step & Drink membantu kamu menjaga kesehatan dengan:
|
||||||
Card interaktif untuk langkah dan air minum
|
- 🏃 **Step Counter** - Track langkah harian secara real-time
|
||||||
Progress bar visual untuk tracking target
|
- 💧 **Water Tracker** - Catat asupan air dengan mudah
|
||||||
Greeting dengan nama pengguna
|
- ⏰ **Smart Reminder** - Notifikasi otomatis untuk minum teratur
|
||||||
|
- 📊 **Progress Monitor** - Lihat pencapaian target harian
|
||||||
|
|
||||||
2. Step Tracker
|
---
|
||||||
|
|
||||||
Real-time tracking langkah menggunakan sensor TYPE_STEP_COUNTER
|
## ✨ Fitur Utama
|
||||||
Start/Stop tracking dengan tombol floating action button
|
|
||||||
Riwayat langkah 7 hari terakhir
|
|
||||||
Target langkah yang dapat disesuaikan
|
|
||||||
|
|
||||||
3. Water Tracker
|
### Step Counter
|
||||||
|
- Real-time tracking dengan hardware sensor
|
||||||
|
- Target harian (default 10,000 langkah)
|
||||||
|
- History 7 hari
|
||||||
|
- Start/Stop control
|
||||||
|
|
||||||
Quick add buttons (250ml, 500ml, 1000ml)
|
### Water Tracker
|
||||||
Input custom untuk jumlah air
|
Pilihan container cepat:
|
||||||
Riwayat minum harian dengan timestamp
|
- 🥛 Gelas Kecil (200ml)
|
||||||
Hapus record jika salah input
|
- 💧 Gelas Sedang (250ml)
|
||||||
Target air minum yang dapat disesuaikan
|
- 🌊 Gelas Besar (350ml)
|
||||||
|
- 🚰 Botol Aqua (600ml)
|
||||||
|
- 🫗 Tumbler (500ml)
|
||||||
|
|
||||||
4. Profile & Settings
|
Plus input custom & history lengkap.
|
||||||
|
|
||||||
Edit nama pengguna
|
### Smart Reminder
|
||||||
Ubah target langkah harian
|
- Notifikasi otomatis setelah 3 jam tidak minum
|
||||||
Ubah target air minum harian
|
- Aktif jam 06:00 - 22:00
|
||||||
Informasi aplikasi
|
- Battery efficient
|
||||||
|
- Bisa di-toggle ON/OFF
|
||||||
|
|
||||||
|
### Profile
|
||||||
|
- Input nama & berat badan
|
||||||
|
- Set target langkah & air harian
|
||||||
|
- Data tersimpan lokal
|
||||||
|
|
||||||
🛠️ Teknologi
|
---
|
||||||
Bahasa & Framework
|
|
||||||
|
|
||||||
Kotlin - Bahasa pemrograman
|
## 🛠️ Teknologi
|
||||||
Jetpack Compose - UI Framework
|
|
||||||
Material Design 3 - Design system
|
|
||||||
|
|
||||||
Architecture
|
**Core:**
|
||||||
|
- Kotlin 100%
|
||||||
|
- Jetpack Compose
|
||||||
|
- Material Design 3
|
||||||
|
|
||||||
MVVM (Model-View-ViewModel)
|
**Architecture:**
|
||||||
Repository Pattern
|
- MVVM Pattern
|
||||||
Clean Architecture
|
- Room Database
|
||||||
|
- DataStore Preferences
|
||||||
|
- WorkManager
|
||||||
|
- Coroutines & Flow
|
||||||
|
|
||||||
Database & Storage
|
---
|
||||||
|
|
||||||
Room Database - Local database (SQLite)
|
## 🚀 Instalasi
|
||||||
DataStore Preferences - Settings storage
|
|
||||||
|
|
||||||
Components
|
```bash
|
||||||
|
# Clone repository
|
||||||
|
git clone https://github.com/HagaDalpintoGinting/Steps-Drink.git
|
||||||
|
|
||||||
Navigation Compose - Multi-halaman navigation
|
# Buka di Android Studio
|
||||||
Sensor Manager - Akses hardware sensor (Step Counter)
|
# Sync Gradle
|
||||||
ViewModel - State management
|
# Run aplikasi
|
||||||
Kotlin Flow - Reactive data stream
|
```
|
||||||
Coroutines - Asynchronous operations
|
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Android Studio (latest)
|
||||||
|
- Min SDK 26 (Android 8.0)
|
||||||
|
- Device dengan step counter sensor (untuk fitur langkah)
|
||||||
|
|
||||||
📦 Struktur Project
|
---
|
||||||
stepdrink/
|
|
||||||
├── data/
|
|
||||||
│ ├── local/
|
|
||||||
│ │ ├── entity/ # StepRecord, WaterRecord
|
|
||||||
│ │ ├── dao/ # StepDao, WaterDao
|
|
||||||
│ │ ├── database/ # AppDatabase
|
|
||||||
│ │ └── PreferencesManager.kt
|
|
||||||
│ └── repository/ # StepRepository, WaterRepository
|
|
||||||
├── ui/
|
|
||||||
│ ├── screen/ # HomeScreen, StepsScreen, WaterScreen, ProfileScreen
|
|
||||||
│ ├── navigation/ # Navigation setup
|
|
||||||
│ ├── components/ # Reusable UI components
|
|
||||||
│ └── theme/ # App theming
|
|
||||||
├── viewmodel/ # StepViewModel, WaterViewModel, ProfileViewModel
|
|
||||||
├── sensor/ # StepCounterManager
|
|
||||||
├── util/ # DateUtils
|
|
||||||
└── MainActivity.kt
|
|
||||||
|
|
||||||
🗄️ Database
|
## 📖 Penggunaan
|
||||||
step_records
|
|
||||||
Menyimpan data langkah harian
|
|
||||||
|
|
||||||
id - Primary key
|
### Setup
|
||||||
date - Tanggal (yyyy-MM-dd)
|
1. Buka aplikasi
|
||||||
steps - Jumlah langkah
|
2. Masuk ke Profile
|
||||||
timestamp - Waktu pencatatan
|
3. Input data pribadi & set target
|
||||||
|
|
||||||
water_records
|
### Track Langkah
|
||||||
Menyimpan data minum air
|
1. Buka halaman Steps
|
||||||
|
2. Tap tombol Start
|
||||||
|
3. Mulai berjalan
|
||||||
|
|
||||||
id - Primary key
|
### Track Air
|
||||||
date - Tanggal (yyyy-MM-dd)
|
1. Buka halaman Water
|
||||||
amount - Jumlah air (ml)
|
2. Tap container (contoh: 🚰 600ml)
|
||||||
timestamp - Waktu pencatatan
|
3. Atau klik "Input Custom" untuk jumlah lain
|
||||||
|
|
||||||
Preferences (DataStore)
|
### Aktifkan Reminder
|
||||||
Menyimpan pengaturan pengguna
|
1. Buka halaman Water
|
||||||
|
2. Toggle "Pengingat Minum" ke ON
|
||||||
|
3. Izinkan permission notifikasi
|
||||||
|
|
||||||
user_name - Nama pengguna
|
---
|
||||||
step_goal - Target langkah harian
|
|
||||||
water_goal - Target air minum harian (ml)
|
|
||||||
|
|
||||||
|
## ⚙️ Konfigurasi
|
||||||
|
|
||||||
🚀 Instalasi
|
Ubah settings reminder di `WaterReminderManager.kt`:
|
||||||
Requirements
|
```kotlin
|
||||||
|
const val CHECK_INTERVAL_MINUTES = 30L // Interval cek
|
||||||
|
const val REMIND_AFTER_HOURS = 3 // Reminder setelah X jam
|
||||||
|
const val ACTIVE_START_HOUR = 6 // Jam mulai
|
||||||
|
const val ACTIVE_END_HOUR = 22 // Jam selesai
|
||||||
|
```
|
||||||
|
|
||||||
Android Studio (versi terbaru)
|
---
|
||||||
Minimum SDK: API 26 (Android 8.0)
|
|
||||||
Kotlin 1.9.22
|
|
||||||
|
|
||||||
Cara Menjalankan
|
## 📝 Catatan
|
||||||
|
|
||||||
Clone atau download project
|
**Tested on:** Samsung Galaxy A35 5G (Android 14)
|
||||||
Buka di Android Studio
|
|
||||||
Sync Gradle (File → Sync Project with Gradle Files)
|
|
||||||
Build project (Build → Make Project)
|
|
||||||
Run di device atau emulator
|
|
||||||
Izinkan permission ACTIVITY_RECOGNITION saat diminta
|
|
||||||
|
|
||||||
|
**Limitations:**
|
||||||
|
- Step counter butuh device fisik (emulator tidak support)
|
||||||
|
- Data tersimpan lokal saja
|
||||||
|
- Reminder bergantung pada battery optimization device
|
||||||
|
|
||||||
📱 Cara Penggunaan
|
**Troubleshooting:**
|
||||||
|
- Steps tidak terhitung? Pastikan permission ACTIVITY_RECOGNITION diizinkan
|
||||||
|
- Reminder tidak muncul? Check permission POST_NOTIFICATIONS dan pastikan jam aktif
|
||||||
|
|
||||||
Tracking Langkah
|
---
|
||||||
|
|
||||||
Buka Step Tracker
|
## 🎓 Project
|
||||||
Klik tombol Play
|
|
||||||
Izinkan permission
|
|
||||||
Mulai berjalan
|
|
||||||
Data otomatis tersimpan
|
|
||||||
|
|
||||||
|
Tugas Akhir Pemrograman Bergerak dengan requirement:
|
||||||
|
- ✅ Multi-screen
|
||||||
|
- ✅ Hardware sensor
|
||||||
|
- ✅ Local database
|
||||||
|
- ✅ Kotlin & Jetpack Compose
|
||||||
|
|
||||||
Catat Air Minum
|
---
|
||||||
|
|
||||||
Buka Water Tracker
|
## 👨💻 Developer
|
||||||
Klik quick add atau tombol +
|
|
||||||
Input jumlah air
|
|
||||||
Data tersimpan dengan timestamp
|
|
||||||
|
|
||||||
|
**Haga Dalpinto Ginting**
|
||||||
|
|
||||||
Ubah Target
|
GitHub: [@HagaDalpintoGinting](https://github.com/HagaDalpintoGinting)
|
||||||
|
|
||||||
Buka Profile
|
---
|
||||||
Klik card yang ingin diubah
|
|
||||||
Input nilai baru
|
|
||||||
Simpan
|
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT License © 2025
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
🎯 Tujuan Project
|
<div align="center">
|
||||||
Project ini dibuat untuk memenuhi tugas akhir mata kuliah Pemrograman Bergerak dengan requirements:
|
|
||||||
|
|
||||||
✅ Multi-halaman (4 screens)
|
Made with ❤️ using Kotlin & Jetpack Compose
|
||||||
✅ Penggunaan sensor (Step Counter)
|
|
||||||
✅ Database lokal (Room)
|
|
||||||
✅ IDE Android Studio
|
|
||||||
✅ Bahasa Kotlin
|
|
||||||
✅ UI Framework Jetpack Compose
|
|
||||||
|
|
||||||
|
</div>
|
||||||
👨💻 Developer
|
|
||||||
Nama: Haga Dalpinto Ginting
|
|
||||||
|
|
||||||
📝 Catatan
|
|
||||||
|
|
||||||
Aplikasi memerlukan device dengan sensor step counter untuk fitur tracking langkah
|
|
||||||
Emulator Android biasanya tidak memiliki sensor step counter
|
|
||||||
Data disimpan secara lokal di device
|
|
||||||
Aplikasi masih dalam tahap pengembangan dan dapat dikembangkan lebih lanjut
|
|
||||||
|
|
||||||
|
|
||||||
📄 License
|
|
||||||
MIT License - Copyright (c) 2025
|
|
||||||
|
|
||||||
Made with Kotlin & Jetpack Compose
|
|
||||||
@ -51,6 +51,8 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
// WorkManager untuk reminder
|
||||||
|
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||||
// Core Android
|
// Core Android
|
||||||
implementation("androidx.core:core-ktx:1.12.0")
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||||
|
|||||||
@ -8,6 +8,9 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
<!-- Require step counter sensor -->
|
<!-- Require step counter sensor -->
|
||||||
<uses-feature
|
<uses-feature
|
||||||
|
|||||||
@ -4,18 +4,25 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.viewModels
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.example.stepdrink.ui.navigation.NavGraph
|
import com.example.stepdrink.ui.navigation.AppNavigation
|
||||||
import com.example.stepdrink.ui.theme.StepDrinkTheme
|
import com.example.stepdrink.ui.theme.StepDrinkTheme
|
||||||
|
import com.example.stepdrink.viewmodel.ProfileViewModel
|
||||||
import com.example.stepdrink.viewmodel.StepViewModel
|
import com.example.stepdrink.viewmodel.StepViewModel
|
||||||
import com.example.stepdrink.viewmodel.WaterViewModel
|
import com.example.stepdrink.viewmodel.WaterViewModel
|
||||||
import com.example.stepdrink.viewmodel.ProfileViewModel
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
// Inisialisasi ViewModels menggunakan viewModels() delegate
|
||||||
|
private val stepViewModel: StepViewModel by viewModels()
|
||||||
|
private val waterViewModel: WaterViewModel by viewModels()
|
||||||
|
private val profileViewModel: ProfileViewModel by viewModels()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
@ -26,11 +33,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
color = MaterialTheme.colorScheme.background
|
color = MaterialTheme.colorScheme.background
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val stepViewModel: StepViewModel = viewModel()
|
|
||||||
val waterViewModel: WaterViewModel = viewModel()
|
|
||||||
val profileViewModel: ProfileViewModel = viewModel()
|
|
||||||
|
|
||||||
NavGraph(
|
// Gunakan AppNavigation yang sudah include splash
|
||||||
|
AppNavigation(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
stepViewModel = stepViewModel,
|
stepViewModel = stepViewModel,
|
||||||
waterViewModel = waterViewModel,
|
waterViewModel = waterViewModel,
|
||||||
|
|||||||
@ -6,18 +6,40 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface WaterDao {
|
interface WaterDao {
|
||||||
@Insert
|
|
||||||
suspend fun insertWaterRecord(waterRecord: WaterRecord)
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert new water record
|
||||||
|
*/
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertWater(waterRecord: WaterRecord)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete water record
|
||||||
|
*/
|
||||||
|
@Delete
|
||||||
|
suspend fun deleteWater(waterRecord: WaterRecord)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all water records for a specific date
|
||||||
|
*/
|
||||||
@Query("SELECT * FROM water_records WHERE date = :date ORDER BY timestamp DESC")
|
@Query("SELECT * FROM water_records WHERE date = :date ORDER BY timestamp DESC")
|
||||||
fun getWaterRecordsByDate(date: String): Flow<List<WaterRecord>>
|
fun getWaterByDate(date: String): Flow<List<WaterRecord>>
|
||||||
|
|
||||||
@Query("SELECT SUM(amount) FROM water_records WHERE date = :date")
|
/**
|
||||||
fun getTotalWaterByDate(date: String): Flow<Int?>
|
* Get last 7 days water records
|
||||||
|
*/
|
||||||
@Query("SELECT * FROM water_records ORDER BY date DESC LIMIT 7")
|
@Query("SELECT * FROM water_records ORDER BY date DESC, timestamp DESC LIMIT 50")
|
||||||
fun getLast7DaysWater(): Flow<List<WaterRecord>>
|
fun getLast7DaysWater(): Flow<List<WaterRecord>>
|
||||||
|
|
||||||
@Delete
|
/**
|
||||||
suspend fun deleteWaterRecord(waterRecord: WaterRecord)
|
* Get all water records (for backup/export)
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM water_records ORDER BY date DESC, timestamp DESC")
|
||||||
|
fun getAllWater(): Flow<List<WaterRecord>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all water records (for reset)
|
||||||
|
*/
|
||||||
|
@Query("DELETE FROM water_records")
|
||||||
|
suspend fun deleteAllWater()
|
||||||
}
|
}
|
||||||
@ -6,23 +6,36 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
|
|
||||||
class WaterRepository(private val waterDao: WaterDao) {
|
class WaterRepository(private val waterDao: WaterDao) {
|
||||||
|
|
||||||
fun getWaterRecordsByDate(date: String): Flow<List<WaterRecord>> {
|
/**
|
||||||
return waterDao.getWaterRecordsByDate(date)
|
* Get all water records for a specific date
|
||||||
|
*/
|
||||||
|
fun getWaterByDate(date: String): Flow<List<WaterRecord>> {
|
||||||
|
return waterDao.getWaterByDate(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getTotalWaterByDate(date: String): Flow<Int?> {
|
/**
|
||||||
return waterDao.getTotalWaterByDate(date)
|
* Insert new water record
|
||||||
|
*/
|
||||||
|
suspend fun insertWater(date: String, amount: Int) {
|
||||||
|
val record = WaterRecord(
|
||||||
|
date = date,
|
||||||
|
amount = amount,
|
||||||
|
timestamp = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
waterDao.insertWater(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete water record
|
||||||
|
*/
|
||||||
|
suspend fun deleteWater(record: WaterRecord) {
|
||||||
|
waterDao.deleteWater(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get last 7 days water records
|
||||||
|
*/
|
||||||
fun getLast7DaysWater(): Flow<List<WaterRecord>> {
|
fun getLast7DaysWater(): Flow<List<WaterRecord>> {
|
||||||
return waterDao.getLast7DaysWater()
|
return waterDao.getLast7DaysWater()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun addWaterRecord(date: String, amount: Int) {
|
|
||||||
waterDao.insertWaterRecord(WaterRecord(date = date, amount = amount))
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun deleteWaterRecord(waterRecord: WaterRecord) {
|
|
||||||
waterDao.deleteWaterRecord(waterRecord)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -5,15 +5,20 @@ import androidx.navigation.NavHostController
|
|||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import com.example.stepdrink.ui.screen.home.HomeScreen
|
import com.example.stepdrink.ui.screen.home.HomeScreen
|
||||||
|
import com.example.stepdrink.ui.screen.profile.ProfileScreen
|
||||||
|
import com.example.stepdrink.ui.screen.splash.SplashScreen
|
||||||
import com.example.stepdrink.ui.screen.steps.StepsScreen
|
import com.example.stepdrink.ui.screen.steps.StepsScreen
|
||||||
import com.example.stepdrink.ui.screen.water.WaterScreen
|
import com.example.stepdrink.ui.screen.water.WaterScreen
|
||||||
import com.example.stepdrink.ui.screen.profile.ProfileScreen
|
import com.example.stepdrink.viewmodel.ProfileViewModel
|
||||||
import com.example.stepdrink.viewmodel.StepViewModel
|
import com.example.stepdrink.viewmodel.StepViewModel
|
||||||
import com.example.stepdrink.viewmodel.WaterViewModel
|
import com.example.stepdrink.viewmodel.WaterViewModel
|
||||||
import com.example.stepdrink.viewmodel.ProfileViewModel
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AppNavigation - Single NavHost untuk semua screen (termasuk splash)
|
||||||
|
* Ini menghindari nested NavHost yang menyebabkan ViewModelStore error
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun NavGraph(
|
fun AppNavigation(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
stepViewModel: StepViewModel,
|
stepViewModel: StepViewModel,
|
||||||
waterViewModel: WaterViewModel,
|
waterViewModel: WaterViewModel,
|
||||||
@ -21,8 +26,14 @@ fun NavGraph(
|
|||||||
) {
|
) {
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = Screen.Home.route
|
startDestination = Screen.Splash.route
|
||||||
) {
|
) {
|
||||||
|
// Splash Screen
|
||||||
|
composable(Screen.Splash.route) {
|
||||||
|
SplashScreen(navController = navController)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Home Screen
|
||||||
composable(Screen.Home.route) {
|
composable(Screen.Home.route) {
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
@ -32,6 +43,7 @@ fun NavGraph(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Steps Screen
|
||||||
composable(Screen.Steps.route) {
|
composable(Screen.Steps.route) {
|
||||||
StepsScreen(
|
StepsScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
@ -39,17 +51,20 @@ fun NavGraph(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Water Screen
|
||||||
composable(Screen.Water.route) {
|
composable(Screen.Water.route) {
|
||||||
WaterScreen(
|
WaterScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
viewModel = waterViewModel
|
viewModel = waterViewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Profile Screen
|
||||||
composable(Screen.Profile.route) {
|
composable(Screen.Profile.route) {
|
||||||
ProfileScreen(
|
ProfileScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
viewModel = profileViewModel )
|
viewModel = profileViewModel
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package com.example.stepdrink.ui.navigation
|
package com.example.stepdrink.ui.navigation
|
||||||
|
|
||||||
sealed class Screen(val route: String) {
|
sealed class Screen(val route: String) {
|
||||||
|
object Splash : Screen("splash")
|
||||||
object Home : Screen("home")
|
object Home : Screen("home")
|
||||||
object Steps : Screen("steps")
|
object Steps : Screen("steps")
|
||||||
object Water : Screen("water")
|
object Water : Screen("water")
|
||||||
|
|||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,17 +1,30 @@
|
|||||||
package com.example.stepdrink.ui.screen.water
|
package com.example.stepdrink.ui.screen.water
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import com.example.stepdrink.util.WaterReminderManager
|
||||||
import com.example.stepdrink.viewmodel.WaterViewModel
|
import com.example.stepdrink.viewmodel.WaterViewModel
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@ -22,11 +35,35 @@ fun WaterScreen(
|
|||||||
navController: NavController,
|
navController: NavController,
|
||||||
viewModel: WaterViewModel
|
viewModel: WaterViewModel
|
||||||
) {
|
) {
|
||||||
val todayRecords by viewModel.todayWaterRecords.collectAsState()
|
val context = LocalContext.current
|
||||||
val totalWater by viewModel.todayTotalWater.collectAsState()
|
val reminderManager = remember { WaterReminderManager(context) }
|
||||||
val waterGoal by viewModel.dailyGoal.collectAsState()
|
|
||||||
|
|
||||||
var showDialog by remember { mutableStateOf(false) }
|
val todayWater by viewModel.todayTotalWater.collectAsState()
|
||||||
|
val waterGoal by viewModel.dailyGoal.collectAsState()
|
||||||
|
val todayRecords by viewModel.todayWaterRecords.collectAsState()
|
||||||
|
|
||||||
|
var showCustomDialog by remember { mutableStateOf(false) }
|
||||||
|
var showSuccessAnimation by remember { mutableStateOf(false) }
|
||||||
|
var reminderEnabled by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Permission launcher untuk notifikasi (Android 13+)
|
||||||
|
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { isGranted ->
|
||||||
|
if (isGranted) {
|
||||||
|
reminderManager.scheduleReminder()
|
||||||
|
reminderEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success animation
|
||||||
|
LaunchedEffect(todayRecords.size) {
|
||||||
|
if (todayRecords.isNotEmpty()) {
|
||||||
|
showSuccessAnimation = true
|
||||||
|
kotlinx.coroutines.delay(800)
|
||||||
|
showSuccessAnimation = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@ -36,160 +73,408 @@ fun WaterScreen(
|
|||||||
IconButton(onClick = { navController.popBackStack() }) {
|
IconButton(onClick = { navController.popBackStack() }) {
|
||||||
Icon(Icons.Default.ArrowBack, "Kembali")
|
Icon(Icons.Default.ArrowBack, "Kembali")
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
)
|
)
|
||||||
},
|
|
||||||
floatingActionButton = {
|
|
||||||
FloatingActionButton(
|
|
||||||
onClick = { showDialog = true }
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Add, "Tambah")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
LazyColumn(
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
modifier = Modifier
|
LazyColumn(
|
||||||
.fillMaxSize()
|
modifier = Modifier
|
||||||
.padding(paddingValues)
|
.fillMaxSize()
|
||||||
.padding(16.dp),
|
.padding(paddingValues)
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
.padding(16.dp),
|
||||||
) {
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
item {
|
) {
|
||||||
// Water Progress Card
|
// Main Progress Card
|
||||||
Card(
|
item {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Card(
|
||||||
colors = CardDefaults.cardColors(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer
|
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
|
||||||
)
|
elevation = CardDefaults.cardElevation(4.dp)
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Box(
|
||||||
imageVector = Icons.Default.WaterDrop,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(64.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.tertiary
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "${totalWater}ml",
|
|
||||||
style = MaterialTheme.typography.displayLarge,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Air Minum Hari Ini",
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { totalWater.toFloat() / waterGoal.toFloat() },
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(8.dp),
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
listOf(Color(0xFF2196F3), Color(0xFF64B5F6))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(24.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.WaterDrop,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "${todayWater}ml",
|
||||||
|
style = MaterialTheme.typography.displayLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Air Minum Hari Ini",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.9f)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { todayWater.toFloat() / waterGoal.toFloat() },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(10.dp)
|
||||||
|
.clip(MaterialTheme.shapes.small),
|
||||||
|
color = Color.White,
|
||||||
|
trackColor = Color.White.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Target: ${waterGoal}ml",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.9f)
|
||||||
|
)
|
||||||
|
|
||||||
|
val remaining = waterGoal - todayWater
|
||||||
|
if (remaining > 0) {
|
||||||
|
Text(
|
||||||
|
text = "Sisa ${remaining}ml lagi!",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color.White.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "🎉 Target tercapai!",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reminder Settings Card
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||||
)
|
)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Notifications,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"Pengingat Minum",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Ingatkan jika 3 jam tidak minum",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Switch(
|
||||||
|
checked = reminderEnabled,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
if (enabled) {
|
||||||
|
// Check & request notification permission
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
} else {
|
||||||
|
reminderManager.scheduleReminder()
|
||||||
|
reminderEnabled = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reminderManager.cancelReminder()
|
||||||
|
reminderEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reminderEnabled) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
|
||||||
|
shape = MaterialTheme.shapes.small
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Info,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Aktif jam 06:00 - 22:00",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container selection
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "Pilih Wadah:",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
// Row 1
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
viewModel.presetContainers.take(2).forEach { container ->
|
||||||
|
ContainerCard(
|
||||||
|
container = container,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
onClick = { viewModel.addWaterFromContainer(container) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 2
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
viewModel.presetContainers.drop(2).take(2).forEach { container ->
|
||||||
|
ContainerCard(
|
||||||
|
container = container,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
onClick = { viewModel.addWaterFromContainer(container) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 3 - Last item centered
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
ContainerCard(
|
||||||
|
container = viewModel.presetContainers[4],
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
onClick = { viewModel.addWaterFromContainer(viewModel.presetContainers[4]) }
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom amount
|
||||||
|
item {
|
||||||
|
OutlinedCard(
|
||||||
|
onClick = { showCustomDialog = true },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Settings, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
"Input Jumlah Custom",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// History
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Target: ${waterGoal}ml",
|
"Riwayat Hari Ini",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${todayRecords.size} kali minum",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (todayRecords.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text("💧", style = MaterialTheme.typography.displayMedium)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"Belum ada riwayat hari ini",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Yuk mulai minum air!",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(todayRecords.sortedByDescending { it.timestamp }) { record ->
|
||||||
|
WaterRecordItem(
|
||||||
|
record = record,
|
||||||
|
container = viewModel.getContainerByAmount(record.amount),
|
||||||
|
onDelete = { viewModel.deleteWater(record) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
// Success animation
|
||||||
// Quick Add Buttons
|
AnimatedVisibility(
|
||||||
Row(
|
visible = showSuccessAnimation,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
enter = scaleIn(initialScale = 0.3f) + fadeIn(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
exit = scaleOut(targetScale = 1.5f) + fadeOut(),
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = CircleShape,
|
||||||
|
color = Color.White,
|
||||||
|
shadowElevation = 8.dp,
|
||||||
|
modifier = Modifier.size(100.dp)
|
||||||
) {
|
) {
|
||||||
QuickAddButton(
|
Box(contentAlignment = Alignment.Center) {
|
||||||
amount = 250,
|
Text("💧", style = MaterialTheme.typography.displayLarge)
|
||||||
onClick = { viewModel.addWater(250) },
|
}
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
QuickAddButton(
|
|
||||||
amount = 500,
|
|
||||||
onClick = { viewModel.addWater(500) },
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
QuickAddButton(
|
|
||||||
amount = 1000,
|
|
||||||
onClick = { viewModel.addWater(1000) },
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
text = "Riwayat Hari Ini",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
items(todayRecords) { record ->
|
|
||||||
WaterRecordItem(
|
|
||||||
amount = record.amount,
|
|
||||||
timestamp = record.timestamp,
|
|
||||||
onDelete = { viewModel.deleteWaterRecord(record) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showDialog) {
|
if (showCustomDialog) {
|
||||||
AddWaterDialog(
|
CustomAmountDialog(
|
||||||
onDismiss = { showDialog = false },
|
onDismiss = { showCustomDialog = false },
|
||||||
onConfirm = { amount ->
|
onConfirm = { amount ->
|
||||||
viewModel.addWater(amount)
|
viewModel.addWater(amount)
|
||||||
showDialog = false
|
showCustomDialog = false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun QuickAddButton(
|
fun ContainerCard(
|
||||||
amount: Int,
|
container: com.example.stepdrink.viewmodel.WaterContainer,
|
||||||
onClick: () -> Unit,
|
modifier: Modifier = Modifier,
|
||||||
modifier: Modifier = Modifier
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
OutlinedButton(
|
Card(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier
|
modifier = modifier.height(120.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color(container.color).copy(alpha = 0.1f)
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(2.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.WaterDrop, contentDescription = null)
|
Text(
|
||||||
Text("${amount}ml")
|
text = container.emoji,
|
||||||
|
style = MaterialTheme.typography.displaySmall
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = container.name,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color(container.color)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${container.amount}ml",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color(container.color).copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WaterRecordItem(
|
fun WaterRecordItem(
|
||||||
amount: Int,
|
record: com.example.stepdrink.data.local.entity.WaterRecord,
|
||||||
timestamp: Long,
|
container: com.example.stepdrink.viewmodel.WaterContainer?,
|
||||||
onDelete: () -> Unit
|
onDelete: () -> Unit
|
||||||
) {
|
) {
|
||||||
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
|
||||||
val time = timeFormat.format(Date(timestamp))
|
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(1.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -199,31 +484,51 @@ fun WaterRecordItem(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Surface(
|
||||||
imageVector = Icons.Default.WaterDrop,
|
shape = CircleShape,
|
||||||
contentDescription = null,
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
tint = MaterialTheme.colorScheme.primary
|
modifier = Modifier.size(48.dp)
|
||||||
)
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = container?.emoji ?: "💧",
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
text = "${amount}ml",
|
text = container?.name ?: "Custom",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
Text(
|
Row(
|
||||||
text = time,
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
verticalAlignment = Alignment.CenterVertically
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
) {
|
||||||
)
|
Text(
|
||||||
|
text = "${record.amount}ml",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Text("•", style = MaterialTheme.typography.bodySmall)
|
||||||
|
Text(
|
||||||
|
text = SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||||
|
.format(Date(record.timestamp)),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IconButton(onClick = onDelete) {
|
IconButton(onClick = onDelete) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Delete,
|
Icons.Default.Delete,
|
||||||
contentDescription = "Hapus",
|
contentDescription = "Hapus",
|
||||||
tint = MaterialTheme.colorScheme.error
|
tint = MaterialTheme.colorScheme.error
|
||||||
)
|
)
|
||||||
@ -233,7 +538,7 @@ fun WaterRecordItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AddWaterDialog(
|
fun CustomAmountDialog(
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onConfirm: (Int) -> Unit
|
onConfirm: (Int) -> Unit
|
||||||
) {
|
) {
|
||||||
@ -241,21 +546,24 @@ fun AddWaterDialog(
|
|||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("Tambah Air Minum") },
|
icon = { Icon(Icons.Default.WaterDrop, contentDescription = null) },
|
||||||
|
title = { Text("Input Jumlah Air") },
|
||||||
text = {
|
text = {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = amount,
|
value = amount,
|
||||||
onValueChange = { amount = it.filter { char -> char.isDigit() } },
|
onValueChange = { amount = it.filter { char -> char.isDigit() } },
|
||||||
label = { Text("Jumlah (ml)") },
|
label = { Text("Jumlah (ml)") },
|
||||||
singleLine = true
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
suffix = { Text("ml") }
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
val amountInt = amount.toIntOrNull()
|
val value = amount.toIntOrNull()
|
||||||
if (amountInt != null && amountInt > 0) {
|
if (value != null && value > 0) {
|
||||||
onConfirm(amountInt)
|
onConfirm(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -1,46 +1,214 @@
|
|||||||
package com.example.stepdrink.ui.theme
|
package com.example.stepdrink.ui.theme
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.darkColorScheme
|
|
||||||
import androidx.compose.material3.lightColorScheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
// Color palette dari icon
|
||||||
primary = androidx.compose.ui.graphics.Color(0xFF90CAF9),
|
val TealPrimary = Color(0xFF7BC8BC) // Mint/Teal utama
|
||||||
secondary = androidx.compose.ui.graphics.Color(0xFF81C784),
|
val TealLight = Color(0xFFA8E6CF) // Light mint
|
||||||
tertiary = androidx.compose.ui.graphics.Color(0xFF64B5F6)
|
val TealDark = Color(0xFF4A9B8E) // Dark teal
|
||||||
|
val BluePrimary = Color(0xFF5DADE2) // Blue dari gradient
|
||||||
|
val NavyAccent = Color(0xFF2C3E50) // Dark blue accent
|
||||||
|
val OrangeAccent = Color(0xFFF39C12) // Orange highlight
|
||||||
|
val Background = Color(0xFFF5F9F8) // Very light teal background
|
||||||
|
|
||||||
|
// Light Theme Colors
|
||||||
|
private val LightColorScheme = lightColorScheme(
|
||||||
|
primary = TealPrimary,
|
||||||
|
onPrimary = Color.White,
|
||||||
|
primaryContainer = TealLight,
|
||||||
|
onPrimaryContainer = NavyAccent,
|
||||||
|
|
||||||
|
secondary = BluePrimary,
|
||||||
|
onSecondary = Color.White,
|
||||||
|
secondaryContainer = Color(0xFFE3F2FD),
|
||||||
|
onSecondaryContainer = NavyAccent,
|
||||||
|
|
||||||
|
tertiary = OrangeAccent,
|
||||||
|
onTertiary = Color.White,
|
||||||
|
tertiaryContainer = Color(0xFFFFE6CC),
|
||||||
|
onTertiaryContainer = Color(0xFF8B5A00),
|
||||||
|
|
||||||
|
background = Background,
|
||||||
|
onBackground = NavyAccent,
|
||||||
|
|
||||||
|
surface = Color.White,
|
||||||
|
onSurface = NavyAccent,
|
||||||
|
surfaceVariant = Color(0xFFE8F5F3),
|
||||||
|
onSurfaceVariant = Color(0xFF5A6A6C),
|
||||||
|
|
||||||
|
error = Color(0xFFE74C3C),
|
||||||
|
onError = Color.White,
|
||||||
|
errorContainer = Color(0xFFFFDAD6),
|
||||||
|
onErrorContainer = Color(0xFF93000A),
|
||||||
|
|
||||||
|
outline = Color(0xFFB0BEC5),
|
||||||
|
outlineVariant = Color(0xFFCFD8DC)
|
||||||
)
|
)
|
||||||
|
|
||||||
private val LightColorScheme = lightColorScheme(
|
// Dark Theme Colors
|
||||||
primary = androidx.compose.ui.graphics.Color(0xFF1976D2),
|
private val DarkColorScheme = darkColorScheme(
|
||||||
secondary = androidx.compose.ui.graphics.Color(0xFF388E3C),
|
primary = TealPrimary,
|
||||||
tertiary = androidx.compose.ui.graphics.Color(0xFF0288D1)
|
onPrimary = NavyAccent,
|
||||||
|
primaryContainer = TealDark,
|
||||||
|
onPrimaryContainer = TealLight,
|
||||||
|
|
||||||
|
secondary = BluePrimary,
|
||||||
|
onSecondary = NavyAccent,
|
||||||
|
secondaryContainer = Color(0xFF1E3A5F),
|
||||||
|
onSecondaryContainer = Color(0xFFB3D9FF),
|
||||||
|
|
||||||
|
tertiary = OrangeAccent,
|
||||||
|
onTertiary = NavyAccent,
|
||||||
|
tertiaryContainer = Color(0xFF8B5A00),
|
||||||
|
onTertiaryContainer = Color(0xFFFFD9A3),
|
||||||
|
|
||||||
|
background = Color(0xFF1A2329),
|
||||||
|
onBackground = Color(0xFFE1E8E8),
|
||||||
|
|
||||||
|
surface = Color(0xFF1F2A30),
|
||||||
|
onSurface = Color(0xFFE1E8E8),
|
||||||
|
surfaceVariant = Color(0xFF2C3E50),
|
||||||
|
onSurfaceVariant = Color(0xFFB8C5C8),
|
||||||
|
|
||||||
|
error = Color(0xFFFFB4AB),
|
||||||
|
onError = Color(0xFF690005),
|
||||||
|
errorContainer = Color(0xFF93000A),
|
||||||
|
onErrorContainer = Color(0xFFFFDAD6),
|
||||||
|
|
||||||
|
outline = Color(0xFF8B9A9D),
|
||||||
|
outlineVariant = Color(0xFF3F5054)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Typography untuk Material3
|
||||||
|
private val AppTypography = androidx.compose.material3.Typography(
|
||||||
|
displayLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 57.sp,
|
||||||
|
lineHeight = 64.sp,
|
||||||
|
letterSpacing = (-0.25).sp
|
||||||
|
),
|
||||||
|
displayMedium = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 45.sp,
|
||||||
|
lineHeight = 52.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
),
|
||||||
|
displaySmall = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 36.sp,
|
||||||
|
lineHeight = 44.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
),
|
||||||
|
headlineLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 32.sp,
|
||||||
|
lineHeight = 40.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
),
|
||||||
|
headlineMedium = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
lineHeight = 36.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
),
|
||||||
|
headlineSmall = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 24.sp,
|
||||||
|
lineHeight = 32.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
),
|
||||||
|
titleLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
lineHeight = 28.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
),
|
||||||
|
titleMedium = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.15.sp
|
||||||
|
),
|
||||||
|
titleSmall = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.1.sp
|
||||||
|
),
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
),
|
||||||
|
bodyMedium = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.25.sp
|
||||||
|
),
|
||||||
|
bodySmall = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
letterSpacing = 0.4.sp
|
||||||
|
),
|
||||||
|
labelLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.1.sp
|
||||||
|
),
|
||||||
|
labelMedium = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
),
|
||||||
|
labelSmall = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StepDrinkTheme(
|
fun StepDrinkTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
dynamicColor: Boolean = false, // Disable dynamic color untuk konsistensi
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
|
val colorScheme = when {
|
||||||
val view = LocalView.current
|
darkTheme -> DarkColorScheme
|
||||||
|
else -> LightColorScheme
|
||||||
if (!view.isInEditMode) {
|
|
||||||
SideEffect {
|
|
||||||
val window = (view.context as Activity).window
|
|
||||||
window.statusBarColor = colorScheme.primary.toArgb()
|
|
||||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
|
typography = AppTypography, // Gunakan AppTypography yang sudah didefinisikan
|
||||||
content = content
|
content = content
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
168
app/src/main/java/util/WaterReminder.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,47 +3,79 @@ package com.example.stepdrink.viewmodel
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.example.stepdrink.data.local.database.AppDatabase
|
|
||||||
import com.example.stepdrink.data.local.PreferencesManager
|
import com.example.stepdrink.data.local.PreferencesManager
|
||||||
|
import com.example.stepdrink.data.local.database.AppDatabase
|
||||||
import com.example.stepdrink.data.local.entity.WaterRecord
|
import com.example.stepdrink.data.local.entity.WaterRecord
|
||||||
import com.example.stepdrink.data.repository.WaterRepository
|
import com.example.stepdrink.data.repository.WaterRepository
|
||||||
import com.example.stepdrink.util.DateUtils
|
import com.example.stepdrink.util.DateUtils
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
// Data class untuk preset containers
|
||||||
|
data class WaterContainer(
|
||||||
|
val emoji: String,
|
||||||
|
val name: String,
|
||||||
|
val amount: Int,
|
||||||
|
val color: Long = 0xFF2196F3
|
||||||
|
)
|
||||||
|
|
||||||
class WaterViewModel(application: Application) : AndroidViewModel(application) {
|
class WaterViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val repository: WaterRepository = WaterRepository(
|
private val repository: WaterRepository
|
||||||
AppDatabase.getDatabase(application).waterDao()
|
|
||||||
)
|
|
||||||
|
|
||||||
private val preferencesManager = PreferencesManager(application)
|
private val preferencesManager = PreferencesManager(application)
|
||||||
|
|
||||||
val dailyGoal: StateFlow<Int> = preferencesManager.waterGoal
|
val dailyGoal: StateFlow<Int> = preferencesManager.waterGoal
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 2000)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 2000)
|
||||||
|
|
||||||
val todayWaterRecords: StateFlow<List<WaterRecord>> =
|
val todayWaterRecords: StateFlow<List<WaterRecord>>
|
||||||
repository.getWaterRecordsByDate(DateUtils.getCurrentDate())
|
val todayTotalWater: StateFlow<Int>
|
||||||
|
|
||||||
|
// SIMPLE CONTAINERS - Kategori yang clear & sederhana
|
||||||
|
val presetContainers = listOf(
|
||||||
|
WaterContainer("🥛", "Gelas Kecil", 200, 0xFF64B5F6), // Small glass
|
||||||
|
WaterContainer("💧", "Gelas Sedang", 250, 0xFF42A5F5), // Medium glass (STANDARD)
|
||||||
|
WaterContainer("🌊", "Gelas Besar", 350, 0xFF2196F3), // Large glass
|
||||||
|
WaterContainer("🚰", "Botol Aqua", 600, 0xFF1976D2), // Aqua bottle (MOST COMMON)
|
||||||
|
WaterContainer("🫗", "Tumbler", 500, 0xFF1565C0), // Tumbler
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
val database = AppDatabase.getDatabase(application)
|
||||||
|
repository = WaterRepository(database.waterDao())
|
||||||
|
|
||||||
|
todayWaterRecords = repository.getWaterByDate(DateUtils.getCurrentDate())
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||||
|
|
||||||
val todayTotalWater: StateFlow<Int> =
|
todayTotalWater = todayWaterRecords.map { records ->
|
||||||
repository.getTotalWaterByDate(DateUtils.getCurrentDate())
|
records.sumOf { it.amount }
|
||||||
.map { it ?: 0 }
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
}
|
||||||
|
|
||||||
val last7DaysWater: StateFlow<List<WaterRecord>> =
|
|
||||||
repository.getLast7DaysWater()
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
|
||||||
|
|
||||||
fun addWater(amount: Int) {
|
fun addWater(amount: Int) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.addWaterRecord(DateUtils.getCurrentDate(), amount)
|
repository.insertWater(DateUtils.getCurrentDate(), amount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteWaterRecord(record: WaterRecord) {
|
fun addWaterFromContainer(container: WaterContainer) {
|
||||||
|
addWater(container.amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteWater(record: WaterRecord) {
|
||||||
viewModelScope.launch {
|
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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
BIN
app/src/main/res/drawable-hdpi/ic_water_notification.png
Normal file
|
After Width: | Height: | Size: 472 B |
BIN
app/src/main/res/drawable-mdpi/ic_water_notification.png
Normal file
|
After Width: | Height: | Size: 339 B |
BIN
app/src/main/res/drawable-xhdpi/ic_water_notification.png
Normal file
|
After Width: | Height: | Size: 655 B |
BIN
app/src/main/res/drawable-xxhdpi/ic_water_notification.png
Normal file
|
After Width: | Height: | Size: 1023 B |
BIN
app/src/main/res/drawable-xxxhdpi/ic_water_notification.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/drawable/icon_apps_steps_drink.jpeg
Normal file
|
After Width: | Height: | Size: 75 KiB |
@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 25 KiB |