HydroFlux 0.0.1
This commit is contained in:
233
Hydroflux/app/src/main/java/com/david/hydroflux/MainActivity.kt
Normal file
233
Hydroflux/app/src/main/java/com/david/hydroflux/MainActivity.kt
Normal file
@@ -0,0 +1,233 @@
|
||||
package com.david.hydroflux
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.webkit.WebChromeClient
|
||||
import android.widget.Toast // Added for Debugging
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.health.connect.client.HealthConnectClient
|
||||
import androidx.health.connect.client.permission.HealthPermission
|
||||
import androidx.health.connect.client.records.StepsRecord
|
||||
import androidx.health.connect.client.records.SleepSessionRecord
|
||||
import androidx.health.connect.client.request.ReadRecordsRequest
|
||||
import androidx.health.connect.client.request.AggregateRequest
|
||||
import androidx.health.connect.client.time.TimeRangeFilter
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
class MainActivity : FragmentActivity() {
|
||||
private lateinit var myWebView: WebView
|
||||
private lateinit var healthConnectClient: HealthConnectClient
|
||||
|
||||
// Define Permissions we need
|
||||
private val PERMISSIONS = setOf(
|
||||
HealthPermission.getReadPermission(StepsRecord::class),
|
||||
HealthPermission.getReadPermission(SleepSessionRecord::class)
|
||||
)
|
||||
|
||||
private val requestPermissions = registerForActivityResult(
|
||||
androidx.health.connect.client.PermissionController.createRequestPermissionResultContract()
|
||||
) { granted ->
|
||||
if (granted.containsAll(PERMISSIONS)) {
|
||||
// Permissions Granted - Silent Sync
|
||||
if (::healthConnectClient.isInitialized) {
|
||||
// Use a slight delay to allow permission propogation
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||
WebAppInterface(this@MainActivity).triggerSync()
|
||||
}, 500)
|
||||
}
|
||||
} else {
|
||||
val missing = PERMISSIONS.minus(granted)
|
||||
Toast.makeText(this, "Denied. Opening Settings...", Toast.LENGTH_LONG).show()
|
||||
// Fallback: Open Health Connect Settings so user can enable manually
|
||||
try {
|
||||
val intent = android.content.Intent("androidx.health.ACTION_HEALTH_CONNECT_SETTINGS")
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
// Try generic app details
|
||||
Toast.makeText(this, "Could not open Health Settings", Toast.LENGTH_SHORT).show()
|
||||
val intent = android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
intent.data = android.net.Uri.parse("package:$packageName")
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
// Setup WebView FIRST to ensure we have it
|
||||
myWebView = findViewById(R.id.webview)
|
||||
myWebView.settings.javaScriptEnabled = true
|
||||
myWebView.settings.domStorageEnabled = true
|
||||
myWebView.settings.allowFileAccess = true
|
||||
myWebView.settings.allowFileAccessFromFileURLs = true
|
||||
myWebView.settings.allowUniversalAccessFromFileURLs = true
|
||||
myWebView.webViewClient = WebViewClient()
|
||||
myWebView.webChromeClient = WebChromeClient()
|
||||
myWebView.addJavascriptInterface(WebAppInterface(this), "HydroFluxNative")
|
||||
myWebView.loadUrl("file:///android_asset/index.html")
|
||||
|
||||
// Setup Health Connect
|
||||
val availabilityStatus = HealthConnectClient.getSdkStatus(this)
|
||||
|
||||
if (availabilityStatus == HealthConnectClient.SDK_UNAVAILABLE) {
|
||||
Toast.makeText(this, "Health Connect Unavailable on this device", Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
if (availabilityStatus == HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED) {
|
||||
Toast.makeText(this, "Health Connect Update Required", Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
|
||||
// Grab client
|
||||
try {
|
||||
healthConnectClient = HealthConnectClient.getOrCreate(this)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
// Start Periodic Sleep Check (6am - 10am)
|
||||
startPeriodicCheck()
|
||||
}
|
||||
|
||||
private fun startPeriodicCheck() {
|
||||
val handler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
val runnable = object : Runnable {
|
||||
override fun run() {
|
||||
val cal = java.util.Calendar.getInstance()
|
||||
val hour = cal.get(java.util.Calendar.HOUR_OF_DAY)
|
||||
|
||||
// Check between 6:00 and 10:00
|
||||
if (hour in 6..10) {
|
||||
WebAppInterface(this@MainActivity).triggerSync()
|
||||
}
|
||||
|
||||
// Repeat every 30 minutes (30 * 60 * 1000)
|
||||
handler.postDelayed(this, 1800000)
|
||||
}
|
||||
}
|
||||
handler.post(runnable) // Start immediately
|
||||
}
|
||||
|
||||
inner class WebAppInterface(private val activity: MainActivity) {
|
||||
@JavascriptInterface
|
||||
fun requestHealthPermissions() {
|
||||
runOnUiThread {
|
||||
if (!::healthConnectClient.isInitialized) {
|
||||
Toast.makeText(activity, "Health Client Not Init", Toast.LENGTH_SHORT).show()
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
activity.lifecycleScope.launch {
|
||||
try {
|
||||
val granted = healthConnectClient.permissionController.getGrantedPermissions()
|
||||
|
||||
if (granted.containsAll(PERMISSIONS)) {
|
||||
triggerSync()
|
||||
} else {
|
||||
try {
|
||||
requestPermissions.launch(PERMISSIONS)
|
||||
} catch (e: Exception) {
|
||||
val err = e.message ?: "Unknown"
|
||||
Toast.makeText(activity, "Launch Error: $err", Toast.LENGTH_LONG).show()
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun triggerSync() {
|
||||
if (!::healthConnectClient.isInitialized) return
|
||||
|
||||
activity.lifecycleScope.launch {
|
||||
// MOVE TO IO THREAD for stability (Prevents IllegalStateException)
|
||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
|
||||
// Use Application Context for Client
|
||||
try {
|
||||
if (!::healthConnectClient.isInitialized) {
|
||||
healthConnectClient = HealthConnectClient.getOrCreate(activity.applicationContext)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
val startOfDay = ZonedDateTime.now().truncatedTo(ChronoUnit.DAYS).toInstant()
|
||||
val now = Instant.now()
|
||||
|
||||
var totalSteps = 0L
|
||||
var sleepHours = 0.0
|
||||
|
||||
// 1. Try Read Steps (AGGREGATE)
|
||||
try {
|
||||
val response = healthConnectClient.aggregate(
|
||||
AggregateRequest(
|
||||
metrics = setOf(StepsRecord.COUNT_TOTAL),
|
||||
timeRangeFilter = TimeRangeFilter.between(startOfDay, now)
|
||||
)
|
||||
)
|
||||
// The result may be null if no data
|
||||
totalSteps = response[StepsRecord.COUNT_TOTAL] ?: 0
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
val msg = e.message ?: "Unknown Error"
|
||||
runOnUiThread { Toast.makeText(activity, "Steps Error: $msg", Toast.LENGTH_LONG).show() }
|
||||
totalSteps = 0
|
||||
}
|
||||
|
||||
// 2. Try Read Sleep (AGGREGATE)
|
||||
try {
|
||||
val sleepResponse = healthConnectClient.aggregate(
|
||||
AggregateRequest(
|
||||
metrics = setOf(SleepSessionRecord.SLEEP_DURATION_TOTAL),
|
||||
timeRangeFilter = TimeRangeFilter.between(now.minus(24, ChronoUnit.HOURS), now)
|
||||
)
|
||||
)
|
||||
// Get total duration in Seconds (Duration -> Seconds)
|
||||
val duration = sleepResponse[SleepSessionRecord.SLEEP_DURATION_TOTAL]
|
||||
if (duration != null) {
|
||||
sleepHours = duration.seconds / 3600.0
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
sleepHours = 0.0
|
||||
}
|
||||
|
||||
runOnUiThread {
|
||||
if (!activity.isFinishing && !activity.isDestroyed) {
|
||||
try {
|
||||
myWebView.evaluateJavascript("window.updateHealthData($totalSteps, $sleepHours)", null)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onBackPressed() {
|
||||
if (myWebView.canGoBack()) {
|
||||
myWebView.goBack()
|
||||
} else {
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.david.hydroflux
|
||||
|
||||
import android.os.Bundle
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Button
|
||||
import android.view.Gravity
|
||||
import android.graphics.Color
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
|
||||
class PermissionsRationaleActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val title = TextView(this).apply {
|
||||
text = "Health Permission Needed"
|
||||
textSize = 24f
|
||||
setTextColor(Color.CYAN)
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(0, 0, 0, 30)
|
||||
}
|
||||
|
||||
val message = TextView(this).apply {
|
||||
text = "HydroFit needs access to your Steps and Sleep data to populate the activity rings and history charts.\n\nWe do not share this data."
|
||||
textSize = 16f
|
||||
setTextColor(Color.WHITE)
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(0, 0, 0, 50)
|
||||
}
|
||||
|
||||
val btn = Button(this).apply {
|
||||
text = "UNDERSTOOD"
|
||||
setBackgroundColor(Color.DKGRAY)
|
||||
setTextColor(Color.WHITE)
|
||||
setOnClickListener {
|
||||
finish() // Close rationale and return to system dialog logic
|
||||
}
|
||||
}
|
||||
|
||||
val layout = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(50, 50, 50, 50)
|
||||
setBackgroundColor(Color.BLACK)
|
||||
addView(title)
|
||||
addView(message)
|
||||
addView(btn)
|
||||
}
|
||||
|
||||
setContentView(layout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.david.hydroflux.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.david.hydroflux.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun HydrofluxTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.david.hydroflux.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
Reference in New Issue
Block a user