Compare commits
No commits in common. "275a2bdcb6c5b0dedd504187f0e45be2daeebab7" and "a226686431a44a8b74d624c076f67cac97015ca6" have entirely different histories.
275a2bdcb6
...
a226686431
254
README.md
@ -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:
|
||||||
|
|
||||||
[](https://kotlinlang.org)
|
Melacak langkah harian menggunakan sensor step counter bawaan smartphone
|
||||||
[](https://developer.android.com/jetpack/compose)
|
Mencatat asupan air minum untuk memenuhi kebutuhan hidrasi harian
|
||||||
[](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
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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()
|
|
||||||
}
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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 )
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
|||||||
@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,408 +36,160 @@ 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 ->
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
LazyColumn(
|
||||||
LazyColumn(
|
modifier = Modifier
|
||||||
modifier = Modifier
|
.fillMaxSize()
|
||||||
.fillMaxSize()
|
.padding(paddingValues)
|
||||||
.padding(paddingValues)
|
.padding(16.dp),
|
||||||
.padding(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
) {
|
||||||
) {
|
item {
|
||||||
// Main Progress Card
|
// Water Progress Card
|
||||||
item {
|
Card(
|
||||||
Card(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
colors = CardDefaults.cardColors(
|
||||||
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
|
containerColor = MaterialTheme.colorScheme.tertiaryContainer
|
||||||
elevation = CardDefaults.cardElevation(4.dp)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
}
|
) {
|
||||||
|
Column(
|
||||||
item {
|
modifier = Modifier
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
.fillMaxWidth()
|
||||||
// Row 1
|
.padding(24.dp),
|
||||||
Row(
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
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(
|
Icon(
|
||||||
modifier = Modifier.padding(16.dp),
|
imageVector = Icons.Default.WaterDrop,
|
||||||
horizontalArrangement = Arrangement.Center,
|
contentDescription = null,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
modifier = Modifier.size(64.dp),
|
||||||
) {
|
tint = MaterialTheme.colorScheme.tertiary
|
||||||
Icon(Icons.Default.Settings, contentDescription = null)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
"Input Jumlah Custom",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// History
|
|
||||||
item {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
"Riwayat Hari Ini",
|
text = "${totalWater}ml",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.displayLarge,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
Text(
|
|
||||||
"${todayRecords.size} kali minum",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (todayRecords.isEmpty()) {
|
Text(
|
||||||
item {
|
text = "Air Minum Hari Ini",
|
||||||
Card(
|
style = MaterialTheme.typography.titleMedium
|
||||||
modifier = Modifier.fillMaxWidth(),
|
)
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
)
|
|
||||||
) {
|
LinearProgressIndicator(
|
||||||
Column(
|
progress = { totalWater.toFloat() / waterGoal.toFloat() },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(32.dp),
|
.height(8.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
)
|
||||||
) {
|
|
||||||
Text("💧", style = MaterialTheme.typography.displayMedium)
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
Text(
|
||||||
"Belum ada riwayat hari ini",
|
text = "Target: ${waterGoal}ml",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyMedium
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Yuk mulai minum air!",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
items(todayRecords.sortedByDescending { it.timestamp }) { record ->
|
|
||||||
WaterRecordItem(
|
|
||||||
record = record,
|
|
||||||
container = viewModel.getContainerByAmount(record.amount),
|
|
||||||
onDelete = { viewModel.deleteWater(record) }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success animation
|
item {
|
||||||
AnimatedVisibility(
|
// Quick Add Buttons
|
||||||
visible = showSuccessAnimation,
|
Row(
|
||||||
enter = scaleIn(initialScale = 0.3f) + fadeIn(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
exit = scaleOut(targetScale = 1.5f) + fadeOut(),
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
modifier = Modifier.align(Alignment.Center)
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
shape = CircleShape,
|
|
||||||
color = Color.White,
|
|
||||||
shadowElevation = 8.dp,
|
|
||||||
modifier = Modifier.size(100.dp)
|
|
||||||
) {
|
) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
QuickAddButton(
|
||||||
Text("💧", style = MaterialTheme.typography.displayLarge)
|
amount = 250,
|
||||||
}
|
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 (showCustomDialog) {
|
if (showDialog) {
|
||||||
CustomAmountDialog(
|
AddWaterDialog(
|
||||||
onDismiss = { showCustomDialog = false },
|
onDismiss = { showDialog = 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(
|
Text(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
text = time,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
) {
|
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(
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 472 B |
|
Before Width: | Height: | Size: 339 B |
|
Before Width: | Height: | Size: 655 B |
|
Before Width: | Height: | Size: 1023 B |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 75 KiB |
@ -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>
|
||||||
@ -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>
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 982 B |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 7.6 KiB |