diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8468bbf..329d86d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,8 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) id("com.google.devtools.ksp") + id("com.google.dagger.hilt.android") + kotlin("plugin.serialization").version("2.2.21") } android { @@ -27,11 +29,12 @@ android { "proguard-rules.pro" ) - buildConfigField("String", "BASE_API_URL", "\"https://videochat-signaling-app.ue.r.appspot.com\"") + buildConfigField("String", "BASE_API_URL", "\"https://meechie.techkit.xyz\"") buildConfigField("String", "BASE_WS_API_URL", "\"wss://videochat-signaling-app.ue.r.appspot.com\"") - buildConfigField("int", "PORT", "443") + buildConfigField("int", "PORT", "444") buildConfigField("boolean", "SECURE", "true") // yes use HTTPS - buildConfigField("String", "API_POST_URL","\"https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/post\"") + buildConfigField("String", "API_POST_URL","\"https://meechie.techkit.xyz:444/key=peerjs/post\"") + buildConfigField("String", "API_GET_ID_URL", "\"https://meechie.techkit.xyz:444/key=peerjs/id\"") buildConfigField("String", "API_GET_PEERS_URL","\"https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/peers\"") signingConfig = signingConfigs.getByName("debug") } @@ -43,11 +46,12 @@ android { "proguard-rules.pro" ) - buildConfigField("String", "BASE_API_URL", "\"https://videochat-signaling-app.ue.r.appspot.com\"") + buildConfigField("String", "BASE_API_URL", "\"https://meechie.techkit.xyz\"") buildConfigField("String", "BASE_WS_API_URL", "\"wss://videochat-signaling-app.ue.r.appspot.com\"") - buildConfigField("int", "PORT", "443") + buildConfigField("int", "PORT", "444") buildConfigField("boolean", "SECURE", "true") // yes use HTTPS - buildConfigField("String", "API_POST_URL","\"https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/post\"") + buildConfigField("String", "API_POST_URL","\"https://meechie.techkit.xyz:444/key=peerjs/post\"") + buildConfigField("String", "API_GET_ID_URL", "\"https://meechie.techkit.xyz:444/key=peerjs/id\"") buildConfigField("String", "API_GET_PEERS_URL","\"https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/peers\"") signingConfig = signingConfigs.getByName("debug") } @@ -62,14 +66,18 @@ android { applicationIdSuffix = ".local" // e.g., com.example.myapp.local versionNameSuffix = "-local" - // Override BASE_API_URL to point to your local development server +// Override BASE_API_URL to point to your local development server // 10.0.2.2 is the special IP for your host machine's loopback on Android Emulator - buildConfigField("String", "BASE_API_URL", "\"localhost\"") - buildConfigField("String", "BASE_WS_API_URL", "\"ws://localhost:9000\"") - buildConfigField("int", "PORT", "9000") - buildConfigField("boolean", "SECURE", "false") // no use HTTPS - buildConfigField("String", "API_POST_URL","\"https://localhost:9000/key=peerjs/post\"") - buildConfigField("String", "API_GET_PEERS_URL","\"http://localhost:9000/key=peerjs/peers\"") + buildConfigField("String", "BASE_API_URL", "\"https://10.0.2.2\"") // Use http for local unless you have SSL set up for this IP + buildConfigField("String", "BASE_WS_API_URL", "\"ws://10.0.2.2:3016\"") + buildConfigField("int", "PORT", "3016") + buildConfigField("boolean", "SECURE", "false") // It's common to use http (not https) for local IP access + + // --- THIS IS THE FIX --- + // Replace 127.0.0.1 with 10.0.2.2 + buildConfigField("String", "API_POST_URL", "\"http://10.0.2.2:3016/key=peerjs/post\"") + buildConfigField("String", "API_GET_PEERS_URL", "\"http://10.0.2.2:3016/key=peerjs/peers\"") + // ------------------------ // If using a physical device on your local network, replace with your actual local IP: // buildConfigField("String", "BASE_API_URL", "\"http://192.168.1.XX:8080\"") @@ -100,9 +108,15 @@ android { } dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("com.google.dagger:hilt-android:2.57.1") + implementation(libs.androidx.runtime) + ksp("com.google.dagger:hilt-android-compiler:2.57.2") + implementation ("com.github.0-u-0:mediasoup-android-sdk:0.0.1") + implementation("com.github.0-u-0:dugon-webrtc-android:100.0.2") + implementation("io.socket:socket.io-client:2.1.1") implementation ("com.github.franmontiel:PersistentCookieJar:v1.0.1") implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.14") - implementation("io.github.webrtc-sdk:android:125.6422.06.1") implementation("androidx.compose.material:material-icons-extended") implementation("androidx.room:room-runtime:2.5.0") implementation(libs.androidx.camera.core) diff --git a/app/src/debug/res/AndroidManifest.xml b/app/src/debug/res/AndroidManifest.xml new file mode 100644 index 0000000..6320ce2 --- /dev/null +++ b/app/src/debug/res/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c925b25..a2873a7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,9 @@ - + - + diff --git a/app/src/main/java/com/example/.DS_Store b/app/src/main/java/com/example/.DS_Store index 5242d05..fb6c37c 100644 Binary files a/app/src/main/java/com/example/.DS_Store and b/app/src/main/java/com/example/.DS_Store differ diff --git a/app/src/main/java/com/example/neurology_project_android/AppModule.kt b/app/src/main/java/com/example/neurology_project_android/AppModule.kt new file mode 100644 index 0000000..bf3f52b --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/AppModule.kt @@ -0,0 +1,39 @@ +package com.example.neurology_project_android + +// In: app/src/main/java/com/example/neurology_project_android/di/AppModule.kt +// (It's good practice to put modules in a 'di' package) + +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import com.example.neurology_project_android.SessionManager +import com.example.neurology_project_android.SignalingRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) // These dependencies will live as long as the app +object AppModule { + + @Provides + @Singleton // Use @Singleton to ensure only one instance is ever created + fun provideOkHttpClient(@ApplicationContext context: Context): OkHttpClient { + // Hilt provides the application context for us + return SessionManager(context).client + } + + + + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + @Provides // <-- ADD THIS: Tells Hilt this function PROVIDES a dependency. + @Singleton + fun provideSignalingClient(@ApplicationContext context: Context): SignalingClient { + return SignalingClient(context) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/CallScreenActivity.kt b/app/src/main/java/com/example/neurology_project_android/CallScreenActivity.kt index be935f6..1d28dd2 100644 --- a/app/src/main/java/com/example/neurology_project_android/CallScreenActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/CallScreenActivity.kt @@ -1,9 +1,11 @@ package com.example.neurology_project_android +import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.* @@ -15,6 +17,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.res.painterResource import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp class CallScreenActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -28,7 +33,7 @@ class CallScreenActivity : ComponentActivity() { @Composable fun CallScreen() { var isMuted by remember { mutableStateOf(false) } - + val context = LocalContext.current Box( modifier = Modifier .fillMaxSize() @@ -49,46 +54,188 @@ fun CallScreen() { .padding(16.dp) .padding(bottom = 20.dp), horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically + ) { - IconButton( - onClick = { /* Open Stroke Scale Form */ }, - modifier = Modifier - .size(64.dp) - .background(Color.Gray, shape = CircleShape) + Column( + // Center the items horizontally within the column + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f).background(Color.DarkGray).clickable(onClick = { + val intent = Intent( + context, + ListNIHFormActivity::class.java + ) + + context.startActivity(intent) + /* Open Stroke Scale Form */ + }) + ) { - Icon( - painter = painterResource(id = android.R.drawable.ic_menu_edit), - contentDescription = null, - tint = Color.White + IconButton( + onClick = { + val intent = Intent( + context, + ListNIHFormActivity::class.java + ) + // 3. Start the new Activity + context.startActivity(intent) + /* Open Stroke Scale Form */ }, + modifier = Modifier + .size(64.dp) + .background(Color.DarkGray) + ) { + Icon( + painter = painterResource(R.drawable.assignment_add_24px), + contentDescription = null, + tint = Color.White + ) + } + // Add the Text composable right below the IconButton + Spacer(modifier = Modifier.height(4.dp)) // Optional: Add a small vertical space + Text( + text = "New Form/View Form", + modifier = Modifier.fillMaxWidth(), + + + fontSize = 16.sp, + + + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, + + + color = Color.White, + + textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis ) } - IconButton( - onClick = { isMuted = !isMuted }, - modifier = Modifier - .size(64.dp) - .background(Color.DarkGray, shape = CircleShape) + Column( + // Center the items horizontally within the column + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f).background(Color.Gray).clickable(onClick = { + + }) + ) { - Icon( - painter = painterResource(id = if (isMuted) android.R.drawable.ic_lock_silent_mode else android.R.drawable.ic_lock_silent_mode_off), + IconButton( + onClick = { + if(isMuted){ + isMuted = false + } + else{ + isMuted = true + } + + }, + modifier = Modifier + .size(64.dp) + .background(Color.Gray) + ) { + Icon( + painter = painterResource(id = if (isMuted) R.drawable.mic_off_24px else R.drawable.mic_24px), contentDescription = null, tint = Color.White ) + } + // Add the Text composable right below the IconButton + Spacer(modifier = Modifier.height(4.dp)) // Optional: Add a small vertical space + Text( + text = "Mute/Unmute", + modifier = Modifier.fillMaxWidth(), + + + fontSize = 16.sp, + + + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, + + + color = Color.White, + + textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) } - IconButton( - onClick = { /* End Call */ }, - modifier = Modifier - .size(64.dp) - .background(Color.Red, shape = CircleShape) + Column( + // Center the items horizontally within the column + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f).background(Color.Red).clickable(onClick = { + val intent = Intent(context, NewNIHFormActivity::class.java).apply { + putExtra(NewNIHFormActivity.EXTRA_IN_CALL, true) + } + context.startActivity(intent) + + /* Open Stroke Scale Form */ + }) + ) { - Icon( - painter = painterResource(id = android.R.drawable.ic_menu_close_clear_cancel), + IconButton( + onClick = { + val intent = Intent(context, NewNIHFormActivity::class.java).apply { + putExtra(NewNIHFormActivity.EXTRA_IN_CALL, true) + } + context.startActivity(intent) + + /* Open Stroke Scale Form */ }, + modifier = Modifier + .size(64.dp) + .background(Color.Red) + ) { + Icon( + painter = painterResource(R.drawable.call_end_24px), contentDescription = null, tint = Color.White ) + } + // Add the Text composable right below the IconButton + Spacer(modifier = Modifier.height(4.dp)) // Optional: Add a small vertical space + Text( + text = "End Call", + modifier = Modifier.fillMaxWidth(), + + + fontSize = 16.sp, + + + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, + + + color = Color.White, + + textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) } + +// IconButton( +// onClick = { isMuted = !isMuted }, +// modifier = Modifier +// .size(64.dp) +// .background(Color.DarkGray, shape = CircleShape) +// ) { +// Icon( +// painter = painterResource(id = if (isMuted) R.drawable.mic_off_24px else R.drawable.mic_24px), +// contentDescription = null, +// tint = Color.White +// ) +// } +// +// IconButton( +// onClick = { /* End Call */ }, +// modifier = Modifier +// .size(64.dp) +// .background(Color.Red, shape = CircleShape) +// ) { +// Icon( +// painter = painterResource(R.drawable.call_end_24px), +// contentDescription = null, +// tint = Color.White +// ) +// } } } } diff --git a/app/src/main/java/com/example/neurology_project_android/FormManager.kt b/app/src/main/java/com/example/neurology_project_android/FormManager.kt index a392576..4d24e18 100644 --- a/app/src/main/java/com/example/neurology_project_android/FormManager.kt +++ b/app/src/main/java/com/example/neurology_project_android/FormManager.kt @@ -1,56 +1,98 @@ package com.example.neurology_project_android import android.util.Log -import kotlinx.coroutines.CoroutineScope +import com.google.gson.Gson import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json import okhttp3.* import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.Request import org.json.JSONArray import org.json.JSONObject import java.io.IOException +import java.util.UUID object FormManager { + private const val POST_URL = "https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/post" + var TAG = "FormManager" fun submitFormToServer(form: NIHForm, client: OkHttpClient, onResult: (Boolean) -> Unit) { - val json = JSONObject().apply { - put("patientName", form.patientName) - put("patientDob", form.dob) - put("formDate", form.date) - put("results", form.formData) - put("username", form.username) - } - + val jsonString = Gson().toJson(form) + Log.d(TAG, "values are " + form.toString()) val requestBody = RequestBody.create( "application/json; charset=utf-8".toMediaTypeOrNull(), - json.toString() + jsonString ) val request = Request.Builder() - .url("https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/post") + .url(POST_URL) .post(requestBody) .addHeader("Content-Type", "application/json") - .addHeader("Action", "submitStrokeScale") + .addHeader("Action", "start_new_nihss_form") .build() client.newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { + Log.e(TAG, e.toString()) onResult(false) } override fun onResponse(call: Call, response: Response) { + Log.d(TAG, response.toString()) onResult(response.isSuccessful) } }) } + suspend fun loadForm(formId: String, client: OkHttpClient): NIHForm = withContext(Dispatchers.IO) { + Log.d(TAG, "formid is ${formId}") + + val json = JSONObject().apply { + put("form_id", formId) + } + + val requestBody = RequestBody.create( + "application/json; charset=utf-8".toMediaTypeOrNull(), + json.toString()) + val request = Request.Builder() + .url(POST_URL) + .post(requestBody) + .addHeader("Content-Type", "application/json") + .addHeader("Action", "requestSingleForm") + .build() + + try { + // 🔑 Fix 2: Keep the synchronous execute() call, but it's now safe + // because it's wrapped in withContext(Dispatchers.IO). + try { + val response = client.newCall(request).execute() + if (response.isSuccessful) { + + val bodyString = response.body?.string().toString() + Log.d(TAG, "forms reponse ${bodyString}") + val formsList = decodeSingleForm(bodyString!!) + return@withContext formsList + } else { + Log.e("FORM_MANAGER", "Server error: ${response.code}") + } + } catch (e: Exception) { + Log.e("FORM_MANAGER", "Error opening form: ${e.message}") + } + } catch (e: Exception) { + Log.e("FORM_MANAGER", "Error opening form: ${e.message}") + } + + // Return null instead of an empty NIHForm() on failure + return@withContext NIHForm() + } + suspend fun fetchFormsForUser( username: String, client: OkHttpClient ): List = withContext(Dispatchers.IO) { val forms = mutableListOf() - + Log.d(TAG, "FETCHING FORMS FOR USER") val json = JSONObject().apply { put("username", username) } @@ -61,7 +103,7 @@ object FormManager { ) val request = Request.Builder() - .url("https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/post") + .url(POST_URL) .post(requestBody) .addHeader("Content-Type", "application/json") .addHeader("Action", "getUsersForms") @@ -70,33 +112,22 @@ object FormManager { try { val response = client.newCall(request).execute() if (response.isSuccessful) { + val bodyString = response.body?.string() - val jsonArray = JSONArray(bodyString) - - for (i in 0 until jsonArray.length()) { - val item = jsonArray.getJSONObject(i) - forms.add( - NIHForm( - id = item.getInt("id"), - patientName = item.getString("patient_name"), - dob = item.getString("patient_dob"), - date = item.getString("form_date"), - formData = item.getString("results"), - username = item.getString("username") - ) - ) - } + Log.d(TAG, "forms reponse ${bodyString}") + val formsList = decodeMultipleForms(bodyString!!) + return@withContext formsList } else { Log.e("FORM_MANAGER", "Server error: ${response.code}") } - } catch (e: IOException) { - Log.e("FORM_MANAGER", "Network error: ${e.message}") + } catch (e: Exception) { + Log.e("FORM_MANAGER", "Error opening form: ${e.message}") } return@withContext forms } - fun deleteForm(formId: Int, username: String, client: OkHttpClient, callback: (Boolean) -> Unit) { + fun deleteForm(formId: UUID, username: String, client: OkHttpClient, callback: (Boolean) -> Unit) { val json = JSONObject().apply { put("id", formId) put("username", username) @@ -108,7 +139,7 @@ object FormManager { ) val request = Request.Builder() - .url("https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/post") + .url(POST_URL) .post(requestBody) .addHeader("Content-Type", "application/json") .addHeader("Action", "deleteForm") @@ -129,12 +160,12 @@ object FormManager { fun updateForm(form: NIHForm, client: OkHttpClient, onComplete: (Boolean) -> Unit) { val json = JSONObject().apply { - put("id", form.id) - put("patientName", form.patientName) - put("patientDob", form.dob) - put("formDate", form.date) - put("results", form.formData) - put("username", form.username) +// put("id", form.id) +// put("patientName", form.patientName) +// put("patientDob", form.dob) +// put("formDate", form.date) +// put("results", form.formData) +// put("username", form.username) } val requestBody = RequestBody.create( @@ -143,7 +174,7 @@ object FormManager { ) val request = Request.Builder() - .url("https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/post") + .url(POST_URL) .post(requestBody) .addHeader("Content-Type", "application/json") .addHeader("Action", "updateForm") @@ -168,4 +199,50 @@ object FormManager { } } } + + fun decodeMultipleForms(jsonString: String): List { + val jsonParser = Json { + // Essential for robustness: ignore fields present in JSON but not in your data class + ignoreUnknownKeys = true + // Handles cases where null is present for a property with a default value + coerceInputValues = true + } + Log.e(TAG, "Decoding forms") + return try { + // The key change: specify List as the target type + val formList: List = jsonParser.decodeFromString(jsonString) + formList + } catch (e: Exception) { + // Log the error and return an empty list or throw a custom exception + println("Error decoding list of forms: $e") + emptyList() + } + } + + fun decodeSingleForm(jsonString: String): NIHForm { + val jsonParser = Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + + Log.e(TAG, "Attempting to decode single form from array.") + + return try { + // 1. Decode as a List because the JSON starts with '[' + val formList: List = jsonParser.decodeFromString(jsonString) + + // 2. Safely return the first element of the list + if (formList.isNotEmpty()) { + formList.first() + } else { + Log.e(TAG, "Successfully decoded array, but the list was empty.") + NIHForm() // Return a default empty form if the list is empty + } + + } catch (e: Exception) { + // This handles exceptions like malformed JSON or other parsing errors + Log.e(TAG, "Error decoding single form: ${e.message}") + NIHForm() // Return a default empty form on failure + } + } } diff --git a/app/src/main/java/com/example/neurology_project_android/ListNIHFormActivity.kt b/app/src/main/java/com/example/neurology_project_android/ListNIHFormActivity.kt index 23bfa1d..aa7d4bd 100644 --- a/app/src/main/java/com/example/neurology_project_android/ListNIHFormActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/ListNIHFormActivity.kt @@ -87,16 +87,20 @@ fun ListNIHFormScreen(refreshTrigger: Int) { ) { items(savedForms) { form -> SavedFormItem( - form = SavedForm(form.patientName, form.date), + form = SavedForm( + patientName = form.patientName?.takeIf { it.isNotBlank() } ?: "Unnamed Patient", + dateRecorded = form.formDate ?: "No Date" // Also handle null date for safety + ), onClick = { - val intent = Intent(context, SavedNIHFormActivity::class.java).apply { - putExtra("formId", form.id) - putExtra("patientName", form.patientName) - putExtra("dob", form.dob) - putExtra("date", form.date) - putExtra("formData", form.formData) - putExtra("username", form.username) + val intent = Intent(context, NewSavedNIHFormActivity::class.java).apply { + putExtra("form", form.form_id) +// putExtra("patientName", form.patientName) +// putExtra("dob", form.dob) +// putExtra("date", form.date) +// putExtra("formData", form.formData) +// putExtra("username", form.username) } + context.startActivity(intent) } ) diff --git a/app/src/main/java/com/example/neurology_project_android/LoginActivity.kt b/app/src/main/java/com/example/neurology_project_android/LoginActivity.kt index 8997ad5..e6fc8d6 100644 --- a/app/src/main/java/com/example/neurology_project_android/LoginActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/LoginActivity.kt @@ -150,8 +150,11 @@ fun LoginScreen(sessionManager: SessionManager, onLoginSuccess: () -> Unit) { override fun onResponse(call: Call, response: Response) { val success = response.isSuccessful val bodyString = response.body?.string()?.trim() ?: "" - var authToken = response.headers.value(9)?.substringAfter("authorization=")?.substringBefore(";") ?: "" + var authToken = response.headers.get("Set-Cookie") + ?.substringBefore(";") ?: "" + Log.d("LOGINACTIVITY:", "RESPONSE HEADERS: " + response.headers.get("Set-Cookie")) + Log.d("LOGINACTIVITY:", "AUTH TOKEN: " + authToken) if (success) { sessionManager.saveAuthToken(authToken, username) (context as ComponentActivity).runOnUiThread { diff --git a/app/src/main/java/com/example/neurology_project_android/MainActivity.kt b/app/src/main/java/com/example/neurology_project_android/MainActivity.kt index f6f8d2c..565061e 100644 --- a/app/src/main/java/com/example/neurology_project_android/MainActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/MainActivity.kt @@ -4,39 +4,49 @@ package com.example.neurology_project_android import androidx.compose.ui.platform.LocalContext import android.Manifest +import android.R import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Intent +import com.example.neurology_project_android.BuildConfig.API_GET_ID_URL import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels import androidx.annotation.OptIn import androidx.annotation.RequiresApi import androidx.camera.core.imagecapture.CameraRequest +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -45,41 +55,40 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.media3.common.util.Log import androidx.media3.common.util.UnstableApi import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import com.example.neurology_project_android.BuildConfig.BASE_WS_API_URL -import com.example.neurology_project_android.BuildConfig.PORT +import com.example.neurology_project_android.BuildConfig.API_GET_PEERS_URL import com.example.neurology_project_android.ui.theme.NeurologyProjectAndroidTheme -import okhttp3.Call -import okhttp3.Callback +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.Response -import okio.IOException -import org.webrtc.CapturerObserver -import org.webrtc.VideoProcessor -import org.webrtc.VideoSource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext + +@AndroidEntryPoint class MainActivity : ComponentActivity() { @SuppressLint("RestrictedApi") private lateinit var cameraRequest: CameraRequest - private lateinit var videoProcessor: VideoProcessor - private lateinit var videoSource: VideoSource - private lateinit var capturerObserver: CapturerObserver +// private lateinit var videoProcessor: VideoProcessor +// private lateinit var videoSource: VideoSource +// private lateinit var capturerObserver: CapturerObserver private var isInCall by mutableStateOf(false) private var cameraInitialized by mutableStateOf(false) private lateinit var signalingClient: SignalingClient + private lateinit var signalingRepository: SignalingRepository + private val viewModel: MainViewModel by viewModels() + @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @OptIn(UnstableApi::class) override fun onCreate(savedInstanceState: Bundle?) { @@ -100,66 +109,37 @@ class MainActivity : ComponentActivity() { ), 1 ) - + enableEdgeToEdge() setContent { NeurologyProjectAndroidTheme { - val userIdState = remember { mutableStateOf(null) } - val peersState = remember { mutableStateOf>(emptyList()) } - - // Fetch user ID once - LaunchedEffect(Unit) { - val fetchedId = fetchUserId() - userIdState.value = fetchedId - - // Now safe to start SignalingClient - signalingClient = SignalingClient( - "$BASE_WS_API_URL:$PORT/peerjs?id=$fetchedId&token=6789&key=peerjs", - this@MainActivity, - fetchedId, - onCallRecieved = { isInCall = true }, - onCallEnded = { runOnUiThread { isInCall = false } } - ) - - // Fetch peers - GetPeers { peers -> - runOnUiThread { - peersState.value = peers.filter { it != fetchedId } + viewModel.connectSignalingClient() + val uiState by viewModel.uiState.collectAsState() + // 3. OBSERVE state from the ViewModel + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + when (val state = uiState) { + is MainUiState.Loading -> { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + CircularProgressIndicator() + } } - } - } - - val userId = userIdState.value - - if (userId == null) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } else { - Scaffold( - modifier = Modifier.fillMaxSize(), - containerColor = Color.Transparent, - content = { innerPadding -> - HomeScreen( - modifier = Modifier.padding(innerPadding), - peerId = userId, - peers = peersState.value - ) - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding), - signalingClient = signalingClient, - cameraInitialized = cameraInitialized, - cameraRequest = { cameraRequest }, - isInCall = isInCall + is MainUiState.Error -> { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Text("Error: ${state.message}") + } + } + is MainUiState.Success -> { + // Pass the state down to your UI + MyApp( + userId = state.userId, + peers = state.peers, + innerPadding = innerPadding, + onJoinRoom = { targetId -> viewModel.joinRoom(targetId) } ) } - ) + } } } } @@ -167,7 +147,21 @@ class MainActivity : ComponentActivity() { @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable - fun OnlineNowSection(peers: List) { + fun OnlineNowSection(peers: List, onNavigateToOnlineScreen: () -> Unit) { + + +// LaunchedEffect(isInCall) { +// if (isInCall) { +// navController.navigate("callScreen") +// } else { +// navController.navigate("home") // Navigate back when call ends +// } +// } + + + + + Text( text = "Online Now:", fontWeight = FontWeight.Bold, @@ -179,7 +173,7 @@ class MainActivity : ComponentActivity() { Text(text = "No peers online", modifier = Modifier.padding(16.dp)) } else { peers.forEach { userId -> - OnlineUserCard(userId) + OnlineUserCard(userId, onNavigateToOnlineScreen) } } } @@ -187,7 +181,8 @@ class MainActivity : ComponentActivity() { @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable - fun OnlineUserCard(userId: String) { + fun OnlineUserCard(userId: String, onNavigateToOnlineScreen: () -> Unit) { + var navController = rememberNavController() Card( modifier = Modifier .fillMaxWidth() @@ -209,7 +204,11 @@ class MainActivity : ComponentActivity() { .padding(end = 16.dp) ) Button( - onClick = { signalingClient.startCall(userId) }, + onClick = { + + signalingClient.joinRoom(userId) + onNavigateToOnlineScreen() + }, //signalingClient.startCall(userId) modifier = Modifier.wrapContentWidth() ) { Text(text = "Call") @@ -218,15 +217,47 @@ class MainActivity : ComponentActivity() { } } + // All your other @Composable functions should be updated to accept the data and lambdas they need + // For example, MyApp now takes the userId, peers, and onJoinRoom lambda + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + @Composable + fun MyApp( + userId: String, + peers: List, + innerPadding: PaddingValues, + onJoinRoom: (String) -> Unit // FIX #1: This is a simple function type, not @Composable + ) { + val navController = rememberNavController() + NavHost(navController, startDestination = "home") { + composable("home") { + HomeScreen( + modifier = Modifier.padding(innerPadding), + userId = userId, // Pass state down + peers = peers, // Pass state down + onJoinRoom = onJoinRoom, // Pass the event handler down + onNavigateToCallScreen = { navController.navigate("callScreen") } + ) + } + composable("callScreen") { + // Assuming CallScreen is defined elsewhere + CallScreen() + } + } + } + + + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable - fun HomeScreen(modifier: Modifier = Modifier, peerId: String, peers: List) { + fun HomeScreen( + modifier: Modifier = Modifier, + userId: String, + peers: List, + onJoinRoom: (String) -> Unit, + onNavigateToCallScreen: () -> Unit + ) { val context = LocalContext.current val sessionManager = remember { SessionManager(context) } - // Refresh UI every 3 seconds - LaunchedEffect(peers) { - // This will trigger recomposition whenever peers update - } Column( modifier = modifier @@ -234,27 +265,24 @@ class MainActivity : ComponentActivity() { .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton( - onClick = { - sessionManager.logout() - val intent = Intent(context, LoginActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - context.startActivity(intent) + // Logout Button + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { + sessionManager.logout() + val intent = Intent(context, LoginActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } - ) { + context.startActivity(intent) + }) { Text("Log Out", color = MaterialTheme.colorScheme.primary) } } - Spacer(modifier = Modifier.height(8.dp)) - PeerIdSection(peerId) // Displays the correct Peer ID + // Peer ID Section + PeerIdSection(peerId = userId) + // Online Now Section (Scrollable) Column( modifier = Modifier .fillMaxWidth() @@ -262,18 +290,25 @@ class MainActivity : ComponentActivity() { .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { - OnlineNowSection(peers) // No need for additional state + OnlineNowSection( + peers = peers, + onJoinRoom = onJoinRoom, + onNavigateToCallScreen = onNavigateToCallScreen + ) } + // NIH Forms Button NIHFormsButton() } } + + } suspend fun fetchUserId(): String { return withContext(Dispatchers.IO) { try { - val idUrl = "https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/id" + val idUrl = API_GET_ID_URL val client = OkHttpClient() val request = Request.Builder().url(idUrl).build() val response = client.newCall(request).execute() @@ -290,38 +325,65 @@ suspend fun fetchUserId(): String { @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable -fun Greeting( - name: String, - modifier: Modifier = Modifier, - signalingClient: SignalingClient, - cameraInitialized: Boolean, - @SuppressLint("RestrictedApi") cameraRequest: () -> CameraRequest, - isInCall: Boolean +fun OnlineNowSection( + peers: List, + onJoinRoom: (String) -> Unit, + onNavigateToCallScreen: () -> Unit ) { - val navController = rememberNavController() - - LaunchedEffect(isInCall) { - if (isInCall) { - navController.navigate("callScreen") - } else { - navController.navigate("home") // Navigate back when call ends + Text( + text = "Online Now:", + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp) + ) + if (peers.isEmpty()) { + Text(text = "No peers online", modifier = Modifier.padding(16.dp)) + } else { + peers.forEach { peerId -> + OnlineUserCard( + userId = peerId, + onJoinClick = { + // FIX #2: Chain the events. Call ViewModel then navigate. + onJoinRoom(peerId) + onNavigateToCallScreen() + } + ) } } +} - - NavHost(navController, startDestination = "home") { - composable("home") { - // A simple loading/home screen - //Greeting() - } - composable("callScreen") { - CallScreen() +@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) +@Composable +fun OnlineUserCard(userId: String, onJoinClick: () -> Unit) { // FIX #3: Simplified parameter + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .shadow(4.dp, RoundedCornerShape(8.dp)), + shape = RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = userId, modifier = Modifier.weight(1f).padding(end = 16.dp)) + Button( + onClick = onJoinClick, // Use the simplified lambda + modifier = Modifier.wrapContentWidth() + ) { + Text(text = "Call") + } } } } + + + @Composable fun PeerIdSection(peerId: String) { Card( diff --git a/app/src/main/java/com/example/neurology_project_android/MainViewModel.kt b/app/src/main/java/com/example/neurology_project_android/MainViewModel.kt new file mode 100644 index 0000000..5d43d8b --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/MainViewModel.kt @@ -0,0 +1,102 @@ +// In: app/src/main/java/com/example/neurology_project_android/MainViewModel.kt + +package com.example.neurology_project_android + +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.neurology_project_android.BuildConfig.API_GET_ID_URL +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import javax.inject.Inject + +@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) +@HiltViewModel +class MainViewModel @Inject constructor( + private val signalingClient: SignalingClient +) : ViewModel() { + + // --- State Management --- + private val _uiState = MutableStateFlow(MainUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + // Start the process as soon as the ViewModel is created + initialize() + } + + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + private fun initialize() { + viewModelScope.launch { + _uiState.value = MainUiState.Loading + val userId = fetchUserId() + if (userId == "unknown") { + _uiState.value = MainUiState.Error("Could not fetch user ID.") + return@launch + } + + + // 1. Connect the client +// signalingClient.connectClient(userId) + + // 2. Start collecting the peer list flow + signalingClient.peerListFlow + .catch { exception -> + // Handle any errors from the flow + _uiState.value = MainUiState.Error(exception.message ?: "An error occurred") + } + .collect { peers -> + Log.e("MainViewModel", "Received peer list: $peers") + + // 3. Every time a new peer list arrives, update the UI state + // The current user is already filtered on the server in this example, + // but filtering here is a good safeguard. + val otherPeers = peers.filter { it != userId } + _uiState.value = MainUiState.Success(userId, otherPeers) + } + } + } + // Expose repository methods for the UI to call + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + fun joinRoom(targetUserId: String) { + signalingClient.joinRoom(targetUserId) + } + + // This logic is now self-contained within the ViewModel + private suspend fun fetchUserId(): String { + return withContext(Dispatchers.IO) { + try { + val idUrl = API_GET_ID_URL + val client = OkHttpClient() + val request = Request.Builder().url(idUrl).build() + val response = client.newCall(request).execute() + response.body?.string() ?: "unknown" + } catch (e: Exception) { + "unknown" + } + } + } + + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + fun connectSignalingClient() { + signalingClient.connectClient() + } +} + +// Sealed interface to represent the different UI states +sealed interface MainUiState { + object Loading : MainUiState + data class Success(val userId: String, val peers: List) : MainUiState + data class Error(val message: String) : MainUiState +} diff --git a/app/src/main/java/com/example/neurology_project_android/MyApplication.kt b/app/src/main/java/com/example/neurology_project_android/MyApplication.kt new file mode 100644 index 0000000..e92cb82 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/MyApplication.kt @@ -0,0 +1,10 @@ + +package com.example.neurology_project_android + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class MyApplication : Application() { + // The body can be empty. Hilt uses this annotation for setup. +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/NIHForm.kt b/app/src/main/java/com/example/neurology_project_android/NIHForm.kt index f3c1dea..48b7510 100644 --- a/app/src/main/java/com/example/neurology_project_android/NIHForm.kt +++ b/app/src/main/java/com/example/neurology_project_android/NIHForm.kt @@ -2,13 +2,91 @@ package com.example.neurology_project_android import androidx.room.Entity import androidx.room.PrimaryKey +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.util.UUID -@Entity(tableName = "nih_forms") + + + +@Serializable data class NIHForm( - @PrimaryKey(autoGenerate = true) val id: Int = 0, - val patientName: String, - val dob: String, - val date: String, - val formData: String, - val username: String -) \ No newline at end of file + + @SerialName(value = "form_id") + val form_id: String? = null, + + // Other string/text fields (all nullable) + @SerialName("username") + val username: String? = null, + + @SerialName("form_date") + val formDate: String? = null, // You can parse this into a Date object later + + @SerialName("patient_name") + val patientName: String? = null, + + @SerialName("patient_dob") + val patientDob: String? = null, + + @SerialName("session_id") + val sessionId: String? = null, + + // --- NIHSS Item Scores (all nullable) --- + + @SerialName("item_1a_loc_level") + val item1aLocLevel: Int? = null, + + @SerialName("item_1b_loc_commands") + val item1bLocCommands: Int? = null, + + @SerialName("item_1c_loc_best_gaze") + val item1cLocBestGaze: Int? = null, + + @SerialName("item_2_best_motor_gaze") // As per your schema + val item2BestMotorGaze: Int? = null, + + @SerialName("item_3_visual") + val item3Visual: Int? = null, + + @SerialName("item_4_facial_palsy") + val item4FacialPalsy: Int? = null, + + @SerialName("item_5_left_arm_motor") + val item5LeftArmMotor: Int? = null, + + @SerialName("item_6_right_arm_motor") + val item6RightArmMotor: Int? = null, + + @SerialName("item_7_left_leg_motor") + val item7LeftLegMotor: Int? = null, + + @SerialName("item_8_right_leg_motor") + val item8RightLegMotor: Int? = null, + + @SerialName("item_9_ataxia") + val item9Ataxia: Int? = null, + + @SerialName("item_10_sensory") + val item10Sensory: Int? = null, + + @SerialName("item_11_language") + val item11Language: Int? = null, + + @SerialName("item_12_dysarthria") + val item12Dysarthria: Int? = null, + + @SerialName("item_13_extinction_inattention") + val item13ExtinctionInattention: Int? = null, + + // --- Total Score --- + + @SerialName("total_nihss_score") + val totalNihssScore: Int? = null + + +) + + public fun NIHForm.toJson(): String { + return Json.encodeToString(this) + } \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/NIHFormModel.kt b/app/src/main/java/com/example/neurology_project_android/NIHFormModel.kt new file mode 100644 index 0000000..cbf84e8 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/NIHFormModel.kt @@ -0,0 +1,191 @@ +// In a new file: app/src/main/java/com/example/neurology_project_android/NIHFormModel.kt + +package com.example.neurology_project_android + +// Represents a single answer option for a question +data class FormOption( + val displayText: String, // The text shown in the UI, e.g., "Both arms held for 10 seconds" + val score: Int // The value stored for this option, e.g., 0 +) + +// Represents a single question in the NIH Stroke Scale +data class FormQuestion( + val id: Int, // A unique identifier, e.g., "1a" + val questionText: String, // The main question header, e.g., "1a. Level of Consciousness" + val instructionText: String, // The sub-header or instructions + val options: List // The list of possible answers for this question +) + +// This object holds the complete definition of your form, replacing the old StrokeScaleQuestions +object NIHFormModel { + val questions: List = listOf( + FormQuestion( + id = 1, + questionText = "1a. Level of Consciousness", + instructionText = "Assess the patient's response to stimulation.", + options = listOf( + FormOption("Alert; keenly responsive.", 0), + FormOption("Not alert, but arousable by minor stimulation.", 1), + FormOption("Not alert; requires repeated stimulation.", 2), + FormOption("Responds only with reflex motor or autonomic effects.", 3) + ) + ), + FormQuestion( + id = 2, + questionText = "1b. LOC Questions", + instructionText = "Ask the patient: What is the month? What is your age?", + options = listOf( + FormOption("Answers both questions correctly.", 0), + FormOption("Answers one question correctly.", 1), + FormOption("Answers neither question correctly.", 2) + ) + ), + FormQuestion( + id = 3, + questionText = "1c. LOC Commands", + instructionText = "Ask the patient to: Open and close your eyes. Grip and release your non-paretic hand.", + options = listOf( + FormOption("Performs both tasks correctly.", 0), + FormOption("Performs one task correctly.", 1), + FormOption("Performs neither task correctly.", 2) + ) + ), + FormQuestion( + id = 4, + questionText = "2. Best Gaze", + instructionText = "Test horizontal eye movements.", + options = listOf( + FormOption("Normal.", 0), + FormOption("Partial gaze palsy; gaze is abnormal in one or both eyes.", 1), + FormOption("Forced deviation, or total gaze paresis.", 2) + ) + ), + FormQuestion( + id = 5, + questionText = "3. Visual", + instructionText = "Test visual fields by confrontation (upper and lower quadrants).", + options = listOf( + FormOption("No visual loss.", 0), + FormOption("Partial hemianopia.", 1), + FormOption("Complete hemianopia.", 2), + FormOption("Bilateral hemianopia (blindness).", 3) + ) + ), + FormQuestion( + id = 6, + questionText = "4. Facial Palsy", + instructionText = "Ask patient to show teeth or raise eyebrows and close eyes.", + options = listOf( + FormOption("Normal symmetrical movements.", 0), + FormOption("Minor paralysis (flattened nasolabial fold, asymmetry on smiling).", 1), + FormOption("Partial paralysis (total or near-total paralysis of lower face).", 2), + FormOption("Complete paralysis of one or both sides (absence of facial movement in the upper and lower face).", 3) + ) + ), + FormQuestion( + id = 7, + questionText = "5. Motor Arm (Left)", + instructionText = "Extend the arm (palms down) 90 degrees (if sitting) or 45 degrees (if supine). Hold for 10 seconds.", + options = listOf( + FormOption("No drift; arm holds 90 (or 45) degrees for full 10 seconds.", 0), + FormOption("Drift; arm holds but drifts down before full 10 seconds.", 1), + FormOption("Some effort against gravity; arm cannot get to or maintain (if cued) 90 (or 45) degrees, drifts down to bed.", 2), + FormOption("No effort against gravity; arm falls.", 3), + FormOption("No movement.", 4), + FormOption("Amputation or joint fusion, explain:", 0) + ) + ), + FormQuestion( + id = 8, + questionText = "6. Motor Arm (Right)", + instructionText = "Extend the arm (palms down) 90 degrees (if sitting) or 45 degrees (if supine). Hold for 10 seconds.", + options = listOf( + FormOption("No drift; arm holds 90 (or 45) degrees for full 10 seconds.", 0), + FormOption("Drift; arm holds but drifts down before full 10 seconds.", 1), + FormOption("Some effort against gravity; arm cannot get to or maintain (if cued) 90 (or 45) degrees, drifts down to bed.", 2), + FormOption("No effort against gravity; arm falls.", 3), + FormOption("No movement.", 4), + FormOption("Amputation or joint fusion, explain:", 0) + ) + ), + FormQuestion( + id = 9, + questionText = "7. Motor Leg (Left)", + instructionText = "Raise the leg to 30 degrees (always supine). Hold for 5 seconds.", + options = listOf( + FormOption("No drift; leg holds 30-degree position for full 5 seconds.", 0), + FormOption("Drift; leg falls by end of the 5-second period but does not hit bed.", 1), + FormOption("Some effort against gravity; leg falls to bed by 5 seconds, but has some effort against gravity.", 2), + FormOption("No effort against gravity; leg falls to bed immediately.", 3), + FormOption("No movement.", 4), + FormOption("Amputation or joint fusion, explain:", 0) + ) + ), + FormQuestion( + id = 10, + questionText = "8. Motor Leg (Right)", + instructionText = "Raise the leg to 30 degrees (always supine). Hold for 5 seconds.", + options = listOf( + FormOption("No drift; leg holds 30-degree position for full 5 seconds.", 0), + FormOption("Drift; leg falls by end of the 5-second period but does not hit bed.", 1), + FormOption("Some effort against gravity; leg falls to bed by 5 seconds, but has some effort against gravity.", 2), + FormOption("No effort against gravity; leg falls to bed immediately.", 3), + FormOption("No movement.", 4), + FormOption("Amputation or joint fusion, explain:", 0) + ) + ), + FormQuestion( + id = 11, + questionText = "9. Limb Ataxia", + instructionText = "Test with finger-nose-finger and heel-shin tests on both sides.", + options = listOf( + FormOption("Absent.", 0), + FormOption("Present in one limb.", 1), + FormOption("Present in two limbs.", 2), + FormOption("Does not understand or is paralyzed.", 0) + ) + ), + FormQuestion( + id = 12, + questionText = "10. Sensory", + instructionText = "Test sensation or grimace to pinprick.", + options = listOf( + FormOption("Normal; no sensory loss.", 0), + FormOption("Mild-to-moderate sensory loss; less sharp/dull on affected side.", 1), + FormOption("Severe to total sensory loss; not aware of being touched.", 2) + ) + ), + FormQuestion( + id = 13, + questionText = "11. Best Language", + instructionText = "Ask patient to describe what is happening in the provided picture, name items, and read sentences.", + options = listOf( + FormOption("No aphasia; normal.", 0), + FormOption("Mild-to-moderate aphasia; some obvious loss of fluency or comprehension.", 1), + FormOption("Severe aphasia; all communication is fragmented, listener cannot understand.", 2), + FormOption("Mute, global aphasia; no usable speech or auditory comprehension.", 3) + ) + ), + FormQuestion( + id = 14, + questionText = "12. Dysarthria", + instructionText = "Ask the patient to read or repeat words from an attached list.", + options = listOf( + FormOption("Normal.", 0), + FormOption("Mild-to-moderate dysarthria; slurring of words but can be understood.", 1), + FormOption("Severe dysarthria; so slurred it is unintelligible or worse.", 2), + FormOption("Intubated or other physical barrier.", 0) + ) + ), + FormQuestion( + id = 15, + questionText = "13. Extinction and Inattention", + instructionText = "Sufficient information to identify neglect from prior testing is acceptable.", + options = listOf( + FormOption("No neglect.", 0), + FormOption("Visual, tactile, auditory, or personal inattention to one side.", 1), + FormOption("Profound hemi-inattention or extinction to more than one modality.", 2) + ) + ) + ) +} diff --git a/app/src/main/java/com/example/neurology_project_android/NewNIHFormActivity.kt b/app/src/main/java/com/example/neurology_project_android/NewNIHFormActivity.kt index 2b566a1..3566c72 100644 --- a/app/src/main/java/com/example/neurology_project_android/NewNIHFormActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/NewNIHFormActivity.kt @@ -1,490 +1,287 @@ package com.example.neurology_project_android -import android.content.Context -import android.content.Intent +import android.os.Build import android.os.Bundle import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.annotation.RequiresApi import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import kotlinx.coroutines.launch -import okhttp3.OkHttpClient -import java.text.SimpleDateFormat -import java.util.* - +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +// --- FIX 1: Import the new models --- +import com.example.neurology_project_android.NIHFormModel +import com.example.neurology_project_android.FormQuestion +import com.example.neurology_project_android.NewNIHFormViewModel +import com.example.neurology_project_android.SubmissionStatus +import dagger.hilt.android.AndroidEntryPoint + +// --- You can remove the old StrokeScaleQuestions import if it exists --- + // This annotation tells Hilt to manage dependencies for this Activity +@AndroidEntryPoint class NewNIHFormActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val formId = intent.getIntExtra("formId", -1) - val patientName = intent.getStringExtra("patientName") - val dob = intent.getStringExtra("dob") - val date = intent.getStringExtra("date") - val formData = intent.getStringExtra("formData") - val username = intent.getStringExtra("username") - val sessionManager = SessionManager(this) // Create the real instance - val existingForm = if (patientName != null && dob != null && date != null && formData != null && username != null) { - NIHForm(formId, patientName, dob, date, formData, username) - } else null - - setContent { - NewNIHFormScreen(existingForm, sessionManager) - } - } -} -@Composable -fun NewNIHFormScreen(existingForm: NIHForm? = null, sessionManager: ISessionManager) { - val context = LocalContext.current + // 1. Get the ViewModel directly from Hilt. + // The `by viewModels()` delegate handles everything for you. + private val viewModel: NewNIHFormViewModel by viewModels() - val client = sessionManager.client - val coroutineScope = rememberCoroutineScope() - var refreshTrigger by remember { mutableStateOf(0) } - - var patientName by remember { mutableStateOf(existingForm?.patientName ?: "") } - var dob by remember { mutableStateOf(existingForm?.dob ?: "") } - val questions = remember { StrokeScaleQuestions.questions } - val selectedOptions = remember { - mutableStateListOf().apply { repeat(questions.size) { add(null) } } + companion object { + const val EXTRA_IN_CALL = "extra_in_call" } - val keyboardController = LocalSoftwareKeyboardController.current - val dobCalendar = remember { Calendar.getInstance() } - val username = remember { sessionManager.fetchUsername() ?: "anonymous" } + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) - val date = remember { - existingForm?.date ?: SimpleDateFormat("MM/dd/yyyy", Locale.getDefault()).format(Date()) + val inCall = intent.getBooleanExtra(EXTRA_IN_CALL, false) + // Hilt provides the ViewModel and its dependencies automatically. + // No manual setup needed. + setContent { + // 2. Simply pass the Hilt-provided ViewModel to your screen. + NewNIHFormScreen(viewModel = viewModel, inCall = inCall) + } } - // Populate selected options if editing - LaunchedEffect(existingForm) { - existingForm?.formData?.forEachIndexed { index, c -> - val score = c.toString().toIntOrNull() ?: 9 - selectedOptions[index] = if (score != 9) score else null + // This is the Composable function your Activity is trying to call. + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + @Composable + fun NewNIHFormScreen(viewModel: NewNIHFormViewModel, inCall: Boolean) { + // 1. Observe state directly from the ViewModel + val patientName by viewModel.patientName + val itemScores by viewModel.itemScores + val submissionStatus by viewModel.submissionStatus.collectAsState() + val context = LocalContext.current + + val totalScore by remember(itemScores) { + derivedStateOf { + itemScores.mapNotNull { it }.sum() + } } - } - val datePickerDialog = android.app.DatePickerDialog( - context, - { _, year, month, dayOfMonth -> - dobCalendar.set(year, month, dayOfMonth) - dob = SimpleDateFormat("MM/dd/yyyy", Locale.getDefault()).format(dobCalendar.time) - }, - dobCalendar.get(Calendar.YEAR), - dobCalendar.get(Calendar.MONTH), - dobCalendar.get(Calendar.DAY_OF_MONTH) - ) + // 2. React to changes in submissionStatus (e.g., show a toast, navigate away) + LaunchedEffect(submissionStatus) { + when (val status = submissionStatus) { + is SubmissionStatus.Success -> { + Toast.makeText(context, "Form submitted successfully!", Toast.LENGTH_SHORT).show() + // You could finish the activity upon success + // (context as? android.app.Activity)?.finish() + viewModel.resetSubmissionStatus() + } + is SubmissionStatus.Error -> { + Toast.makeText(context, status.message, Toast.LENGTH_LONG).show() + viewModel.resetSubmissionStatus() + } + else -> { /* Do nothing for Idle or Loading states here */ } + } + } - Column( - modifier = Modifier - .fillMaxSize() - .background(Color(0xFFF3E5F5)) - .padding(16.dp) - ) { + // 3. Define the UI structure Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .background(MaterialTheme.colorScheme.background) ) { Text( text = "New NIH Stroke Scale Form", fontSize = 24.sp, fontWeight = FontWeight.Bold, - modifier = Modifier - .padding(top = 16.dp, bottom = 8.dp) + modifier = Modifier.padding(bottom = 16.dp) ) OutlinedTextField( value = patientName, - onValueChange = { patientName = it }, - label = { Text("Enter Patient Name") }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - keyboardOptions = KeyboardOptions( - autoCorrect = false // Disables suggestions - ), - keyboardActions = KeyboardActions( - onDone = { keyboardController?.hide() } // Closes keyboard on Done press - ), - placeholder = { Text("") }, // Prevents showing hints + // Send user input events up to the ViewModel + onValueChange = { viewModel.onPatientNameChange(it) }, + label = { Text("Patient Name") }, + modifier = Modifier.fillMaxWidth() ) - OutlinedTextField( - value = dob, - onValueChange = {}, - readOnly = true, - label = { Text("Date of Birth") }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .clickable { datePickerDialog.show() }, - enabled = false, //Disables editing, but still clickable due to Modifier - colors = OutlinedTextFieldDefaults.colors( - disabledBorderColor = MaterialTheme.colorScheme.outline - ) - - ) - - - Text( - text = "Date: $date", - fontSize = 16.sp, - color = Color.Gray, - modifier = Modifier - .padding(bottom = 16.dp) - ) - } - - LazyColumn( - modifier = Modifier.weight(1f) - ) { - items(questions) { question -> - QuestionCard(question, selectedOptions) + Spacer(modifier = Modifier.height(16.dp)) + + // A scrollable list for all the questions + LazyColumn(modifier = Modifier.weight(1f)) { + // --- FIX 2: Use the new NIHFormModel instead of StrokeScaleQuestions --- + itemsIndexed(NIHFormModel.questions) { index, question -> + QuestionCard( + questionIndex = index, + question = question, + // Get the currently selected score for this question from the ViewModel's state + selectedScore = itemScores.getOrNull(index), + inCall = inCall, + // When an option is clicked, notify the ViewModel with the question index and the option's score + onOptionClick = { score -> + viewModel.onScoreSelected(index, score) + }, + onAutomateClick = { + viewModel.automateQuestion(index) + } + ) + } } - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally) - ) { - Button( - onClick = { - if (patientName.isBlank()) { - Toast.makeText(context, "Please enter a patient name", Toast.LENGTH_SHORT).show() - return@Button - } + // --- NEW: ADD THE TOTAL SCORE DISPLAY --- + Spacer(modifier = Modifier.height(16.dp)) - coroutineScope.launch { - val formData = selectedOptions.joinToString("") { (it ?: 9).toString() } - val form = NIHForm( - patientName = patientName, - dob = dob, - date = date, - formData = formData, - username = username - ) - FormManager.submitFormToServer(form, client as OkHttpClient) { success -> - (context as? ComponentActivity)?.runOnUiThread { - if (success) { - Toast.makeText(context, "Form saved successfully", Toast.LENGTH_SHORT).show() - val intent = Intent(context, ListNIHFormActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP - context.startActivity(intent) - } else { - Toast.makeText(context, "Failed to save form", Toast.LENGTH_SHORT).show() - } - } - } - } - }, - colors = ButtonDefaults.buttonColors(containerColor = Color.Green), - modifier = Modifier.weight(1f) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Text(text = "Save", color = Color.White) + Text( + text = "Total NIHSS Score:", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineSmall + ) + Text( + text = "$totalScore", // Display the calculated total score + fontSize = 22.sp, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.primary + ) } + Spacer(modifier = Modifier.height(8.dp)) + Button( - onClick = { - val intent = Intent(context, ListNIHFormActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP - context.startActivity(intent) - }, - colors = ButtonDefaults.buttonColors(containerColor = Color.Red), - modifier = Modifier.weight(1f) + // When the button is clicked, call the submitForm function on the ViewModel + onClick = { viewModel.submitForm() }, + // Disable the button while the form is submitting + enabled = submissionStatus != SubmissionStatus.Loading, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) ) { - Text(text = "Cancel", color = Color.White) + if (submissionStatus == SubmissionStatus.Loading) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), color = Color.White) + } else { + Text("Save Form") + } } } } -} - -@Composable -fun QuestionCard(question: StrokeScaleQuestion, selectedOptions: MutableList) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - .background(Color.White) - .padding(16.dp), - horizontalAlignment = Alignment.Start - ) { - // Question Header - Text( - text = question.questionHeader, - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - modifier = Modifier.padding(bottom = 4.dp) - ) - - // Subheader - if (!question.subHeader.isNullOrEmpty()) { - Text( - text = question.subHeader, - fontSize = 14.sp, - color = Color.Gray, - modifier = Modifier.padding(bottom = 8.dp) + /** + * A reusable Composable for displaying a single question, its options, and highlighting the selection. + */ + @Composable + fun QuestionCard(questionIndex: Int, question: FormQuestion, selectedScore: Int?, inCall: Boolean, onOptionClick: (Int) -> Unit, onAutomateClick: () -> Unit) { // --- FIX 3: Use FormQuestion class --- + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors( + containerColor = Color.hsv(268.9F, saturation = .165F, value = 1.0F, alpha = 1.0F) ) - } - - // Options - question.options.forEachIndexed { index, option -> - Row( + ) { + Column( modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .background( - if (selectedOptions[question.id] == index) Color(0xFFA5D6A7) else Color(0xFFF3E5F5) - ) - .padding(8.dp) - .clickable { selectedOptions[question.id] = index }, - horizontalArrangement = Arrangement.SpaceBetween + .padding(16.dp) + ) { - Text(text = option.title) - Text(text = if (option.score > 0) "+${option.score}" else "${option.score}") + // --- FIX 4: Use properties from the new FormQuestion data class --- + Text(text = question.questionText, fontWeight = FontWeight.Bold, fontSize = 18.sp) + if (question.instructionText.isNotEmpty()) { + Text( + text = question.instructionText, + fontSize = 18.sp, + color = Color.Gray, + modifier = Modifier.padding(bottom = 8.dp) + ) + } + + // Show automate only for question index 6 AND only in a call + if (inCall && questionIndex == 6) { + Button( + onClick = onAutomateClick, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + Text("Automate Question") + } + } + + // Create a clickable row for each answer option + question.options.forEach { option -> + Row( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .border( + width = 2.dp, + color = Color.LightGray, + shape = RoundedCornerShape(8.dp) + ) + .fillMaxWidth() + .background( + // Highlight the row if its score matches the selected score + if (selectedScore == option.score) Color.hsv(270.0F, saturation = .477F, value = .987F, alpha = 1.0F) else Color.White + ) + + .clickable { onOptionClick(option.score) } // Pass the option's actual score up + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // --- FIX 5: Use properties from the new FormOption data class --- + Text(text = option.displayText,fontSize = 18.sp, modifier = Modifier.weight(1f)) + Text(text = "${option.score}",fontSize = 18.sp) + } + Spacer(modifier = Modifier.height(8.dp)) + } } } } } -data class StrokeScaleQuestion( - val id: Int, - val questionHeader: String, - val subHeader: String?, - val options: List