diff --git a/StudyBuddy/.gitignore b/StudyBuddy/.gitignore new file mode 100644 index 00000000..aa724b77 --- /dev/null +++ b/StudyBuddy/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/StudyBuddy/.idea/.gitignore b/StudyBuddy/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/StudyBuddy/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/StudyBuddy/.idea/.name b/StudyBuddy/.idea/.name new file mode 100644 index 00000000..43b3e179 --- /dev/null +++ b/StudyBuddy/.idea/.name @@ -0,0 +1 @@ +Study Buddy \ No newline at end of file diff --git a/StudyBuddy/.idea/appInsightsSettings.xml b/StudyBuddy/.idea/appInsightsSettings.xml new file mode 100644 index 00000000..23b2e1fe --- /dev/null +++ b/StudyBuddy/.idea/appInsightsSettings.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/StudyBuddy/.idea/compiler.xml b/StudyBuddy/.idea/compiler.xml new file mode 100644 index 00000000..b589d56e --- /dev/null +++ b/StudyBuddy/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/StudyBuddy/.idea/deploymentTargetSelector.xml b/StudyBuddy/.idea/deploymentTargetSelector.xml new file mode 100644 index 00000000..55a93089 --- /dev/null +++ b/StudyBuddy/.idea/deploymentTargetSelector.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/StudyBuddy/.idea/gradle.xml b/StudyBuddy/.idea/gradle.xml new file mode 100644 index 00000000..0897082f --- /dev/null +++ b/StudyBuddy/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/StudyBuddy/.idea/inspectionProfiles/Project_Default.xml b/StudyBuddy/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 00000000..fb93d254 --- /dev/null +++ b/StudyBuddy/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,69 @@ + + + + \ No newline at end of file diff --git a/StudyBuddy/.idea/kotlinc.xml b/StudyBuddy/.idea/kotlinc.xml new file mode 100644 index 00000000..fdf8d994 --- /dev/null +++ b/StudyBuddy/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/StudyBuddy/.idea/migrations.xml b/StudyBuddy/.idea/migrations.xml new file mode 100644 index 00000000..f8051a6f --- /dev/null +++ b/StudyBuddy/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/StudyBuddy/.idea/misc.xml b/StudyBuddy/.idea/misc.xml new file mode 100644 index 00000000..8978d23d --- /dev/null +++ b/StudyBuddy/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/StudyBuddy/.idea/vcs.xml b/StudyBuddy/.idea/vcs.xml new file mode 100644 index 00000000..35eb1ddf --- /dev/null +++ b/StudyBuddy/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/StudyBuddy/app/.gitignore b/StudyBuddy/app/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/StudyBuddy/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/StudyBuddy/app/build.gradle.kts b/StudyBuddy/app/build.gradle.kts new file mode 100644 index 00000000..2b9b33b7 --- /dev/null +++ b/StudyBuddy/app/build.gradle.kts @@ -0,0 +1,112 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.jetbrains.kotlin.android) + id("com.google.dagger.hilt.android") + id("com.google.devtools.ksp") + alias(libs.plugins.google.gms.google.services) +} + +android { + namespace = "com.example.studybuddy" + compileSdk = 34 + + defaultConfig { + applicationId = "com.example.studybuddy" + minSdk = 28 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.1" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation ("com.google.firebase:firebase-firestore:25.0.0") // Replace with latest compatible version + implementation ("com.google.firebase:firebase-auth:23.0.0") // Replace with latest compatible version + implementation ("com.google.firebase:firebase-storage:21.0.0") // Replace with latest compatible version + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.runtime.livedata) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.androidx.activity) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + + implementation(libs.androidx.lifecycle.runtime.compose) + + //compose destination + val destinationVersion = "1.10.0" + implementation(libs.core) + ksp(libs.compose.destinations.ksp) + + // Room + val roomVersion = "2.6.1" + implementation(libs.androidx.room.runtime) + ksp(libs.androidx.room.compiler) + implementation(libs.androidx.room.ktx) + + //Dagger-Hilt + implementation(libs.dagger.hilt.android) + ksp(libs.hilt.android.compiler) + ksp(libs.androidx.hilt.compiler) + implementation(libs.androidx.hilt.navigation.compose) + + //fonts + implementation(libs.androidx.ui.text.google.fonts) + + + coreLibraryDesugaring(libs.desugar.jdk.libs) + + + implementation("com.mesibo.api:webrtc:1.0.5") + implementation("org.java-websocket:Java-WebSocket:1.5.7") + implementation("com.google.code.gson:gson:2.10.1") + + +} \ No newline at end of file diff --git a/StudyBuddy/app/google-services.json b/StudyBuddy/app/google-services.json new file mode 100644 index 00000000..a242a565 --- /dev/null +++ b/StudyBuddy/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "645370312545", + "project_id": "study-buddy-e11a2", + "storage_bucket": "study-buddy-e11a2.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:645370312545:android:f6abeceaf79f4a06373720", + "android_client_info": { + "package_name": "com.example.studybuddy" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyCgeXGafBRoE84e32V3gdttHdjGay8LVn4" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/StudyBuddy/app/proguard-rules.pro b/StudyBuddy/app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/StudyBuddy/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/StudyBuddy/app/src/androidTest/java/com/example/studybuddy/ExampleInstrumentedTest.kt b/StudyBuddy/app/src/androidTest/java/com/example/studybuddy/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..bfe46aec --- /dev/null +++ b/StudyBuddy/app/src/androidTest/java/com/example/studybuddy/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.studybuddy + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.studybuddy", appContext.packageName) + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/AndroidManifest.xml b/StudyBuddy/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..236cafa6 --- /dev/null +++ b/StudyBuddy/app/src/main/AndroidManifest.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/MainActivity.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/MainActivity.kt new file mode 100644 index 00000000..7869f2ad --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/MainActivity.kt @@ -0,0 +1,105 @@ +package com.example.studybuddy + +import android.content.ComponentName +import android.content.Intent +import android.content.ServiceConnection +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.app.ActivityCompat +import com.example.studybuddy.ui.theme.StudyBuddyTheme +import com.example.studybuddy.view.NavGraphs +import com.example.studybuddy.view.destinations.SessionScreenRouteDestination +import com.example.studybuddy.view.session.StudySessionTimerService +import com.ramcosta.composedestinations.DestinationsNavHost +import com.ramcosta.composedestinations.navigation.dependency +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + private var isBound by mutableStateOf(false) + private lateinit var timerService: StudySessionTimerService + private val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + val binder = service as StudySessionTimerService.StudySessionTimerBinder + timerService = binder.getService() + isBound = true + } + + override fun onServiceDisconnected(name: ComponentName?) { + isBound = false + } + } + + override fun onStart() { + super.onStart() + Intent(this, StudySessionTimerService::class.java).also { intent -> + bindService(intent, connection, BIND_AUTO_CREATE) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + setContent { + + + // Only navigate when the service is bound + if (isBound) { + StudyBuddyTheme { + DestinationsNavHost( + navGraph = NavGraphs.root, + dependenciesContainerBuilder = { + dependency ( SessionScreenRouteDestination ) {timerService + } + } + ) + } + } else { + // Show a loading UI or a message while the service is binding + LoadingScreen() // Custom composable to show a loading indicator + } + } + + + requestPermission() + } + private fun requestPermission(){ + if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.TIRAMISU) + { + ActivityCompat.requestPermissions( + this, + arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), + 0) + } + } + + override fun onStop() { + super.onStop() + // Keep the service bound to maintain the session + } + + override fun onDestroy() { + super.onDestroy() + if (isBound) { + unbindService(connection) + isBound = false + } + } +} + +@Composable +fun LoadingScreen() { + // This is a placeholder UI to display while waiting for the service to bind + Text("Loading...") +} diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/StudyBuddyApp.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/StudyBuddyApp.kt new file mode 100644 index 00000000..84b8149b --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/StudyBuddyApp.kt @@ -0,0 +1,13 @@ +package com.example.studybuddy + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp +import java.util.UUID + + +@HiltAndroidApp +class StudyBuddyApp:Application(){ + companion object{ + val username = UUID.randomUUID().toString().substring(0,6) + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/di/AppModule.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/di/AppModule.kt new file mode 100644 index 00000000..b8b438a4 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/di/AppModule.kt @@ -0,0 +1,20 @@ +package com.example.studybuddy.domain.database.di + +import com.google.gson.Gson +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import android.content.Context + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + + @Provides + fun providesContext(@ApplicationContext context: Context):Context = context + + @Provides + fun providesGson():Gson = Gson() +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/di/DatabaseModule.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/di/DatabaseModule.kt new file mode 100644 index 00000000..195a25f8 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/di/DatabaseModule.kt @@ -0,0 +1,63 @@ +package com.example.studybuddy.domain.database.di + +import android.app.Application +import androidx.room.Room +import com.example.studybuddy.domain.database.local.AppDatabase +import com.example.studybuddy.domain.database.local.SessionDao +import com.example.studybuddy.domain.database.local.SubjectDao +import com.example.studybuddy.domain.database.local.TaskDao +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + +@Provides +@Singleton //it will make sure that there is only one instance of the provided function + + fun provideDatabase( + application: Application + ):AppDatabase{ + return Room.databaseBuilder( + application, + AppDatabase::class.java, + "studybuddy.db" + ).build() + } + + + + @Provides + @Singleton + + fun provideSubjectDao(database: AppDatabase) :SubjectDao{ + return database.subjectDao() + + } + + @Provides + @Singleton + + fun provideTaskDao(database: AppDatabase) : TaskDao { + return database.taskDao() + + } + + @Provides + @Singleton + + fun provideSessionDao(database: AppDatabase) :SessionDao{ + return database.sessionDao() + + } + + + + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/di/FirebaseModule.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/di/FirebaseModule.kt new file mode 100644 index 00000000..b231cc93 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/di/FirebaseModule.kt @@ -0,0 +1,30 @@ +package com.example.studybuddy.domain.database.di + +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.FirebaseFirestore +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + + +@InstallIn(SingletonComponent::class) +@Module +object FirebaseModule { + + @Provides + @Singleton + fun ProvideFirestoreInstance():FirebaseFirestore{ + return FirebaseFirestore.getInstance() + } + + @Provides + @Singleton + fun ProvideFirebaseAuthInstance():FirebaseAuth{ + return FirebaseAuth.getInstance() + } + + + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/di/NotificationModule.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/di/NotificationModule.kt new file mode 100644 index 00000000..35f016f1 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/di/NotificationModule.kt @@ -0,0 +1,42 @@ +package com.example.studybuddy.domain.database.di + +import android.app.NotificationManager +import android.content.Context +import androidx.core.app.NotificationCompat +import com.example.studybuddy.R +import com.example.studybuddy.utils.ServiceConstants.NOTIFICATION_CHANNEL_ID +import com.example.studybuddy.view.session.ServiceHelper +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ServiceComponent +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.scopes.ServiceScoped + +@Module +@InstallIn(ServiceComponent::class) +object NotificationModule { + @ServiceScoped + @Provides + fun provideNotificationBuilder( + @ApplicationContext context: Context + + ): NotificationCompat.Builder{ + return NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) + .setAutoCancel(false) + .setOngoing(true) + .setSmallIcon(R.drawable.logo) + .setContentTitle("Study Session") + .setContentText("00:00:00") + .setContentIntent(ServiceHelper.clickPendingIntent(context)) + } + + @ServiceScoped + @Provides + + fun provideNotificationManager( + @ApplicationContext context: Context + ): NotificationManager{ + return context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/di/RepositoryModule.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/di/RepositoryModule.kt new file mode 100644 index 00000000..878ddef9 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/di/RepositoryModule.kt @@ -0,0 +1,43 @@ +package com.example.studybuddy.domain.database.di + +import com.example.studybuddy.domain.database.repositoryImpl.SessionRepositoryImpl +import com.example.studybuddy.domain.database.repositoryImpl.SubjectRepositoryImpl +import com.example.studybuddy.domain.database.repositoryImpl.TaskRepositoryImpl +import com.example.studybuddy.domain.model.repository.SessionRepository +import com.example.studybuddy.domain.model.repository.SubjectRepository +import com.example.studybuddy.domain.model.repository.TaskRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + + +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + + + @Singleton + @Binds + abstract fun bindSubjectRepository( + impl:SubjectRepositoryImpl + ):SubjectRepository + + @Singleton + @Binds + abstract fun bindTaskRepository( + impl: TaskRepositoryImpl + ): TaskRepository + + + + @Singleton + @Binds + abstract fun bindSessionRepository( + impl: SessionRepositoryImpl + ): SessionRepository + + + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/local/AppDatabase.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/local/AppDatabase.kt new file mode 100644 index 00000000..ede9861b --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/local/AppDatabase.kt @@ -0,0 +1,22 @@ +package com.example.studybuddy.domain.database.local + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.example.studybuddy.domain.model.Session +import com.example.studybuddy.domain.model.Subject +import com.example.studybuddy.domain.model.Task +import com.example.studybuddy.utils.ColorListConverter + + +@Database(entities = [Subject::class, Session::class, Task::class], version = 1) + +@TypeConverters(ColorListConverter::class) +abstract class AppDatabase : RoomDatabase() { + + + abstract fun subjectDao(): SubjectDao + abstract fun sessionDao(): SessionDao + abstract fun taskDao(): TaskDao + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/local/SessionDao.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/local/SessionDao.kt new file mode 100644 index 00000000..ec0463e1 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/local/SessionDao.kt @@ -0,0 +1,41 @@ +package com.example.studybuddy.domain.database.local + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import com.example.studybuddy.domain.model.Session +import kotlinx.coroutines.flow.Flow + +@Dao +interface SessionDao { + + + @Insert + suspend fun insertSession(session: Session) + + @Delete + suspend fun deleteSession(session: Session) + + + @Query("SELECT * FROM session") + fun getAllSessions(): Flow> + + + @Query("SELECT * FROM session WHERE sessionSubjectId = :subjectId") + fun getSessionBySubjectId(subjectId: Int): Flow> + + @Query("SELECT SUM(duration) FROM Session") + fun getTotalSessionDuration(): Flow + + + @Query("SELECT SUM (duration) FROM session WHERE sessionSubjectId = :subjectId") + fun getTotalDurationBySubjectId(subjectId: Int): Flow + + @Query("SELECT SUM (duration) FROM Session ") + fun getTotalDurationBySubjectId(): Flow + + + @Query("DELETE FROM session WHERE sessionSubjectId = :subjectId") + suspend fun deleteSessionBySubjectId(subjectId: Int) +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/local/SubjectDao.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/local/SubjectDao.kt new file mode 100644 index 00000000..fe44baee --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/local/SubjectDao.kt @@ -0,0 +1,38 @@ +package com.example.studybuddy.domain.database.local + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.example.studybuddy.domain.model.Subject +import kotlinx.coroutines.flow.Flow + + +@Dao +interface SubjectDao { + + @Upsert + suspend fun upsertSubject(subject: Subject) + + + @Query("SELECT COUNT(*) FROM SUBJECT") + fun getTotalSubjectCount(): Flow + + + @Query("SELECT SUM(goalHour) FROM SUBJECT") + fun getTotalGoalHours():Flow + + + @Query("SELECT * FROM SUBJECT WHERE subjectId = :subjectId") + suspend fun getSubjectById(subjectId : Int) : Subject? + + + @Query("DELETE FROM SUBJECT WHERE subjectId = :subjectId") + suspend fun deleteSubjectById(subjectId : Int) + + + @Query("SELECT * FROM SUBJECT") + fun getAllSubjects() : Flow> + + + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/local/TaskDao.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/local/TaskDao.kt new file mode 100644 index 00000000..2182a6bb --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/local/TaskDao.kt @@ -0,0 +1,33 @@ +package com.example.studybuddy.domain.database.local + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.example.studybuddy.domain.model.Task +import kotlinx.coroutines.flow.Flow + +@Dao +interface TaskDao { + + @Upsert + suspend fun upsertTask (task: Task) + + + @Query("DELETE FROM task WHERE taskId = :taskId") + suspend fun deleteTaskById(taskId: Int) + + + @Query("DELETE FROM task WHERE taskSubjectId = :subjectId") + suspend fun deleteTaskBySubjectId(subjectId: Int) + + + @Query("SELECT * FROM task WHERE taskId = :taskId") + suspend fun getTaskByTaskId(taskId: Int): Task? + + + @Query("SELECT * FROM task WHERE taskSubjectId = :subjectId") + fun getTaskBySubjectId(subjectId: Int): Flow> + + @Query("SELECT * FROM task") + fun getAllTasks(): Flow> +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/repositoryImpl/SessionRepositoryImpl.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/repositoryImpl/SessionRepositoryImpl.kt new file mode 100644 index 00000000..10e3b882 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/repositoryImpl/SessionRepositoryImpl.kt @@ -0,0 +1,52 @@ +package com.example.studybuddy.domain.database.repositoryImpl + +import com.example.studybuddy.domain.database.local.SessionDao +import com.example.studybuddy.domain.model.Session +import com.example.studybuddy.domain.model.repository.SessionRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.take +import javax.inject.Inject + +class SessionRepositoryImpl @Inject constructor( + private val sessionDao: SessionDao +):SessionRepository { + override suspend fun insertSession(session: Session) { + sessionDao.insertSession(session) + } + + override suspend fun deleteSession(session: Session) { + return sessionDao.deleteSession(session) + } + + override fun getAllSessions(): Flow> { + return sessionDao.getAllSessions().map { + sessions-> sessions.sortedByDescending { it.date } + } + } + + + override fun getRecentFiveSessions(): Flow> { + return sessionDao.getAllSessions().map { + sessions-> sessions.sortedByDescending { it.date } + } + .take(5) + } + + + override suspend fun getSessionBySubjectId(subjectId: Int): Flow> { + TODO("Not yet implemented") + } + + override fun getTotalSessionDuration(): Flow { + return sessionDao.getTotalSessionDuration() + } + + override fun getTotalSessionDurationBySubjectId(subjectId: Int): Flow { + return sessionDao.getTotalDurationBySubjectId(subjectId) + } + + override suspend fun deleteSessionBySubjectId(subjectId: Int) { + TODO("Not yet implemented") + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/repositoryImpl/SubjectRepositoryImpl.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/repositoryImpl/SubjectRepositoryImpl.kt new file mode 100644 index 00000000..1c7c6e2f --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/repositoryImpl/SubjectRepositoryImpl.kt @@ -0,0 +1,41 @@ +package com.example.studybuddy.domain.database.repositoryImpl + +import com.example.studybuddy.domain.database.local.SessionDao +import com.example.studybuddy.domain.database.local.SubjectDao +import com.example.studybuddy.domain.database.local.TaskDao +import com.example.studybuddy.domain.model.Subject +import com.example.studybuddy.domain.model.repository.SubjectRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class SubjectRepositoryImpl @Inject constructor( + private val subjectDao: SubjectDao, + private val taskDao: TaskDao, + private val sessionDao: SessionDao +):SubjectRepository { + override suspend fun upsertSubject(subject: Subject) { + subjectDao.upsertSubject(subject) + } + + override fun getTotalSubjectCount(): Flow { + return subjectDao.getTotalSubjectCount() + } + + override fun getTotalGoalHours(): Flow { + return subjectDao.getTotalGoalHours() + } + + override suspend fun getSubjectById(subjectId: Int): Subject? { + return subjectDao.getSubjectById(subjectId) + } + + override suspend fun deleteSubjectById(subjectId: Int) { + taskDao.deleteTaskBySubjectId(subjectId) + sessionDao.deleteSessionBySubjectId(subjectId) + subjectDao.deleteSubjectById(subjectId) + } + + override fun getAllSubjects(): Flow> { + return subjectDao.getAllSubjects() + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/repositoryImpl/TaskRepositoryImpl.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/repositoryImpl/TaskRepositoryImpl.kt new file mode 100644 index 00000000..8e0b2ffa --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/database/repositoryImpl/TaskRepositoryImpl.kt @@ -0,0 +1,53 @@ +package com.example.studybuddy.domain.database.repositoryImpl + +import com.example.studybuddy.domain.database.local.TaskDao +import com.example.studybuddy.domain.model.Task +import com.example.studybuddy.domain.model.repository.TaskRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class TaskRepositoryImpl @Inject constructor( + private val taskDao: TaskDao +):TaskRepository { + override suspend fun upsertTask(task: Task) { + return taskDao.upsertTask(task) + } + + override suspend fun deleteTaskById(taskId: Int) { + return taskDao.deleteTaskById(taskId) + } + + override suspend fun deleteTaskBySubjectId(subjectId: Int) { + TODO("Not yet implemented") + } + + override suspend fun getTaskByTaskId(taskId: Int): Task? { + return taskDao.getTaskByTaskId(taskId) + } + + override fun getTaskBySubjectId(subjectId: Int): Flow> { + return taskDao.getTaskBySubjectId(subjectId) + .map { tasks -> tasks.filter { it.isCompleted.not() } } + } + + override fun getAllTasks(): Flow> { + return taskDao.getAllTasks() + } + + override fun getCompletedTaskBySubjectId(subjectId: Int): Flow> { + return taskDao.getTaskBySubjectId(subjectId) + .map { tasks -> tasks.filter { it.isCompleted} } + } + + override fun getAllUpcomingTasks(): Flow> { + return taskDao.getAllTasks() + .map { tasks ->tasks.filter { it.isCompleted.not() } } + .map { tasks -> sortTasks(tasks) } + } + +private fun sortTasks(tasks: List): List { + return tasks.sortedWith(compareBy {it.dueDate }. thenByDescending { it.priority }) +} + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/BottomNavigationItem.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/BottomNavigationItem.kt new file mode 100644 index 00000000..c299f42a --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/BottomNavigationItem.kt @@ -0,0 +1,9 @@ +package com.example.studybuddy.domain.model + +data class BottomNavigationItem( + val title:String, + val selectedIcon: Int, + val unselectedIcon:Int, + val hasNews:Boolean, + val badgeCount:Int? =null +) diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/Chats.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/Chats.kt new file mode 100644 index 00000000..c1615553 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/Chats.kt @@ -0,0 +1,20 @@ +package com.example.studybuddy.domain.model + +data class ChatData( + val chatId: String? = "", + val user1:ChatUser = ChatUser(), + val user2:ChatUser = ChatUser(), +) + +data class ChatUser( + val userId: String? = "", + val name: String? = "", + val phone: String? = "", +) + +data class Message( + val userId: String="", + val time:String="", + val message:String="" +) + diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/MessageModel.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/MessageModel.kt new file mode 100644 index 00000000..c9b46871 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/MessageModel.kt @@ -0,0 +1,10 @@ +package com.example.studybuddy.domain.model + +import com.example.studybuddy.view.videocall.socket.SocketEvents + +data class MessageModel( + val type: SocketEvents, + val name: String? = null, + val target: String? = null, + val data:Any?=null +) \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/RoomModel.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/RoomModel.kt new file mode 100644 index 00000000..76d56e66 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/RoomModel.kt @@ -0,0 +1,6 @@ +package com.example.studybuddy.domain.model + +data class RoomModel( + val roomName:String, + val population:Int +) \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/Session.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/Session.kt new file mode 100644 index 00000000..83444f7e --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/Session.kt @@ -0,0 +1,16 @@ +package com.example.studybuddy.domain.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class Session( + val sessionSubjectId:Int, + @PrimaryKey(autoGenerate = true) + val sessionId:Int? = null, + val date:Long, + val duration:Long, + val relatedToSubject:String) { + + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/Subject.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/Subject.kt new file mode 100644 index 00000000..b41aaaa7 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/Subject.kt @@ -0,0 +1,24 @@ +package com.example.studybuddy.domain.model + +import androidx.compose.ui.graphics.Color +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.example.studybuddy.ui.theme.gradient1 +import com.example.studybuddy.ui.theme.gradient2 +import com.example.studybuddy.ui.theme.gradient3 +import com.example.studybuddy.ui.theme.gradient4 +import com.example.studybuddy.ui.theme.gradient5 + +@Entity +data class Subject( + val name: String, + val goalHour:Float, + val color:List, + @PrimaryKey(autoGenerate = true) + val SubjectId:Int? = null +){ + companion object{ + val subjectCardColor = listOf(gradient1, gradient2, gradient3, gradient4, gradient5) + + } +} diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/Task.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/Task.kt new file mode 100644 index 00000000..97545493 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/Task.kt @@ -0,0 +1,20 @@ +package com.example.studybuddy.domain.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + + +@Entity +data class Task( + val title: String, + val description: String, + val dueDate:Long, + val dueTime:Long, + val setReminder:Boolean, + val priority:Int, + val relatedToSubject:String, + val isCompleted:Boolean, + @PrimaryKey(autoGenerate = true) + val TaskId: Int? = null, + val taskSubjectId:Int +) diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/User.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/User.kt new file mode 100644 index 00000000..04c56575 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/User.kt @@ -0,0 +1,18 @@ +package com.example.studybuddy.domain.model + +data class UserData( + val userId:String? = "", + val name: String? = "", + val number: String? = null, + val PremiumMember:Boolean? = true, + val expiry:Long? = null +){ + fun toMap() = mapOf( + "userId" to userId, + "userName" to name, + "number" to number, + "PremiumMember" to PremiumMember, + "Expiry" to expiry, + + ) +} diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/repository/SessionRepository.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/repository/SessionRepository.kt new file mode 100644 index 00000000..a6864b71 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/repository/SessionRepository.kt @@ -0,0 +1,26 @@ +package com.example.studybuddy.domain.model.repository + +import com.example.studybuddy.domain.model.Session +import kotlinx.coroutines.flow.Flow + +interface SessionRepository { + + suspend fun insertSession(session: Session) + + suspend fun deleteSession(session: Session) + + fun getAllSessions(): Flow> + + + fun getRecentFiveSessions(): Flow> + + suspend fun getSessionBySubjectId(subjectId: Int): Flow> + + fun getTotalSessionDuration(): Flow + + fun getTotalSessionDurationBySubjectId(subjectId: Int): Flow + + suspend fun deleteSessionBySubjectId(subjectId: Int) + + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/repository/SubjectRepository.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/repository/SubjectRepository.kt new file mode 100644 index 00000000..59df0ced --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/repository/SubjectRepository.kt @@ -0,0 +1,20 @@ +package com.example.studybuddy.domain.model.repository + +import com.example.studybuddy.domain.model.Subject +import kotlinx.coroutines.flow.Flow + +interface SubjectRepository { + + suspend fun upsertSubject(subject: Subject) + + fun getTotalSubjectCount(): Flow + + fun getTotalGoalHours(): Flow + + suspend fun getSubjectById(subjectId : Int) : Subject? + + suspend fun deleteSubjectById(subjectId : Int) + + fun getAllSubjects() : Flow> + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/repository/TaskRepository.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/repository/TaskRepository.kt new file mode 100644 index 00000000..6b741477 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/domain/model/repository/TaskRepository.kt @@ -0,0 +1,27 @@ +package com.example.studybuddy.domain.model.repository + +import com.example.studybuddy.domain.model.Task +import kotlinx.coroutines.flow.Flow + +interface TaskRepository { + + suspend fun upsertTask (task: Task) + + + suspend fun deleteTaskById(taskId: Int) + + + suspend fun deleteTaskBySubjectId(subjectId: Int) + + + suspend fun getTaskByTaskId(taskId: Int): Task? + + + fun getTaskBySubjectId(subjectId: Int): Flow> + + fun getCompletedTaskBySubjectId(subjectId: Int): Flow> + + fun getAllTasks(): Flow> + + fun getAllUpcomingTasks(): Flow> +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/ui/theme/Color.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/ui/theme/Color.kt new file mode 100644 index 00000000..10db784a --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/ui/theme/Color.kt @@ -0,0 +1,75 @@ +package com.example.studybuddy.ui.theme + +import androidx.compose.ui.graphics.Color + +val primaryLight = Color(0xFF455E91) +val onPrimaryLight = Color(0xFFFFFFFF) +val primaryContainerLight = Color(0xFFD8E2FF) +val onPrimaryContainerLight = Color(0xFF001A43) +val secondaryLight = Color(0xFF575E71) +val onSecondaryLight = Color(0xFFFFFFFF) +val secondaryContainerLight = Color(0xFFDBE2F9) +val onSecondaryContainerLight = Color(0xFF141B2C) +val tertiaryLight = Color(0xFF715573) +val onTertiaryLight = Color(0xFFFFFFFF) +val tertiaryContainerLight = Color(0xFFFCD7FB) +val onTertiaryContainerLight = Color(0xFF2A132D) +val errorLight = Color(0xFFBA1A1A) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFFFDAD6) +val onErrorContainerLight = Color(0xFF410002) +val backgroundLight = Color(0xFFFAF9FF) +val onBackgroundLight = Color(0xFF1A1B20) +val surfaceLight = Color(0xFFFAF9FF) +val onSurfaceLight = Color(0xFF1A1B20) +val surfaceVariantLight = Color(0xFFE1E2EC) +val onSurfaceVariantLight = Color(0xFF44474F) +val outlineLight = Color(0xFF757780) +val outlineVariantLight = Color(0xFFC5C6D0) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFF2F3036) +val inverseOnSurfaceLight = Color(0xFFF1F0F7) +val inversePrimaryLight = Color(0xFFAEC6FF) + + +val primaryDark = Color(0xFFAEC6FF) +val onPrimaryDark = Color(0xFF132F60) +val primaryContainerDark = Color(0xFF2D4678) +val onPrimaryContainerDark = Color(0xFFD8E2FF) +val secondaryDark = Color(0xFFBFC6DC) +val onSecondaryDark = Color(0xFF293041) +val secondaryContainerDark = Color(0xFF3F4759) +val onSecondaryContainerDark = Color(0xFFDBE2F9) +val tertiaryDark = Color(0xFFDFBBDE) +val onTertiaryDark = Color(0xFF402843) +val tertiaryContainerDark = Color(0xFF583E5A) +val onTertiaryContainerDark = Color(0xFFFCD7FB) +val errorDark = Color(0xFFFFB4AB) +val onErrorDark = Color(0xFF690005) +val errorContainerDark = Color(0xFF93000A) +val onErrorContainerDark = Color(0xFFFFDAD6) +val backgroundDark = Color(0xFF121318) +val onBackgroundDark = Color(0xFFE2E2E9) +val surfaceDark = Color(0xFF121318) +val onSurfaceDark = Color(0xFFE2E2E9) +val surfaceVariantDark = Color(0xFF44474F) +val onSurfaceVariantDark = Color(0xFFC5C6D0) +val outlineDark = Color(0xFF8E9099) +val outlineVariantDark = Color(0xFF44474F) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFE2E2E9) +val inverseOnSurfaceDark = Color(0xFF2F3036) +val inversePrimaryDark = Color(0xFF455E91) + +val Red = Color(0xFFD53A2F) +val BlueText = Color(0xFF455E91) +val Green = Color(0xFF1E9651) +val Orange = Color(0xFFFF9800) +val grey = Color(0xFFEBF5FF) +val buttonColor = Color(0xFF006782) + +val gradient1 = listOf(Color(0xFFad5389), Color(0xFF3c1053)) +val gradient2 = listOf(Color(0xFF3a6073), Color(0xFF16222a)) +val gradient3 = listOf(Color(0xFFF857a6), Color(0xFFff5858)) +val gradient4 = listOf(Color(0xFF00d2ff), Color(0xFF3a7bd5)) +val gradient5 = listOf(Color(0xFF99f2c8), Color(0xFF1f4037)) diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/ui/theme/Theme.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/ui/theme/Theme.kt new file mode 100644 index 00000000..4200de6f --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/ui/theme/Theme.kt @@ -0,0 +1,104 @@ +package com.example.studybuddy.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext + +private val LightColorScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, +) + +private val DarkColorScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, +) + + + +@Composable +fun StudyBuddyTheme( + 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 + ) +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/ui/theme/Type.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/ui/theme/Type.kt new file mode 100644 index 00000000..e28d17bc --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/ui/theme/Type.kt @@ -0,0 +1,133 @@ +package com.example.studybuddy.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.text.googlefonts.Font +import androidx.compose.ui.text.googlefonts.GoogleFont +import androidx.compose.ui.unit.sp +import com.example.studybuddy.R + +val provider = GoogleFont.Provider( + providerAuthority = "com.google.android.gms.fonts", + providerPackage = "com.google.android.gms", + certificates = R.array.com_google_android_gms_fonts_certs +) + +val ubuntuFontFamily = FontFamily( + Font( + googleFont = GoogleFont("Ubuntu"), + fontProvider = provider + ) +) + +val salsaFontFamily = FontFamily( + Font( + googleFont = GoogleFont("Salsa"), + fontProvider = provider + ) +) + +// Set of Material typography styles to start with +val Typography = Typography( + displayLarge = TextStyle( + fontFamily = salsaFontFamily, + fontWeight = FontWeight.W600, + fontSize = 57.sp, + lineHeight = 64.sp + ), + displayMedium = TextStyle( + fontFamily = salsaFontFamily, + fontWeight = FontWeight.W400, + fontSize = 45.sp, + lineHeight = 52.sp + ), + displaySmall = TextStyle( + fontFamily = salsaFontFamily, + fontWeight = FontWeight.W400, + fontSize = 36.sp, + lineHeight = 44.sp + ), + headlineLarge = TextStyle( + fontFamily = salsaFontFamily, + fontWeight = FontWeight.W600, + fontSize = 32.sp, + lineHeight = 40.sp + ), + headlineMedium = TextStyle( + fontFamily = salsaFontFamily, + fontWeight = FontWeight.W400, + fontSize = 28.sp, + lineHeight = 36.sp + ), + headlineSmall = TextStyle( + fontFamily = salsaFontFamily, + fontWeight = FontWeight.W400, + fontSize = 24.sp, + lineHeight = 32.sp + ), + titleLarge = TextStyle( + fontFamily = ubuntuFontFamily, + fontWeight = FontWeight.W500, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.5.sp + ), + titleMedium = TextStyle( + fontFamily = ubuntuFontFamily, + fontWeight = FontWeight.W500, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + titleSmall = TextStyle( + fontFamily = ubuntuFontFamily, + fontWeight = FontWeight.W500, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.5.sp + ), + bodyLarge = TextStyle( + fontFamily = ubuntuFontFamily, + fontWeight = FontWeight.W400, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + bodyMedium = TextStyle( + fontFamily = ubuntuFontFamily, + fontWeight = FontWeight.W400, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.5.sp + ), + bodySmall = TextStyle( + fontFamily = ubuntuFontFamily, + fontWeight = FontWeight.W400, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), + labelLarge = TextStyle( + fontFamily = ubuntuFontFamily, + fontWeight = FontWeight.W500, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.5.sp + ), + labelMedium = TextStyle( + fontFamily = ubuntuFontFamily, + fontWeight = FontWeight.W500, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), + labelSmall = TextStyle( + fontFamily = ubuntuFontFamily, + fontWeight = FontWeight.W500, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) +) \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/utils/ColorListConverter.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/utils/ColorListConverter.kt new file mode 100644 index 00000000..708c6cc9 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/utils/ColorListConverter.kt @@ -0,0 +1,17 @@ +package com.example.studybuddy.utils + +import androidx.room.TypeConverter + +class ColorListConverter { + + @TypeConverter + fun fromColorList(colorList: List): String { + return colorList.joinToString(","){it.toString()} + } + + @TypeConverter + fun toColorList(colorString: String): List { + return colorString.split(",").map { it.toInt() } + } + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/utils/Constants.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/utils/Constants.kt new file mode 100644 index 00000000..d3711f2f --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/utils/Constants.kt @@ -0,0 +1,5 @@ +package com.example.studybuddy.utils + +const val Chat_Data = "chats" +const val User_Node = "user" +const val MESSAGE ="message" \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/utils/Priority.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/utils/Priority.kt new file mode 100644 index 00000000..5450713d --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/utils/Priority.kt @@ -0,0 +1,78 @@ +package com.example.studybuddy.utils + +import android.util.Log +import androidx.compose.material3.SnackbarDuration +import com.example.studybuddy.ui.theme.Green +import com.example.studybuddy.ui.theme.Orange +import com.example.studybuddy.ui.theme.Red +import java.text.SimpleDateFormat +import java.time.Instant +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Date +import java.util.Locale + +enum class Priority(val title: String, val color:androidx.compose.ui.graphics.Color, val value: Int) { + LOW(title = "Low", color = Green, value = 1), + MEDIUM(title = "Medium", color = Orange, value = 2), + HIGH(title = "High", color = Red, value = 3); + + + companion object { + fun fromInt(value: Int) = values().firstOrNull { it.value == value }?:MEDIUM + } + + +} + + +fun Long?.changeMillisToDateString():String{ + val date:LocalDate = this?.let { + Instant + .ofEpochMilli(it) + .atZone(java.time.ZoneId.systemDefault()) + .toLocalDate() + + }?: LocalDate.now() + + return date.format(DateTimeFormatter.ofPattern("dd/MM/yyyy")) +} + +fun millisToTimeString(millis: Long?, pattern: String = "hh:mm a"): String { + return millis?.let { + val formatter = SimpleDateFormat(pattern, Locale.getDefault()) + formatter.format(Date(it)) + } ?: "No Time Set" +} + + + +fun Long.toHours():Float{ + val second = this.toFloat() + val hours = second.div(3600) + return "%.2f".format(hours).toFloat() +} + + + + +sealed class SnackbarEvent{ + data class ShowSnackbar( + val message:String, + val duration: SnackbarDuration = SnackbarDuration.Short + ):SnackbarEvent() + + data object NavigateUp:SnackbarEvent() + + fun Int.TimeToString():String{ + return this.toString().padStart(length = 2, padChar = '0') + } +} + +fun handleException(tag: String, message: String, exception: Exception?) { + // Log the error + Log.e(tag, message, exception) + + // Optionally, you can show a snackbar or toast + SnackbarEvent.ShowSnackbar("Error: ${exception?.localizedMessage ?: "Unknown error"}") +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/utils/ServiceConstants.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/utils/ServiceConstants.kt new file mode 100644 index 00000000..d89a3b51 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/utils/ServiceConstants.kt @@ -0,0 +1,14 @@ +package com.example.studybuddy.utils + +object ServiceConstants { + + const val Action_Service_Start = "ACTION_SERVICE_START" + const val Action_Service_Stop = "ACTION_SERVICE_STOP" + const val Action_Service_Cancel = "ACTION_SERVICE_CANCEL" + + const val NOTIFICATION_CHANNEL_ID = "TIMER_NOTIFICATION_ID" + const val NOTIFICATION_CHANNEL_NAME = "TIMER_NOTIFICATION" + const val NOTIFICATION_ID = 10 + + const val CLICK_REQUEST_CODE = 100 +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/FocusMode/FocusScreen.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/FocusMode/FocusScreen.kt new file mode 100644 index 00000000..2bde6d1e --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/FocusMode/FocusScreen.kt @@ -0,0 +1,198 @@ +package com.example.studybuddy.view.FocusMode + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.compose.ui.unit.sp +import com.example.studybuddy.R +import com.example.studybuddy.ui.theme.grey +import com.example.studybuddy.view.components.CustomToggleButton + + +@Composable +fun FocusScreen() { + val (blockYoutube, setBlockYoutube) = remember { mutableStateOf(false) } + val (blockInstagram, setBlockInstagram) = remember { mutableStateOf(false) } + val (blockWhatsapp, setBlockWhatsapp) = remember { mutableStateOf(false) } + + var showConfirmDialog by remember { mutableStateOf Unit>?>(null) } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { FocusTopBar() } + ) { paddingValues -> + Column( + modifier = Modifier.padding(paddingValues), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Do you want to block any social media app?", + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.W500) + ) + AppBlockCard( + appName = "YouTube", + isBlocked = blockYoutube, + onToggle = { + if (!blockYoutube) { // Show confirmation dialog only when toggling on + showConfirmDialog = "YouTube" to { setBlockYoutube(!blockYoutube) } + } else { + setBlockYoutube(!blockYoutube) // Directly toggle off + } + } + ) + AppBlockCard( + appName = "Instagram", + isBlocked = blockInstagram, + onToggle = { + if (!blockInstagram) { + showConfirmDialog = "Instagram" to { setBlockInstagram(!blockInstagram) } + } else { + setBlockInstagram(!blockInstagram) + } + } + ) + AppBlockCard( + appName = "WhatsApp", + isBlocked = blockWhatsapp, + onToggle = { + if (!blockWhatsapp) { + showConfirmDialog = "WhatsApp" to { setBlockWhatsapp(!blockWhatsapp) } + } else { + setBlockWhatsapp(!blockWhatsapp) + } + } + ) + } + + showConfirmDialog?.let { (appName, onConfirm) -> + ConfirmBlockDialog( + appName = appName, + onConfirm = { + onConfirm() + showConfirmDialog = null + }, + onDismiss = { showConfirmDialog = null } + ) + } + } +} + + +@Composable +fun AppBlockCard( + appName: String, + isBlocked: Boolean, + onToggle: () -> Unit +) { + Card( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(grey), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Image( + painter = when (appName) { + "YouTube" -> painterResource(R.drawable.youtube) + "Instagram" -> painterResource(R.drawable.instagram) + "WhatsApp" -> painterResource(R.drawable.whatsapp) + else -> painterResource(R.drawable.youtube)//todo change this + }, + contentDescription = "Focus", + modifier = Modifier.size(50.dp) + ) + Text( + text = appName, + modifier = Modifier + .padding(horizontal = 32.dp) + .align(Alignment.CenterVertically), + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + CustomToggleButton( + checked = isBlocked, + onCheckedClick = { onToggle() } + ) + } + } +} + + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FocusTopBar() { + CenterAlignedTopAppBar( + title = { + Text( + text = "Focus Mode", + style = MaterialTheme.typography.headlineMedium + ) + } + ) +} + +@Composable +fun ConfirmBlockDialog( + appName: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = "Block $appName?") }, + text = { Text(text = "Are you sure you want to block $appName?") }, + confirmButton = { + androidx.compose.material3.TextButton(onClick = onConfirm) { + Text("Confirm") + } + }, + dismissButton = { + androidx.compose.material3.TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + + + +@Preview +@Composable +fun FocusScreenPreview(){ + FocusScreen() +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/MainScreen.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/MainScreen.kt new file mode 100644 index 00000000..8ab89401 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/MainScreen.kt @@ -0,0 +1,131 @@ +package com.example.studybuddy.view + +import android.content.Intent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import com.example.studybuddy.R +import com.example.studybuddy.domain.model.BottomNavigationItem +import com.example.studybuddy.view.chat.ChatListScreen +import com.example.studybuddy.view.chat.ChatListViewModel +import com.example.studybuddy.view.dashboard.DashBoardScreenRoute +import com.example.studybuddy.view.videocall.HomeScreen +import com.example.studybuddy.view.videocall.HomeScreenRoute +import com.ramcosta.composedestinations.annotation.DeepLink +import com.ramcosta.composedestinations.navigation.DestinationsNavigator + +@com.ramcosta.composedestinations.annotation.Destination( + deepLinks = [ + DeepLink( + action = Intent.ACTION_VIEW, + uriPattern = "study_buddy://main" + ) + ] +) + +@Composable +fun MainScreenRoute( + navigator: DestinationsNavigator, + initialSelectedIndex: Int = 0 // Default to 0 (Dashboard) +) { + MainScreen( + modifier = Modifier, + navigator = navigator, + initialSelectedIndex = initialSelectedIndex + ) +} + +@Composable +fun MainScreen( + modifier: Modifier = Modifier, + navigator: DestinationsNavigator, + initialSelectedIndex: Int +) { + val navigationItems = listOf( + BottomNavigationItem( + title = "Dashboard", + selectedIcon = R.drawable.homefilled, + unselectedIcon = R.drawable.homeoutlined, + hasNews = false + ), + BottomNavigationItem( + title = "Chats", + selectedIcon = R.drawable.chatfilled, + unselectedIcon = R.drawable.chatoutlined, + hasNews = false + ), + BottomNavigationItem( + title = "Calls", + selectedIcon = R.drawable.videocallfill, + unselectedIcon = R.drawable.videocalloutline, + hasNews = false + ), +// BottomNavigationItem( +// title = "Focus", +// selectedIcon = R.drawable.focusfill, +// unselectedIcon = R.drawable.focus, +// hasNews = false +// ) + ) + + var selectedIndex by remember { mutableStateOf(initialSelectedIndex) } + + Scaffold( + bottomBar = { + BottomNavigationBar( + items = navigationItems, + selectedIndex = selectedIndex, + onItemSelected = { selectedIndex = it } + ) + } + ) { paddingValues -> + ContentScreen( + navigator = navigator, + modifier = Modifier.padding(paddingValues), + selectedIndex = selectedIndex + ) + } +} + + +@Composable +fun BottomNavigationBar(items: List, selectedIndex: Int, onItemSelected: (Int) -> Unit) { + NavigationBar { + items.forEachIndexed { index, item -> + NavigationBarItem( + selected = selectedIndex == index, + onClick = { onItemSelected(index) }, + icon = { + Icon( + painter = painterResource(id = if (selectedIndex == index) item.selectedIcon else item.unselectedIcon), + contentDescription = item.title + ) + }, + label = { Text(item.title) }, + alwaysShowLabel = true // Show label always or only when selected + ) + } + } +} + +@Composable +fun ContentScreen(navigator: DestinationsNavigator,modifier: Modifier = Modifier,selectedIndex: Int) +{ + when (selectedIndex) { + 0 -> DashBoardScreenRoute(navigator) + 1 -> ChatListScreen(navigator) + 2 -> HomeScreenRoute(navigator) +// 3 -> Text("Doubts Screen", modifier = modifier.fillMaxSize()) + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/auth/AuthViewModel.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/auth/AuthViewModel.kt new file mode 100644 index 00000000..c67428e8 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/auth/AuthViewModel.kt @@ -0,0 +1,157 @@ +package com.example.studybuddy.view.auth + +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.example.studybuddy.domain.model.UserData +import com.example.studybuddy.utils.User_Node +import com.example.studybuddy.utils.handleException +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.toObject + +class AuthViewModel : ViewModel() { + private val auth: FirebaseAuth = FirebaseAuth.getInstance() + private val firestore: FirebaseFirestore = FirebaseFirestore.getInstance() + + val _authState = MutableLiveData() + val authState:LiveData = _authState + var inProcess = mutableStateOf(false) + val userData = mutableStateOf(null) + + + init { + checkAuthStatus() + } + + fun checkAuthStatus(){ + if (auth.currentUser!=null) + { + _authState.value = AuthState.Authenticated + } + else{ + _authState.value=AuthState.Unauthenticated + } + } + + fun login(email:String, password:String) + { + if (email.isEmpty()||password.isEmpty()) + { + _authState.value = AuthState.Error("Please fill all the credentials before login") + return + } + _authState.value = AuthState.Loading + auth.signInWithEmailAndPassword(email,password) + .addOnCompleteListener {task -> + if (task.isSuccessful) + { + _authState.value = AuthState.Authenticated + }else{ + _authState.value = AuthState.Error(task.exception?.message?:"Something went wrong") + } + } + } + + fun signUp(email: String, password: String, name: String, premiumMember: Boolean,number: String?,expiry: Long?) { + if (email.isEmpty() || password.isEmpty() || name.isEmpty()) { + + _authState.value = AuthState.Error("Please fill all the credentials before signing up") + return + } + + inProcess.value=true + + firestore.collection(User_Node).whereEqualTo("number" , number).get().addOnSuccessListener { + if (it.isEmpty){ + auth.createUserWithEmailAndPassword(email, password) + .addOnCompleteListener { task -> + if (task.isSuccessful) { + createUser(name,number,premiumMember, expiry) + } else { + _authState.value = AuthState.Error(task.exception?.message ?: "Something went wrong") + } + } + } + else{ + handleException("AuthViewModel","Phone number already exist",null) + inProcess.value = false + } + } + + } + + + + fun createUser(name:String? = null, number:String? = null,premiumMember: Boolean? = null,expiry:Long? =null){ + var uid = auth.currentUser?.uid + val userData = UserData( + userId = uid, + name = name?: userData.value?.name, + number = number?: userData.value?.number, + PremiumMember = premiumMember?:userData.value?.PremiumMember, + expiry = expiry?: userData.value?.expiry + ) + uid?.let { + inProcess.value = true + + firestore.collection(User_Node).document(uid).get().addOnSuccessListener { + if (it.exists()){ + handleException("AuthViewModel","User already exist",null) + inProcess.value = false + getUserData(uid) + + } + + else{ + firestore.collection(User_Node).document(uid).set(userData) + inProcess.value = false + } + }.addOnFailureListener { + handleException("AuthViewModel","User can't retrieved",null) + } + + } + + + } + + private fun getUserData(uid:String) { + inProcess.value = true + firestore.collection(User_Node).document(uid).addSnapshotListener{ + value, error-> + if (error!=null) + { + handleException("AuthViewModel","Something went wrong",error) + } + if (value != null){ + var user = value.toObject() + userData.value = user + inProcess.value = false + } + } + } + + fun logout(){ + auth.signOut() + _authState.value = AuthState.Unauthenticated + } + + fun checkSubscriptionStatus(onResult: (Boolean) -> Unit) { + + } + + + +} + + +sealed class AuthState{ + data object Authenticated: AuthState() + data object Unauthenticated: AuthState() + data object Loading: AuthState() + + data object RegisterSuccess: AuthState() + data class Error(val message: String): AuthState() +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/auth/SignupScreen.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/auth/SignupScreen.kt new file mode 100644 index 00000000..966382b2 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/auth/SignupScreen.kt @@ -0,0 +1,349 @@ +package com.example.studybuddy.view.auth + +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +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.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.example.studybuddy.R +import com.example.studybuddy.ui.theme.BlueText +import com.example.studybuddy.ui.theme.buttonColor +import com.example.studybuddy.ui.theme.grey +import com.example.studybuddy.view.destinations.LoginScreenRouteDestination +import com.ramcosta.composedestinations.annotation.DeepLink +import com.ramcosta.composedestinations.navigation.DestinationsNavigator + +@com.ramcosta.composedestinations.annotation.Destination( + deepLinks = [ + DeepLink(action = Intent.ACTION_VIEW, + uriPattern = "study_buddy://signup") + ] +) +@Composable +fun SignupScreenRoute( + navigator: DestinationsNavigator, +) { + val viewModel: AuthViewModel = hiltViewModel() + SignupScreen( + viewModel = viewModel, + onLoginClick = { navigator.navigateUp() }, + onSignupSuccess = { navigator.navigate(LoginScreenRouteDestination) } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SignupScreen( + viewModel: AuthViewModel, + onLoginClick: () -> Unit, + onSignupSuccess: () -> Unit +) { + var userName by remember { mutableStateOf("") } + var userPhone by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var confirmPassword by remember { mutableStateOf("") } + var userEmail by remember { mutableStateOf("") } + val authState by viewModel.authState.observeAsState() + val context = LocalContext.current + var passwordVisible by remember { mutableStateOf(false) } + var confirmPasswordVisible by remember { mutableStateOf(false) } + + LaunchedEffect(authState) { + when (authState) { + is AuthState.Loading -> { + // Show loading indicator + } + is AuthState.Error -> { + val message = (authState as AuthState.Error).message + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + is AuthState.Authenticated -> { + onSignupSuccess() + } + else -> Unit + } + } + + Scaffold { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 12.dp) + ) { + item { + Image( + painter = painterResource(id = R.drawable.signup), + contentDescription = "Signup Image", + modifier = Modifier + .padding(top = 24.dp) + .fillMaxWidth() + .size(250.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Box(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Sign up", + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.align(Alignment.Center) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Column(modifier = Modifier.padding(horizontal = 25.dp)) { + Text( + text = "Name", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.W500) + ) + + Spacer(modifier = Modifier.height(5.dp)) + + TextField( + value = userName, + onValueChange = { userName = it }, + shape = RoundedCornerShape(20.dp), + modifier = Modifier.fillMaxWidth(), + colors = TextFieldDefaults.textFieldColors( + containerColor = grey, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent + ), + singleLine = true, + placeholder = { + Text("Enter your Name") + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Mobile Number", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.W500) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + TextField( + value = userPhone, + onValueChange = { + userPhone = it}, + shape = RoundedCornerShape(20.dp), + modifier = Modifier.fillMaxWidth(), + colors = TextFieldDefaults.textFieldColors( + containerColor = grey, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent + ), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + placeholder = { + Text("Enter your Phone Number") + } + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Email", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.W500) + ) + Spacer(modifier = Modifier.height(8.dp)) + + TextField( + value = userEmail, + onValueChange = { userEmail = it }, + shape = RoundedCornerShape(20.dp), + modifier = Modifier.fillMaxWidth(), + colors = TextFieldDefaults.textFieldColors( + containerColor = grey, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent + ), + singleLine = true, + placeholder = { + Text("Enter your Email") + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Password", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.W500) + ) + Spacer(modifier = Modifier.height(8.dp)) + TextField( + value = password, + onValueChange = { password = it }, + shape = RoundedCornerShape(20.dp), + modifier = Modifier.fillMaxWidth(), + colors = TextFieldDefaults.textFieldColors( + containerColor = grey, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent + ), + singleLine = true, + placeholder = { + Text("Enter your password") + }, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + val icon = if (passwordVisible) + painterResource(id = R.drawable.baseline_visibility_24) + else + painterResource(id = R.drawable.baseline_visibility_off_24) + + IconButton(onClick = { + passwordVisible = !passwordVisible + }) { + Image( + painter = icon, + contentDescription = if (passwordVisible) "Hide password" else "Show password" + ) + } + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Confirm Password", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.W500) + ) + Spacer(modifier = Modifier.height(8.dp)) + + TextField( + value = confirmPassword, + onValueChange = { confirmPassword = it }, + shape = RoundedCornerShape(20.dp), + modifier = Modifier.fillMaxWidth(), + colors = TextFieldDefaults.textFieldColors( + containerColor = grey, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent + ), + singleLine = true, + placeholder = { + Text("Re-enter your password") + }, + visualTransformation = if (confirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + val icon = if (confirmPasswordVisible) + painterResource(id = R.drawable.baseline_visibility_24) + else + painterResource(id = R.drawable.baseline_visibility_off_24) + + IconButton(onClick = { + confirmPasswordVisible = !confirmPasswordVisible + }) { + Image( + painter = icon, + contentDescription = if (confirmPasswordVisible) "Hide password" else "Show password" + ) + } + } + ) + + + Spacer(modifier = Modifier.height(15.dp)) + + Button( + onClick = { + if (userEmail.isNotBlank() && isValidEmail(userEmail) && + password.isNotBlank() && confirmPassword.isNotBlank() && + password == confirmPassword && userName.isNotBlank() && userPhone.isNotBlank() + ) { + viewModel.signUp( + name = userName, + number = userPhone, + premiumMember = false, + expiry = 0, + email = userEmail, + password = password + ) + userName = "" + userEmail = "" + password = "" + confirmPassword = "" + userPhone = "" + } else { + if (password != confirmPassword) { + viewModel._authState.value = AuthState.Error("Passwords do not match") + } else if (!isValidEmail(userEmail)) { + viewModel._authState.value = AuthState.Error("Please fill a valid email") + } else { + viewModel._authState.value = AuthState.Error("Please fill all fields correctly") + } + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 48.dp, vertical = 20.dp), + colors = ButtonDefaults.buttonColors(buttonColor), + enabled = viewModel._authState.value != AuthState.Loading + ) { + Text(text = "Sign up") + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row(modifier = Modifier.align(Alignment.CenterHorizontally)) { + Text( + text = "Have an account?", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Log in", + style = MaterialTheme.typography.bodyMedium.copy(color = BlueText), + modifier = Modifier.clickable { onLoginClick() } + ) + } + + Spacer(modifier = Modifier.height(30.dp)) + } + } + } + } +} diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/auth/loginScreen.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/auth/loginScreen.kt new file mode 100644 index 00000000..46d63f8b --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/auth/loginScreen.kt @@ -0,0 +1,254 @@ +package com.example.studybuddy.view.auth + +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +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.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.example.studybuddy.R +import com.example.studybuddy.ui.theme.BlueText +import com.example.studybuddy.ui.theme.buttonColor +import com.example.studybuddy.ui.theme.grey +import com.example.studybuddy.view.destinations.MainScreenRouteDestination +import com.example.studybuddy.view.destinations.SignupScreenRouteDestination +import com.ramcosta.composedestinations.annotation.DeepLink +import com.ramcosta.composedestinations.navigation.DestinationsNavigator + +@com.ramcosta.composedestinations.annotation.Destination( + deepLinks = [ + DeepLink( + action = Intent.ACTION_VIEW, + uriPattern = "study_buddy://login") + ] +) +@Composable +fun LoginScreenRoute( + navigator: DestinationsNavigator, + +) { + val viewModel: AuthViewModel = hiltViewModel() + val authState by viewModel.authState.observeAsState() + + + LoginScreen( + viewModel = viewModel, + onLoginSuccess = { + navigator.navigate(MainScreenRouteDestination(0)) + }, + signupisclicked = { + navigator.navigate(SignupScreenRouteDestination) + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoginScreen( + viewModel: AuthViewModel, + onLoginSuccess: () -> Unit, + signupisclicked: () -> Unit = {} + +) { + var password by remember { + mutableStateOf("") + } + var userEmail by remember { + mutableStateOf("") + } + var passwordVisible by remember { mutableStateOf(false) } +val authState by viewModel.authState.observeAsState() + val context = LocalContext.current + + LaunchedEffect(authState) { + when (authState) { + + is AuthState.Error -> { + val message = (authState as AuthState.Error).message + Toast.makeText(context,message, Toast.LENGTH_SHORT).show() + } + is AuthState.Authenticated -> { + onLoginSuccess() + } + + else -> Unit + } + } + + Scaffold { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 12.dp) + ) { + Image( + painter = painterResource(id = R.drawable.boyimg), + contentDescription = "Login Image", + modifier = Modifier + .padding(top = 24.dp) + .fillMaxWidth() + .size(250.dp) + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = "Log in", + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Column(modifier = Modifier.padding(25.dp)) { + Text( + text = "Email", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.W500) + ) + Spacer(modifier = Modifier.height(5.dp)) + + TextField( + value = userEmail, + onValueChange = { userEmail = it }, + shape = RoundedCornerShape(20.dp), + modifier = Modifier.fillMaxWidth(), + colors = TextFieldDefaults.textFieldColors( + containerColor = grey, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent + ), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = "Password", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.W500) + ) + + Spacer(modifier = Modifier.height(5.dp)) + + TextField( + value = password, + onValueChange = { password = it }, + shape = RoundedCornerShape(20.dp), + modifier = Modifier.fillMaxWidth(), + colors = TextFieldDefaults.textFieldColors( + containerColor = grey, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent + ), + singleLine = true, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + val icon = if (passwordVisible) + painterResource(id = R.drawable.baseline_visibility_24) + else + painterResource(id = R.drawable.baseline_visibility_off_24) + + IconButton(onClick = { + passwordVisible = !passwordVisible + }) { + Image( + painter = icon, + contentDescription = if (passwordVisible) "Hide password" else "Show password" + ) + } + } + ) + Spacer(modifier = Modifier.height(20.dp)) + + Button( + onClick = { + if (userEmail.isNotBlank() && password.isNotBlank()&& isValidEmail(userEmail)) { + viewModel.login( + email = userEmail, + password = password + ) + } else { + if (isValidEmail(userEmail)==false) + { + viewModel._authState.value = AuthState.Error("Please fill valid email") + + } + else{ + viewModel._authState.value = AuthState.Error("Please enter both email and password") + } + + } + }, + enabled = viewModel._authState.value != AuthState.Loading , + + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 48.dp, vertical = 20.dp), + colors = ButtonDefaults.buttonColors( + containerColor = buttonColor // Set the custom color here + ) + ) { + Text(text = "Log in") + } + + Spacer(modifier = Modifier.height(50.dp)) + + Row(modifier = Modifier.align(Alignment.CenterHorizontally)) { + Text( + text = "No Account?", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Sign up", + style = MaterialTheme.typography.bodyMedium.copy(color = BlueText), + modifier = Modifier.clickable { signupisclicked()} + ) + } + + + } + } + } +} + +fun isValidEmail(email: String): Boolean { + val emailRegex = "^[A-Za-z0-9+_.-]+@(.+)$".toRegex() + return email.matches(emailRegex) +} + + diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/chat/ChatActivity.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/chat/ChatActivity.kt new file mode 100644 index 00000000..0dafb1ca --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/chat/ChatActivity.kt @@ -0,0 +1,298 @@ +package com.example.studybuddy.view.chat + +import android.util.Log +import androidx.activity.compose.BackHandler +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Send +import androidx.compose.material3.ExperimentalMaterial3Api +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.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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 androidx.hilt.navigation.compose.hiltViewModel +import com.example.studybuddy.domain.model.Message +import com.example.studybuddy.ui.theme.buttonColor +import com.example.studybuddy.ui.theme.grey +import com.example.studybuddy.view.destinations.ChatScreenRouteDestination +import com.example.studybuddy.view.destinations.MainScreenRouteDestination +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Destination +@Composable +fun ChatScreenRoute( + chatId: String, + navigator: DestinationsNavigator +) { + val viewModel: ChatListViewModel = hiltViewModel() + ChatScreen( + chatId = chatId, + onSendMessage = { messageText -> + viewModel.onSendMessage(chatId, messageText) + }, + onBackClick = { navigator.navigate(MainScreenRouteDestination(1)) { + popUpTo(ChatScreenRouteDestination) { inclusive = true } + viewModel.depopulateMessage() + }} + ) +} + +@Composable +fun ChatScreen( + chatId: String, + onSendMessage: (String) -> Unit, + onBackClick: () -> Unit = {} +) { + val vm: ChatListViewModel = hiltViewModel() + val myUser = vm.userData.value + val currentChat = vm.chats.value.firstOrNull { it.chatId == chatId } + val chatUser = if (myUser?.userId == currentChat?.user1?.userId) currentChat?.user2 else currentChat?.user1 + val messages = vm.chatMessage.value + + // Group messages by date + val groupedMessages = messages.groupBy { getDateFromDateTime(it.time) } + + LaunchedEffect(key1 = chatId) { + vm.getMessages(chatId) + } + + BackHandler { + vm.depopulateMessage() + onBackClick() + } + + Scaffold( + topBar = { + ChatScreenTopBar( + chatPartnerName = chatUser?.name ?: "", + onBackClick = onBackClick + ) + }, + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.systemBars) + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .imePadding() // Adjust the entire column when the keyboard appears + ) { + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + groupedMessages.toList().reversed().forEach { (date, messagesForDate) -> + item { + DateHeader(date) + } + + items(messagesForDate) { message -> + MessageItem( + message = message, + isUserMe = message.userId == myUser?.userId, + time = convertTimeTo12HourFormat(message.time) + ) + } + } + } + MessageInput( + onSendMessage = { text -> + onSendMessage(text) + }, + modifier = Modifier + .navigationBarsPadding() // Handle navigation bar padding + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChatScreenTopBar( + chatPartnerName: String, + onBackClick: () -> Unit = {} +) { + TopAppBar( + navigationIcon = { + IconButton(onClick = { onBackClick() }) { + Icon(imageVector = Icons.Default.ArrowBack, contentDescription = "Navigate Back") + } + }, + title = { Text(text = chatPartnerName) } + ) +} + +@Composable +fun MessageItem(message: Message, isUserMe: Boolean, time: String) { + Row( + horizontalArrangement = if (isUserMe) Arrangement.End else Arrangement.Start, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Box( + modifier = Modifier + .background( + if (isUserMe) buttonColor else Color.LightGray, + shape = RoundedCornerShape(8.dp) + ) + .padding(8.dp) + .widthIn(max = 250.dp) + ) { + Column { + // Message text + Text( + text = message.message, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp, fontWeight = FontWeight.W500), + color = if (isUserMe) Color.White else Color.Black, + modifier = Modifier.padding(bottom = 4.dp) // Space between message and time + ) + + + // Time text aligned to the bottom end of the box + Text( + text = time, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 10.sp), + color = if (isUserMe) Color.White.copy(alpha = 0.7f) else Color.Black.copy(alpha = 0.7f), + modifier = Modifier.align(Alignment.End) + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MessageInput(onSendMessage: (String) -> Unit, modifier: Modifier = Modifier) { + var text by remember { mutableStateOf("") } + + Row( + modifier = modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + value = text, + onValueChange = { text = it }, + placeholder = { Text("Type a message") }, + modifier = Modifier.weight(1f), + colors = TextFieldDefaults.textFieldColors( + containerColor = grey, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent + ), + shape = RoundedCornerShape(20.dp), + trailingIcon = { + IconButton(onClick = { + if (text.isNotEmpty()) { + onSendMessage(text) + text = "" + } + }) { + Icon(Icons.Default.Send, contentDescription = "Send", tint = buttonColor, modifier = Modifier.size(32.dp)) + } + } + ) + } +} + +@Composable +fun DateHeader(date: String) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = date, + style = MaterialTheme.typography.bodySmall, + color = Color.Black, + maxLines = 1, + modifier = Modifier + .background(grey, shape = RoundedCornerShape(16.dp)) + .padding(vertical = 8.dp, horizontal = 16.dp) + .wrapContentSize() + ) + } +} + + +// Function to extract just the date (e.g., "Aug 15, 2024") from the dateTime string +fun getDateFromDateTime(dateTime: String): String { + val inputFormat = SimpleDateFormat("EEE MMM dd HH:mm:ss z yyyy", Locale.ENGLISH) + val outputFormat = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) + + return try { + val date: Date = inputFormat.parse(dateTime) ?: return "" + outputFormat.format(date) + } catch (e: Exception) { + Log.e("DateConversion", "Error parsing date: ${e.message}") + "" + } +} + +// Function to convert time from 24-hour format to 12-hour format (e.g., "10:30 AM") +fun convertTimeTo12HourFormat(dateTime: String): String { + val inputFormat = SimpleDateFormat("EEE MMM dd HH:mm:ss z yyyy", Locale.ENGLISH) + val outputFormat = SimpleDateFormat("hh:mm a", Locale.getDefault()) + + return try { + val date: Date = inputFormat.parse(dateTime) ?: return "" + outputFormat.format(date) + } catch (e: Exception) { + Log.e("DateConversion", "Error parsing time: ${e.message}") + "" + } +} + + +// TODO: if possible fix the message issue message goes up on keyboard open + diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/chat/ChatListViewModel.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/chat/ChatListViewModel.kt new file mode 100644 index 00000000..2b1d2adf --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/chat/ChatListViewModel.kt @@ -0,0 +1,189 @@ +package com.example.studybuddy.view.chat + +import android.icu.util.Calendar +import android.util.Log +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import com.example.studybuddy.domain.model.ChatData +import com.example.studybuddy.domain.model.ChatUser +import com.example.studybuddy.domain.model.Message +import com.example.studybuddy.domain.model.UserData +import com.example.studybuddy.utils.Chat_Data +import com.example.studybuddy.utils.MESSAGE +import com.example.studybuddy.utils.User_Node +import com.example.studybuddy.utils.handleException +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.Filter +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.ListenerRegistration +import com.google.firebase.firestore.toObject +import com.google.firebase.firestore.toObjects +import com.google.firebase.firestore.util.Listener + +class ChatListViewModel : ViewModel() { + + private val auth: FirebaseAuth = FirebaseAuth.getInstance() + private val firestore: FirebaseFirestore = FirebaseFirestore.getInstance() + + val userData = mutableStateOf(null) + val isLoading = mutableStateOf(false) + val chats = mutableStateOf>(listOf()) + val chatMessage = mutableStateOf>(listOf()) + val inProgressChat = mutableStateOf(false) + var currentChatMessageListener: ListenerRegistration? = null + + init { + Log.d("ChatListViewModel", "Initializing ViewModel") + fetchUserData() + } + + private fun fetchUserData() { + Log.d("ChatListViewModel", "Fetching user data") + val uid = auth.currentUser?.uid + if (uid != null) { + isLoading.value = true + firestore.collection(User_Node).document(uid).addSnapshotListener { value, error -> + if (error != null) { + Log.d("ChatListViewModel", "Error fetching user data: ${error.message}") + handleException("ChatListViewModel", "Something went wrong", error) + } + if (value != null) { + Log.d("ChatListViewModel", "User data fetched successfully") + userData.value = value.toObject() + isLoading.value = false + populateChats() + } + } + } else { + Log.d("ChatListViewModel", "User is not signed in") + handleException("ChatListViewModel", "User is not signed in", null) + } + } + + fun populateChats() { + Log.d("ChatListViewModel", "Populating chats") + val userId = userData.value?.userId ?: return + isLoading.value = true + firestore.collection(Chat_Data).where( + Filter.or( + Filter.equalTo("user1.userId", userId), + Filter.equalTo("user2.userId", userId) + ) + ).addSnapshotListener { value, error -> + if (error != null) { + Log.d("ChatListViewModel", "Error fetching chats: ${error.message}") + handleException("ChatListViewModel", "Something went wrong", error) + } + if (value != null) { + Log.d("ChatListViewModel", "Chats populated successfully") + chats.value = value.documents.mapNotNull { + it.toObject() + } + isLoading.value = false + } else { + Log.d("ChatListViewModel", "No chats found") + handleException("ChatListViewModel", "Something went wrong", error) + isLoading.value = false + } + } + } + + fun onAddChat(number: String) { + Log.d("ChatListViewModel", "Adding chat for number: $number") + val userId = userData.value?.userId ?: return + firestore.collection(Chat_Data).where( + Filter.or( + Filter.and( + Filter.equalTo("user1.phone", number), + Filter.equalTo("user2.phone", userData.value?.number) + ), + Filter.and( + Filter.equalTo("user1.phone", userData.value?.number), + Filter.equalTo("user2.phone", number) + ) + ) + ).get().addOnSuccessListener { querySnapshot -> + if (querySnapshot.isEmpty) { + Log.d("ChatListViewModel", "No existing chat found, creating new chat") + firestore.collection(User_Node).whereEqualTo("number", number).get() + .addOnSuccessListener { userSnapshot -> + if (userSnapshot.isEmpty) { + Log.d("ChatListViewModel", "Number not found") + handleException("ChatListViewModel", "Number not found", null) + } else { + val chatPartner = userSnapshot.toObjects()[0] + val id = firestore.collection(Chat_Data).document().id + val chat = ChatData( + chatId = id, + ChatUser( + userId, + userData.value?.name, + userData.value?.number + ), + ChatUser( + chatPartner.userId, + chatPartner.name, + chatPartner.number + ) + ) + firestore.collection(Chat_Data).document(id).set(chat) + Log.d("ChatListViewModel", "Chat created with partner ${chatPartner.number}") + } + } + .addOnFailureListener { + Log.d("ChatListViewModel", "Error fetching user data: ${it.message}") + } + } else { + Log.d("ChatListViewModel", "Chat already exists") + handleException("ChatListViewModel", "Chat already exists", null) + } + } + .addOnFailureListener { + Log.d("ChatListViewModel", "Error checking existing chats: ${it.message}") + } + } + + fun getMessages(chatId: String) { + Log.d("ChatListViewModel", "Getting messages for chatId: $chatId") + inProgressChat.value = true + currentChatMessageListener = firestore.collection(Chat_Data).document(chatId).collection( + MESSAGE).addSnapshotListener { value, error -> + if (error != null) { + Log.d("ChatListViewModel", "Error fetching messages: ${error.message}") + handleException("ChatListViewModel", "Something went wrong", error) + } + if (value != null) { + Log.d("ChatListViewModel", "Messages fetched successfully") + chatMessage.value = value.documents.mapNotNull { + it.toObject() + }.sortedBy { it.time } + inProgressChat.value = false + } + } + } + + fun depopulateMessage() { + Log.d("ChatListViewModel", "Depopulating messages") + chatMessage.value = listOf() + currentChatMessageListener?.remove() + currentChatMessageListener = null + } + + fun onSendMessage(chatId: String, message: String) { + Log.d("ChatListViewModel", "Sending message to chatId: $chatId") + val time = Calendar.getInstance().time.toString() + val msg = Message(userId = userData.value?.userId ?: return, time = time, message = message) + firestore.collection(Chat_Data) + .document(chatId) + .collection(MESSAGE) + .document() + .set(msg) + .addOnSuccessListener { + Log.d("ChatListViewModel", "Message sent successfully") + } + .addOnFailureListener { + Log.d("ChatListViewModel", "Failed to send message: ${it.message}") + } + } +} + diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/chat/chatListScreen.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/chat/chatListScreen.kt new file mode 100644 index 00000000..797dcc5f --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/chat/chatListScreen.kt @@ -0,0 +1,359 @@ +package com.example.studybuddy.view.chat + +import android.content.Context +import android.content.Intent +import android.util.Log +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.focusModifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.core.text.isDigitsOnly +import androidx.hilt.navigation.compose.hiltViewModel +import com.example.studybuddy.R +import com.example.studybuddy.domain.model.ChatData +import com.example.studybuddy.domain.model.UserData +import com.example.studybuddy.ui.theme.buttonColor +import com.example.studybuddy.ui.theme.grey +import com.example.studybuddy.view.components.CircularProgressBar +import com.example.studybuddy.view.components.MembershipPurchaseScreen +import com.example.studybuddy.view.destinations.ChatScreenRouteDestination +import com.ramcosta.composedestinations.annotation.DeepLink +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import kotlin.random.Random + + +@Destination( + deepLinks = [ + DeepLink(action = Intent.ACTION_VIEW, uriPattern = "study_buddy://chat_list") +] +) + + +@Composable +fun ChatListScreen(navigator: DestinationsNavigator) { + val vm: ChatListViewModel = hiltViewModel() + var isSubscribed by remember { mutableStateOf(true) } + val inProgress = vm.isLoading + val showDialog = remember { mutableStateOf(false) } + val context: Context = LocalContext.current + val userData = vm.userData.value + + Log.d("ChatListScreen", "userData: $userData") + Log.d("ChatListScreen", "current time: ${System.currentTimeMillis()}") + + LaunchedEffect(Unit) { + userData?.let { + if (it.PremiumMember == true && it.expiry!! > System.currentTimeMillis()) { + isSubscribed = true + Log.d("ChatListScreen", "User is subscribed: $userData") + } else { + isSubscribed = false + Log.d("ChatListScreen", "User is not subscribed or subscription expired: $userData") + } + } + } + + if (inProgress.value) { + CircularProgressBar() + } else { + ChatScreenContent( + isSubscribed = true,//isSubscribed, + chats = vm.chats.value, + showDialog = showDialog.value, + onFabClick = { showDialog.value = true }, + onDismiss = { showDialog.value = false }, + onAddChat = { + vm.onAddChat(it) + showDialog.value = false + Toast.makeText(context, "Connection made request", Toast.LENGTH_SHORT).show() + }, + context = context, + userData = userData!!, + navigator = navigator // Pass navigator to ChatScreenContent + ) + } +} + + +@Composable +fun ChatScreenContent( + isSubscribed: Boolean, + chats: List, + showDialog: Boolean, + onFabClick: () -> Unit, + onDismiss: () -> Unit, + onAddChat: (String) -> Unit, + context: Context, + userData: UserData, + navigator: DestinationsNavigator // Add navigator parameter +) { + if (isSubscribed) { + Scaffold( + topBar = { ChatTopBar() }, + floatingActionButton = { + FAB( + showDialog = showDialog, + onFabClick = onFabClick, + onDismiss = onDismiss, + onAddChat = onAddChat, + context = context, + modifier = Modifier.offset(y = (-70.dp)) + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + if (chats.isEmpty()) { + Column( + modifier = Modifier + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text(text = "No Chats Available") + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(10.dp) + ) { + items(chats){ + chat-> + val chatUser = + if (chat.user1.userId ==userData.userId ) + { + chat.user2 + } + else{ + chat.user1 + } + + CommonRow( + name = chatUser.name, + navigator = navigator, // Pass navigator + chatId = chat.chatId, // Pass chatId + onItemClicked = { + + } + ) + + } + } + } + } + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + MembershipPurchaseScreen() + } + } +} + +@Composable +fun CommonRow( + name: String?, + navigator: DestinationsNavigator, + chatId: String?, + onItemClicked: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp) + .clickable { + onItemClicked.invoke() + if (chatId != null) { + navigator.navigate(ChatScreenRouteDestination(chatId)) + + } + }, + elevation = CardDefaults.cardElevation(4.dp), + colors = CardDefaults.cardColors(Color.White), + shape = RoundedCornerShape(20.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(60.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val images = listOf( + R.drawable.user1, + R.drawable.user2, + R.drawable.user4, + R.drawable.user5, + R.drawable.user3, + R.drawable.user6, + R.drawable.user7, + R.drawable.user8, + R.drawable.user + ) + + // Select a random image + val randomImage = images[Random.nextInt(images.size)] + + Image( + modifier = Modifier + .padding(8.dp) + .size(42.dp) + .clip(CircleShape), + painter = painterResource(id = randomImage), + contentDescription = "User Image" + ) + Spacer(modifier = Modifier.width(70.dp)) + + Text( + text = name ?: ".....", + modifier = Modifier.padding(8.dp), + fontWeight = FontWeight.Bold + ) + } + } +} + + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatTopBar() { + CenterAlignedTopAppBar( + title = { + Text( + text = "Chats", + style = MaterialTheme.typography.headlineMedium + ) + } + ) +} + + + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FAB( + showDialog: Boolean, + onFabClick: () -> Unit, + onDismiss: () -> Unit, + onAddChat: (String) -> Unit, + context: Context, + modifier: Modifier +) { + val addMember = remember { mutableStateOf("") } + + if (showDialog) { + AlertDialog( + onDismissRequest = { + onDismiss.invoke() + addMember.value = "" + }, + confirmButton = { + Button( + onClick = {if (addMember.value.isEmpty() or !addMember.value.isDigitsOnly()) + { + Toast.makeText(context, "Invalid Number", Toast.LENGTH_SHORT).show() + } + else{ + onAddChat(addMember.value) + addMember.value = "" + } + + }, + colors = ButtonDefaults.buttonColors(buttonColor), + ) { + Text(text = "Add Chat") + } + }, + title = { Text(text = "Add Chat") }, + text = { + OutlinedTextField( + value = addMember.value, + onValueChange = { addMember.value = it }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + shape = RoundedCornerShape(20.dp), + modifier = Modifier.fillMaxWidth(), + colors = TextFieldDefaults.textFieldColors( + containerColor = grey, + unfocusedIndicatorColor = grey.copy(alpha = 0.3f), + focusedIndicatorColor = grey.copy(alpha = 0.3f) + ), + singleLine = true + ) + } + ) + } else { + FloatingActionButton( + onClick = { onFabClick.invoke() }, + containerColor = buttonColor, + shape = CircleShape, + modifier = modifier + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = "Add Chat", + tint = Color.White + ) + } + } +} + + diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/AddSubject.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/AddSubject.kt new file mode 100644 index 00000000..628a9b82 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/AddSubject.kt @@ -0,0 +1,141 @@ +package com.example.studybuddy.view.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.runtime.Composable +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +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.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.example.studybuddy.domain.model.Subject + +@Composable +fun AddSubjectDialog( + isOpen: Boolean, + title:String = "Add/Update Subject", + selectedColor: List, + subjectName:String, + goalHours:String, + onColourChange:(List) ->Unit, + onSubjectNameChange:(String) ->Unit, + onGoalHoursChange:(String) ->Unit, + onDismiss: () -> Unit, + onConfirmBtnClick: () -> Unit +){ + var subjectNameError by rememberSaveable { mutableStateOf(null)} + var goalHoursError by rememberSaveable { mutableStateOf(null)} + + + subjectNameError = when{ + subjectName.isBlank()-> "Please enter subject name" + subjectName.length < 2 -> "Subject name must be at least 2 characters" + subjectName.length >20 -> "Subject name must be less than 20 characters" + + else -> null + } + + goalHoursError = when{ + goalHours.isBlank()-> "Please enter goal study hours" + goalHours.toFloatOrNull()==null -> "Please enter a valid number" + goalHours.toFloat()<=0f -> "Please enter a number greater than 0" + goalHours.toFloat()>1000f -> "Please enter a number less than 1000" + + else -> null + } + + if (isOpen){ + AlertDialog( + title = { Text(text=title) }, + onDismissRequest = onDismiss, + text = { + Column() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceAround + ) { + Subject.subjectCardColor.forEach { + colors -> + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .border( + width = 1.dp, + color = if (colors == selectedColor) Color.Black + else Color.Transparent, + shape = CircleShape + ) + .background(brush = Brush.verticalGradient(colors)) + .clickable { onColourChange(colors) } + + + ) + + } + + + + } + OutlinedTextField( + value =subjectName, + onValueChange =onSubjectNameChange, + label = { Text(text = "Subject Name")}, + singleLine = true, + isError = subjectNameError != null && subjectName.isNotBlank(), + supportingText = { Text(text = subjectNameError.orEmpty())} + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value =goalHours , + onValueChange =onGoalHoursChange, + label = { Text(text = "Goal Study Hours")}, + singleLine = true, + isError = goalHoursError != null && goalHours.isNotBlank(), + supportingText = { Text(text = goalHoursError.orEmpty())}, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) + } + + }, + confirmButton = { + TextButton(onClick = onConfirmBtnClick, + enabled = subjectNameError == null && goalHoursError == null) { + Text(text = "Save") + } + + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = "Close") + } + + } + + ) + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/ConfirmBackDialog.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/ConfirmBackDialog.kt new file mode 100644 index 00000000..aca9a8c4 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/ConfirmBackDialog.kt @@ -0,0 +1,53 @@ +package com.example.studybuddy.view.components + + +import androidx.activity.compose.BackHandler +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue + +@Composable +fun ConfirmBackDialog(onConfirmBack: () -> Unit) { + var showDialog by remember { mutableStateOf(false) } + + // Intercept back button press and show dialog + BackHandler(enabled = true) { + showDialog = true + } + + // Show confirmation dialog when needed + if (showDialog) { + AlertDialog( + onDismissRequest = { + // Dismiss the dialog when the user touches outside the dialog or presses the back button + showDialog = false + }, + title = { Text("Confirm Exit") }, + text = { Text("Are you sure you want to leave?") }, + confirmButton = { + Button( + onClick = { + showDialog = false + onConfirmBack() // Handle the confirmed back action + } + ) { + Text("Yes") + } + }, + dismissButton = { + Button( + onClick = { + showDialog = false // Simply close the dialog + } + ) { + Text("No") + } + } + ) + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/CountCards.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/CountCards.kt new file mode 100644 index 00000000..33ce8854 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/CountCards.kt @@ -0,0 +1,40 @@ +package com.example.studybuddy.view.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun CountCards(modifier: Modifier= Modifier, + headingText: String, + count: String){ + ElevatedCard(modifier=modifier) { + Column(modifier= Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 12.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = headingText, + style = MaterialTheme.typography.labelSmall + ) + Spacer(modifier = Modifier.height(5.dp)) + Text( + text = count, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 30.sp) + ) + } + + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/DatePicker.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/DatePicker.kt new file mode 100644 index 00000000..c4d05d4b --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/DatePicker.kt @@ -0,0 +1,57 @@ +package com.example.studybuddy.view.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DatePickerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TaskDatePicker( + state: DatePickerState, + isOpen: Boolean, + onDismissRequest: () -> Unit, + onConfirmRequest: () -> Unit, + confirmButtonText: String = "OK", + dismissButtonText: String = "Cancel" +) +{ + if (isOpen) { + + DatePickerDialog(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp), + onDismissRequest = { onDismissRequest() }, + confirmButton = { + TextButton(onClick = onConfirmRequest) { + Text(text = confirmButtonText) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = dismissButtonText) + } + }, + content = { + androidx.compose.material3.DatePicker( + state = state, + dateValidator = { + timestamp-> + val selectedDate = Instant.ofEpochMilli(timestamp) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + + val currentDate = LocalDate.now(ZoneId.systemDefault()) + selectedDate >= currentDate + }) + } + ) + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/DeleteDialog.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/DeleteDialog.kt new file mode 100644 index 00000000..a05ab67e --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/DeleteDialog.kt @@ -0,0 +1,38 @@ +package com.example.studybuddy.view.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable + +@Composable +fun DeleteDialog( + isOpen: Boolean, + title:String, + bodyText:String, + onDismiss: () -> Unit, + onConfirmBtnClick: () -> Unit +){ + + + if (isOpen){ + AlertDialog( + title = { Text(text=title) }, + onDismissRequest = onDismiss, + text = { + Text(text = bodyText) + }, + confirmButton = { + TextButton(onClick = onConfirmBtnClick){ Text(text = "Delete")} + + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = "Cancel") + } + + } + + ) + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/MembershipScreen.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/MembershipScreen.kt new file mode 100644 index 00000000..3db04d65 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/MembershipScreen.kt @@ -0,0 +1,128 @@ +package com.example.studybuddy.view.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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 com.example.studybuddy.ui.theme.buttonColor + +@Composable +fun MembershipPurchaseScreen() { + var selectedPlan by remember { mutableStateOf("Monthly") } + val buttonText = when (selectedPlan) { + "Monthly" -> "Pay ₹99" + "Quarterly" -> "Pay ₹249" + "Yearly" -> "Pay ₹799" + else -> "Purchase" + } + + Card( + modifier = Modifier + .wrapContentSize() + .padding(16.dp), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + colors = CardDefaults.cardColors(containerColor = Color.White) + ) { + Column( + modifier = Modifier + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Choose Your Plan", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 32.dp) + ) + + PlanOption( + title = "Monthly Plan", + price = "₹99/month", + isSelected = selectedPlan == "Monthly", + onClick = { selectedPlan = "Monthly" } + ) + Spacer(modifier = Modifier.height(16.dp)) + PlanOption( + title = "Quarterly Plan", + price = "₹83/month", + isSelected = selectedPlan == "Quarterly", + onClick = { selectedPlan = "Quarterly" } + ) + Spacer(modifier = Modifier.height(16.dp)) + PlanOption( + title = "Annual Plan", + price = "₹67/month", + isSelected = selectedPlan == "Yearly", + onClick = { selectedPlan = "Yearly" } + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = { /* Handle purchase */ }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 48.dp, vertical = 20.dp), + colors = ButtonDefaults.buttonColors( + containerColor = buttonColor // Set the custom color here + ) + ) { + Text(text = buttonText) + } + } + } +} + +@Composable +fun PlanOption( + title: String, + price: String, + isSelected: Boolean, + onClick: () -> Unit +) { + val borderColor = if (isSelected) buttonColor else Color.Transparent + + Card( + modifier = Modifier + .fillMaxWidth() + .height(80.dp) + .border( + width = 2.dp, + color = borderColor, + shape = RoundedCornerShape(10.dp) + ) + .clickable(onClick = onClick), + shape = RoundedCornerShape(10.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + colors = CardDefaults.cardColors(containerColor = Color.White) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = title, fontWeight = FontWeight.Bold, fontSize = 18.sp) + Text(text = price, fontSize = 16.sp) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun MembershipPurchaseScreenPreview() { + MembershipPurchaseScreen() +} diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/ProgressIndicator.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/ProgressIndicator.kt new file mode 100644 index 00000000..4bcac2c2 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/ProgressIndicator.kt @@ -0,0 +1,43 @@ +package com.example.studybuddy.view.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Card +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun Circle( +) +{ + Card( + modifier = Modifier + .size(40.dp) + .background(color = Color.Gray, shape = CircleShape) + ) { + + } +} + +@Composable +fun ShowProgress() +{ + Row(modifier = Modifier.fillMaxSize()) { +Circle() + } +} + + +@Preview +@Composable +fun ShowProgressPreview() +{ + ShowProgress() + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/StudySession.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/StudySession.kt new file mode 100644 index 00000000..2b683c6c --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/StudySession.kt @@ -0,0 +1,140 @@ +package com.example.studybuddy.view.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.example.studybuddy.R +import com.example.studybuddy.domain.model.Session +import com.example.studybuddy.utils.changeMillisToDateString + +fun LazyListScope.StudySessionList( + sectionTitle: String, + Sessions: List, + emptyListText: String, + onDeleteItemClick: (Session) -> Unit +) { + item { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = sectionTitle, + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall + ) + + Text(text = "See All", + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall) + + } + } + } + if (Sessions.isEmpty()) { + item { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + modifier = Modifier + .size(120.dp), + painter = painterResource(id = R.drawable.lamp), + contentDescription = emptyListText + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = emptyListText, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + textAlign = TextAlign.Center + ) + } + } + } + items(Sessions) { session -> + SessionCard( + session = session, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + onDeleteItemClick = { onDeleteItemClick(session) } + ) + } +} + +@Composable +private fun SessionCard( + modifier: Modifier = Modifier, + session: Session, + onDeleteItemClick: () -> Unit = {} +) { + val formattedDuration = formatDuration(session.duration) + + Card(modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = session.relatedToSubject, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = session.date.changeMillisToDateString(), + style = MaterialTheme.typography.bodySmall + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = formattedDuration, + style = MaterialTheme.typography.bodySmall + ) + } + IconButton(onClick = { onDeleteItemClick() }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete Session" + ) + } + } + } +} + +// Function to format duration in hours and minutes +fun formatDuration(durationSeconds: Long): String { + if (durationSeconds < 0) return "Invalid duration" + + val hours = durationSeconds / 3600 + val minutes = (durationSeconds % 3600) / 60 + val seconds = durationSeconds % 60 + + return String.format("%02dh %02dm %02ds", hours, minutes, seconds) +} + diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/SubjectCard.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/SubjectCard.kt new file mode 100644 index 00000000..f9f0baf0 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/SubjectCard.kt @@ -0,0 +1,48 @@ +package com.example.studybuddy.view.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.example.studybuddy.R + +@Composable +fun SubjectCard( + subjectName: String, + gradientColors:List, + onClick: () -> Unit +){ + Box(modifier = Modifier + .size(150.dp).clickable { onClick() } + .background(brush = Brush.verticalGradient(gradientColors), + shape = MaterialTheme.shapes.medium + ) + ) + { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.Center + ) { + Image(painter = painterResource(id = R.drawable.books), + contentDescription ="", + modifier = Modifier.size(80.dp)) + + Text(text = subjectName, + style = MaterialTheme.typography.headlineMedium, + color = Color.White, + maxLines = 1) + } + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/SubjectListDropDown.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/SubjectListDropDown.kt new file mode 100644 index 00000000..cda37a24 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/SubjectListDropDown.kt @@ -0,0 +1,79 @@ +package com.example.studybuddy.view.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.studybuddy.domain.model.Subject + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SubjectListDropDown( + sheetState:SheetState, + isOpen:Boolean, + subjects:List, + bottomSheetTitle:String = "Related to subject", + onSubjectClick:(Subject)->Unit, + onDismissRequest:()->Unit +) { + if (isOpen){ + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = onDismissRequest, + dragHandle = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + BottomSheetDefaults.DragHandle() + Text(text = bottomSheetTitle) + Spacer(modifier = Modifier.height(10.dp)) + Divider() + } + } + ) + + { + LazyColumn( + contentPadding = PaddingValues(16.dp) + ) + { + items(subjects) { + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { onSubjectClick(it) } + .padding(8.dp) + ) { + Text(text = it.name) + } + } + if (subjects.isEmpty()) { + item { + Text( + modifier = Modifier.fillMaxWidth().padding(10.dp), + text = "Ready to begin? First add a subject") + + } + } + + } + } + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/SurfaceViewRendrerComposable.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/SurfaceViewRendrerComposable.kt new file mode 100644 index 00000000..b5bd9260 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/SurfaceViewRendrerComposable.kt @@ -0,0 +1,59 @@ +package com.example.studybuddy.view.components + +import android.graphics.Color +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import org.webrtc.SurfaceViewRenderer + +@Composable +fun SurfaceViewRendererComposable( + modifier: Modifier, + streamName: String, + onSurfaceReady: (SurfaceViewRenderer) -> Unit +) { + AndroidView( + modifier = modifier, + factory = { context -> + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + setPadding(10, 10, 10, 10) // Set padding inside the border + setBackgroundColor(Color.WHITE) // Border color + + val titleView = TextView(context).apply { + text = streamName + textSize = 16f // Set the text size + setTextColor(Color.BLACK) // Set the text color + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + addView(titleView) + + val frameLayout = FrameLayout(context).apply { + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ).also { + it.setMargins(10, 10, 10, 10) // Margin for the border effect + } + setBackgroundResource(android.R.drawable.dialog_holo_light_frame) // Use a system resource as an example border + + addView(SurfaceViewRenderer(context).also { + onSurfaceReady.invoke(it) + }) + } + addView(frameLayout) + } + } + ) +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/TaskCheckbox.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/TaskCheckbox.kt new file mode 100644 index 00000000..a77c4d3a --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/TaskCheckbox.kt @@ -0,0 +1,40 @@ +package com.example.studybuddy.view.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +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.unit.dp + +@Composable +fun TaskCheckBox( + isCompleted: Boolean, + borderColor: Color, + onCheckBoxClicked: () -> Unit, +) +{ + Box ( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .border(2.dp, borderColor, CircleShape) + .clickable { onCheckBoxClicked() }, + contentAlignment = Alignment.Center + ){ + AnimatedVisibility(visible = isCompleted) { + Icon(modifier = Modifier.size(20.dp), + imageVector = Icons.Rounded.Check, + contentDescription =null ) + } + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/TaskList.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/TaskList.kt new file mode 100644 index 00000000..9d105dfe --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/TaskList.kt @@ -0,0 +1,137 @@ +package com.example.studybuddy.view.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.example.studybuddy.R +import com.example.studybuddy.domain.model.Task +import com.example.studybuddy.utils.Priority +import com.example.studybuddy.utils.changeMillisToDateString + +fun LazyListScope.TaskList( + sectionTitle:String, + Tasks:List, + emptyListText:String, + onCheckBoxClick:(Task)->Unit, + onTaskCardClick:(Int?)->Unit +){ + item { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = sectionTitle, + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall) + + Text(text = "See All", + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall) + + } + + + } + } + if (Tasks.isEmpty()){ + item { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + modifier = Modifier + .size(120.dp) , + painter = painterResource(id = R.drawable.todo), + contentDescription =emptyListText ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = emptyListText, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + textAlign = TextAlign.Center + ) + } + } + } + items(Tasks){task-> + TaskCard( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + task =task , + onCheckBoxClicked = { onCheckBoxClick(task) }, + onClick = {onTaskCardClick(task.TaskId)} + ) + + } + +} + + +@Composable +private fun TaskCard( + modifier: Modifier = Modifier, + task: Task, + onCheckBoxClicked: () -> Unit, + onClick: () -> Unit +) +{ + ElevatedCard(modifier = modifier.clickable { onClick() } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TaskCheckBox( + isCompleted = task.isCompleted, + borderColor = Priority.fromInt(task.priority).color, + onCheckBoxClicked = onCheckBoxClicked, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text(text = task.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + textDecoration = if (task.isCompleted) { + TextDecoration.LineThrough + } + else TextDecoration.None) + + Spacer(modifier = Modifier.height(4.dp)) + Text(text = task.dueDate.changeMillisToDateString(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray) + } + } + + } + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/loading.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/loading.kt new file mode 100644 index 00000000..7324bbd5 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/loading.kt @@ -0,0 +1,46 @@ +package com.example.studybuddy.view.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun CircularProgressBar() { + val infiniteTransition = rememberInfiniteTransition() + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ) + ) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .background(color = Color.White) + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + strokeWidth = 4.dp, + modifier = Modifier.rotate(rotation) // Apply rotation to the CircularProgressIndicator + ) + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/toggleButton.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/toggleButton.kt new file mode 100644 index 00000000..30aa9c8a --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/components/toggleButton.kt @@ -0,0 +1,78 @@ +package com.example.studybuddy.view.components + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.studybuddy.ui.theme.inversePrimaryDark +import com.example.studybuddy.ui.theme.onPrimaryContainerLight +import com.example.studybuddy.ui.theme.primaryLight + +@Composable +fun CustomToggleButton( + checked: Boolean, + onCheckedClick: (Boolean) -> Unit +) { + val toggleAlignment by animateDpAsState( + targetValue = if (checked) 32.dp else 0.dp, + animationSpec = tween(durationMillis = 200) + ) + // Accessing theme colors + val colorScheme = MaterialTheme.colorScheme + val toggleBackgroundColor = if (checked) colorScheme.primary else colorScheme.onSurface.copy(alpha = 0.5f) + val cardBackgroundColor = colorScheme.background + Card( + modifier = Modifier + .width(60.dp) + .clickable { onCheckedClick(!checked) }, + elevation = CardDefaults.cardElevation(4.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = cardBackgroundColor) + ) { + Box( + modifier = Modifier + .background(toggleBackgroundColor) + .fillMaxWidth() + .padding(4.dp), + contentAlignment = Alignment.CenterStart + ) { + CheckCard( + Modifier + .padding(start = toggleAlignment) + .size(24.dp) + ) + } + } +} + +@Composable +fun CheckCard( + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.size(24.dp), + elevation = CardDefaults.cardElevation(4.dp), + shape = CircleShape + ) { + Box(modifier = Modifier.background(color = Color.White)) + } +} + + diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/dashboard/DashboardEvent.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/dashboard/DashboardEvent.kt new file mode 100644 index 00000000..db2ea364 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/dashboard/DashboardEvent.kt @@ -0,0 +1,22 @@ +package com.example.studybuddy.view.dashboard + +import androidx.compose.ui.graphics.Color +import com.example.studybuddy.domain.model.Session +import com.example.studybuddy.domain.model.Task + +sealed class DashboardEvent { + + data object SaveSubject:DashboardEvent() + + data object DeleteSession:DashboardEvent() + + data class OnDeleteSessionButtonClicked(val session: Session):DashboardEvent() + + data class OnTaskIsCompleteChange(val task: Task):DashboardEvent() + + data class OnSubjectCardColorChange(val colors:List):DashboardEvent() + + data class OnSubjectNameChange(val name:String):DashboardEvent() + + data class OnGoalStudyHoursChange(val hours:String):DashboardEvent() +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/dashboard/DashboardScreen.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/dashboard/DashboardScreen.kt new file mode 100644 index 00000000..2eb20b9a --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/dashboard/DashboardScreen.kt @@ -0,0 +1,345 @@ +package com.example.studybuddy.view.dashboard + +import android.content.Intent +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material3.Button +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.studybuddy.R +import com.example.studybuddy.domain.model.BottomNavigationItem +import com.example.studybuddy.domain.model.Session +import com.example.studybuddy.domain.model.Subject +import com.example.studybuddy.domain.model.Task +import com.example.studybuddy.utils.SnackbarEvent +import com.example.studybuddy.view.components.AddSubjectDialog +import com.example.studybuddy.view.components.CountCards +import com.example.studybuddy.view.components.DeleteDialog +import com.example.studybuddy.view.components.StudySessionList +import com.example.studybuddy.view.components.SubjectCard +import com.example.studybuddy.view.components.TaskList +import com.example.studybuddy.view.destinations.SessionScreenRouteDestination +import com.example.studybuddy.view.destinations.SubjectScreenRouteDestination +import com.example.studybuddy.view.destinations.TaskScreenRouteDestination +import com.example.studybuddy.view.subject.SubjectScreenNavArgs +import com.example.studybuddy.view.task.TaskScreenNavArgs +import com.ramcosta.composedestinations.annotation.DeepLink +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.collectLatest + +@Destination( + deepLinks = [ + DeepLink(action = Intent.ACTION_VIEW, uriPattern = "study_buddy://dashboard") + ] +) +@Composable +fun DashBoardScreenRoute( + navigator: DestinationsNavigator +){ + val viewModel : DashboardViewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + val task by viewModel.tasks.collectAsStateWithLifecycle() + val session by viewModel.session.collectAsStateWithLifecycle() + + DashboardScreen( + state = state, + task = task, + session = session, + onEvent = viewModel::onEvent, + snackbarEvent = viewModel.snackbarEventFlow, + onTaskCardClick ={taskId -> + taskId?.let{ + val navArgs = TaskScreenNavArgs(taskId, subjectId = null) + navigator.navigate(TaskScreenRouteDestination(navArgs = navArgs)) + } + } , + onSubjectCardClick ={ + subjectId -> + subjectId?.let { + val navArgs = SubjectScreenNavArgs(subjectId) + navigator.navigate(SubjectScreenRouteDestination(navArgs)) + } + } , + onStartSessionButtonClick ={ + navigator.navigate(SessionScreenRouteDestination()) + } ) +} + + +@Composable +private fun DashboardScreen( + state: dashboardVariable, + task:List, + session:List, + onEvent: (DashboardEvent) -> Unit, + snackbarEvent: SharedFlow, + onTaskCardClick: (Int?) -> Unit, + onSubjectCardClick: (Int?) -> Unit, + onStartSessionButtonClick: () -> Unit, +){ + + + var isAddSubjectDialogOpen by rememberSaveable { mutableStateOf(false) } + val snackbarHostState = remember{ SnackbarHostState() } + LaunchedEffect(key1 = true) { + snackbarEvent.collectLatest { event -> + when (event) { + is SnackbarEvent.ShowSnackbar -> { + snackbarHostState.showSnackbar( + message = event.message, + duration = event.duration + ) + } + + SnackbarEvent.NavigateUp -> {} + } + } + } + + AddSubjectDialog( + subjectName = state.subjectName, + goalHours = state.goalStudyHours, + onSubjectNameChange ={onEvent.invoke(DashboardEvent.OnSubjectNameChange(it))}, + onGoalHoursChange ={onEvent.invoke(DashboardEvent.OnGoalStudyHoursChange(it))} , + selectedColor = state.subjectCardColors, + onColourChange ={onEvent.invoke(DashboardEvent.OnSubjectCardColorChange(it))} , + isOpen = isAddSubjectDialogOpen , + onDismiss = {isAddSubjectDialogOpen = false }, + onConfirmBtnClick = {onEvent.invoke(DashboardEvent.SaveSubject) + isAddSubjectDialogOpen = false}) + + var isDeleteDialogOpen by rememberSaveable { mutableStateOf(false) } + + DeleteDialog( + title = "Delete Session?", + bodyText = "Are you sure you want to delete this session? Your studied hour will be reduced"+ + " by the duration of the session. This action is irreversible", + isOpen = isDeleteDialogOpen , + onDismiss = {isDeleteDialogOpen = false }, + onConfirmBtnClick = {onEvent.invoke(DashboardEvent.DeleteSession) + isDeleteDialogOpen = false}) + + + Scaffold ( + snackbarHost = { SnackbarHost(hostState = snackbarHostState)}, + topBar = { DashboardTopBar() } + ){paddingValues -> + LazyColumn (modifier = Modifier + .fillMaxSize() + .padding(paddingValues)){ + item { + CountCardSection( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + subjectCount = state.totalSubjectCount , + studyHours =state.totalStudiedHours.toString() , + targetHours = state.totalGoalHours.toString()) + } + item{ + SubjectCardSection( + modifier = Modifier.fillMaxWidth(), + subjectList = state.subjects, + onAddIconClick = {isAddSubjectDialogOpen = true}, + onSubjectCardClick = onSubjectCardClick + ) + } + + item { + Button(onClick = onStartSessionButtonClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 48.dp, vertical = 20.dp), + enabled = state.subjects.isNotEmpty() + ) { + Text(text = "Start Study Session") + } + } + TaskList(sectionTitle ="UPCOMING TASKS" , + Tasks = task, + emptyListText ="You don't have any task yet.\n Click + button to add new task.", + onCheckBoxClick = {onEvent.invoke(DashboardEvent.OnTaskIsCompleteChange(it))}, + onTaskCardClick = onTaskCardClick + ) + item { + Spacer(modifier = Modifier.height(20.dp)) + } + + StudySessionList(sectionTitle ="RECENT STUDY SESSION" , + Sessions = session , + emptyListText ="You don't have any recent study session.\n Start a study session to begin your progress.", + onDeleteItemClick = {onEvent.invoke(DashboardEvent.OnDeleteSessionButtonClicked(it)) + isDeleteDialogOpen=true} + ) + + item { + Spacer(modifier = Modifier.height(100.dp)) + } + + } + + } + +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DashboardTopBar(){ + CenterAlignedTopAppBar(title = { + Text(text = "StudyBuddy", + style = MaterialTheme.typography.headlineMedium) + }) +} + +@Composable +fun BottomAppBar(){ + NavigationBar { + + } +} + +@Composable +private fun CountCardSection( + modifier: Modifier, + subjectCount: Int, + studyHours: String, + targetHours: String +){ + Row { + CountCards( + modifier = modifier.weight(1f), + headingText = "Subject Count", + count = "$subjectCount" + ) + + Spacer(modifier = Modifier.width(10.dp)) + + CountCards( + modifier = modifier.weight(1f), + headingText = "Study Hours", + count = studyHours + ) + + Spacer(modifier = Modifier.width(10.dp)) + + CountCards( + modifier = modifier.weight(1f), + headingText = "Target Hours", + count = targetHours + ) + + + } +} + +@Composable +private fun SubjectCardSection( + modifier: Modifier, + subjectList:List, + emptyListText:String = "You don't have any subject yet.\n Click + button to add new subject." , + onAddIconClick: () -> Unit, + onSubjectCardClick: (Int?) -> Unit +){ + Column(modifier = modifier) { + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = "SUBJECTS", + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall + ) + + IconButton(onClick = onAddIconClick ) { + Icon(imageVector = Icons.Default.Add, + contentDescription ="Add Subjects" ) + + } + + } + if (subjectList.isEmpty()){ + Image( + modifier = Modifier + .size(120.dp) + .align(Alignment.CenterHorizontally) , + painter = painterResource(id = R.drawable.books), + contentDescription =emptyListText ) + Text( + modifier = Modifier.fillMaxWidth(), + text = emptyListText, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + textAlign = TextAlign.Center + ) + } + + LazyRow ( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(start = 12.dp, end = 12.dp) + ){ + items(subjectList){subject -> + SubjectCard( + subjectName = subject.name, + gradientColors =subject.color.map { Color(it) }, + onClick = {onSubjectCardClick(subject.SubjectId)}) + + } + + } + } + } + + +@Preview +@Composable +fun BottomAppBarPreview() { + BottomAppBar() +} + + diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/dashboard/DashboardVariable.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/dashboard/DashboardVariable.kt new file mode 100644 index 00000000..d3414ba7 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/dashboard/DashboardVariable.kt @@ -0,0 +1,16 @@ +package com.example.studybuddy.view.dashboard + +import androidx.compose.ui.graphics.Color +import com.example.studybuddy.domain.model.Session +import com.example.studybuddy.domain.model.Subject + +data class dashboardVariable( + val totalSubjectCount: Int = 0, + val totalStudiedHours: Float = 0f, + val totalGoalHours: Float= 0f, + val subjects:List = emptyList(), + val subjectName: String = "", + val goalStudyHours: String = "", + val subjectCardColors: List = Subject.subjectCardColor.random(), + val session: Session? = null +) diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/dashboard/DashboardViewModel.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/dashboard/DashboardViewModel.kt new file mode 100644 index 00000000..0ce5aa9a --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/dashboard/DashboardViewModel.kt @@ -0,0 +1,189 @@ +package com.example.studybuddy.view.dashboard + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.ui.graphics.toArgb +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.studybuddy.domain.model.Session +import com.example.studybuddy.domain.model.Subject +import com.example.studybuddy.domain.model.Task +import com.example.studybuddy.domain.model.repository.SessionRepository +import com.example.studybuddy.domain.model.repository.SubjectRepository +import com.example.studybuddy.domain.model.repository.TaskRepository +import com.example.studybuddy.utils.SnackbarEvent +import com.example.studybuddy.utils.toHours +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + + +@HiltViewModel +class DashboardViewModel @Inject constructor( + private val subjectRepository: SubjectRepository, + private val sessionRepository: SessionRepository, + private val taskRepository: TaskRepository +):ViewModel() +{ + private val _state = MutableStateFlow(dashboardVariable()) + + val state = combine( + _state, + subjectRepository.getTotalSubjectCount(), + subjectRepository.getTotalGoalHours(), + subjectRepository.getAllSubjects(), + sessionRepository.getTotalSessionDuration() + ){ + _state, totalSubjectCount, totalGoalHours, subjects, totalSessionDuration -> + _state.copy( + totalSubjectCount = totalSubjectCount, + totalGoalHours = totalGoalHours, + subjects = subjects, + totalStudiedHours = totalSessionDuration.toHours() + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = dashboardVariable() + ) + + + val tasks: StateFlow> = taskRepository.getAllUpcomingTasks() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + + val session: StateFlow> = sessionRepository.getRecentFiveSessions() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + + + private val _snackbarEventFlow = MutableSharedFlow() + val snackbarEventFlow = _snackbarEventFlow.asSharedFlow() + + + fun onEvent(event: DashboardEvent){ + when(event){ + DashboardEvent.DeleteSession -> deleteSession() + is DashboardEvent.OnDeleteSessionButtonClicked -> { + _state.update { + it.copy(session = event.session) + } + } + is DashboardEvent.OnGoalStudyHoursChange -> { + + _state.update { + it.copy(goalStudyHours = event.hours) + } + } + is DashboardEvent.OnSubjectCardColorChange -> { + + _state.update { + it.copy(subjectCardColors = event.colors) + } + } + is DashboardEvent.OnSubjectNameChange -> { + + _state.update { + it.copy(subjectName = event.name) + } + } + is DashboardEvent.OnTaskIsCompleteChange -> { + updateTask(event.task) + } + DashboardEvent.SaveSubject -> saveSubject() + } + } + + private fun updateTask(task: Task) { + viewModelScope.launch { + try { + taskRepository.upsertTask( + task = task.copy(isCompleted = !task.isCompleted) + ) + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Task marked as completed" + ) + ) + } + catch (e:Exception){ + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Couldn't update the task. ${e.message}", + SnackbarDuration.Long + ) + ) + } + } + + } + + private fun saveSubject() { + viewModelScope.launch { + try { + subjectRepository.upsertSubject( + subject = Subject( + name = state.value.subjectName, + goalHour = state.value.goalStudyHours.toFloatOrNull()?: 1f, + color = state.value.subjectCardColors.map { it.toArgb() }, + + ) + ) + _state.update { + it.copy( + subjectName = "", + goalStudyHours = "", + subjectCardColors = Subject.subjectCardColor.random() + ) + } + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Subject Added Successfully" + ) + ) + } + catch (e:Exception){ + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Failed to add subject. ${e.message}", + SnackbarDuration.Long + ) + ) + } + } + } + + private fun deleteSession() { + viewModelScope.launch { + try { + state.value.session?.let { + sessionRepository.deleteSession(it) + } + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Session deleted successfully")) + } + catch (e:Exception) + { + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Failed to delete Session ${e.message}", SnackbarDuration.Long)) + } + } + } + + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/session/ServiceHelper.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/session/ServiceHelper.kt new file mode 100644 index 00000000..d63eb565 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/session/ServiceHelper.kt @@ -0,0 +1,34 @@ +package com.example.studybuddy.view.session + +import android.app.PendingIntent +import android.app.TaskStackBuilder +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import com.example.studybuddy.MainActivity +import com.example.studybuddy.utils.ServiceConstants.CLICK_REQUEST_CODE + +object ServiceHelper { + + fun clickPendingIntent(context: Context):PendingIntent{ + val deepLinkIntent = Intent( + Intent.ACTION_VIEW, + "study_buddy://dashboard/session".toUri(), + context, + MainActivity::class.java + + ) + return TaskStackBuilder.create(context).run { + addNextIntentWithParentStack(deepLinkIntent) + getPendingIntent(CLICK_REQUEST_CODE, PendingIntent.FLAG_IMMUTABLE) + } + } + + fun triggerForegroundService(context: Context, action: String) { + Intent(context, StudySessionTimerService::class.java).apply { + this.action = action + context.startService(this) + } + } + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/session/SessionEvent.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/session/SessionEvent.kt new file mode 100644 index 00000000..0997dbce --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/session/SessionEvent.kt @@ -0,0 +1,22 @@ +package com.example.studybuddy.view.session + +import com.example.studybuddy.domain.model.Session +import com.example.studybuddy.domain.model.Subject + +sealed class SessionEvent { + + data class OnRelatedSubjectChange(val subject: Subject) : SessionEvent() + + data class SaveSession(val duration: Long) : SessionEvent() + + data class OnDeleteSessionButtonClicked(val session: Session) : SessionEvent() + + data object DeleteSession: SessionEvent() + + data object NotifyToUpdateSubject : SessionEvent() + + data class UpdateSubjectIdAndRelatedSubject( + val subjectId: Int?, + val relatedToSubject: String? + ) : SessionEvent() +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/session/SessionScreen.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/session/SessionScreen.kt new file mode 100644 index 00000000..21129fd0 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/session/SessionScreen.kt @@ -0,0 +1,433 @@ +package com.example.studybuddy.view.session + +import android.content.Intent +import android.util.Log +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideIn +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.studybuddy.ui.theme.Red +import com.example.studybuddy.utils.ServiceConstants.Action_Service_Cancel +import com.example.studybuddy.utils.ServiceConstants.Action_Service_Start +import com.example.studybuddy.utils.ServiceConstants.Action_Service_Stop +import com.example.studybuddy.utils.SnackbarEvent +import com.example.studybuddy.view.components.DeleteDialog +import com.example.studybuddy.view.components.StudySessionList +import com.example.studybuddy.view.components.SubjectListDropDown +import com.example.studybuddy.view.destinations.MainScreenRouteDestination +import com.example.studybuddy.view.destinations.SessionScreenRouteDestination +import com.example.studybuddy.view.destinations.SubjectScreenRouteDestination +import com.ramcosta.composedestinations.annotation.DeepLink +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlin.time.DurationUnit + +@Destination( + deepLinks = [ + DeepLink( + action = Intent.ACTION_VIEW, + uriPattern = "study_buddy://dashboard/session" + ) + ] +) +@Composable +fun SessionScreenRoute( + navigator: DestinationsNavigator, + timerService: StudySessionTimerService +) +{ + val viewModel:SessionScreenViewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + + SessionScreen( + state =state , + onEvent = viewModel::onEvent , + onBackButtonClick = { + navigator.navigate(MainScreenRouteDestination(0)) { + popUpTo(SessionScreenRouteDestination) { inclusive = true } + } + }, + snackbarEvent = viewModel.snackbarEventFlow, + timerService = timerService + ) +} + +@OptIn(ExperimentalMaterial3Api::class) + +@Composable +private fun SessionScreen( + state: SessionState, + snackbarEvent: SharedFlow, + onEvent: (SessionEvent) -> Unit, + onBackButtonClick: () -> Unit, + timerService: StudySessionTimerService +) +{ + val hours by timerService.hours + val minutes by timerService.minute + val seconds by timerService.second + val currentTimerState by timerService.currentTimerState + + + val context = LocalContext.current + val scope = rememberCoroutineScope() + var isSubjectDropDownOpen by rememberSaveable { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState() + var isDeleteSessionDialogOpen by rememberSaveable { mutableStateOf(false) } + val snackbarHostState = remember{ SnackbarHostState() } + + + LaunchedEffect(key1 = true) { + snackbarEvent.collectLatest { event -> + when (event) { + is SnackbarEvent.ShowSnackbar -> { + snackbarHostState.showSnackbar( + message = event.message, + duration = event.duration + ) + } + + SnackbarEvent.NavigateUp -> onBackButtonClick() + } + } + } + + LaunchedEffect(key1 = state.subjects) { + val subjectId = timerService.subjectId.value + onEvent( + SessionEvent.UpdateSubjectIdAndRelatedSubject( + subjectId = subjectId, + relatedToSubject = state.subjects.find { it.SubjectId == subjectId }?.name + + ) + ) + + } + + + + + + + DeleteDialog( + title = "Delete Session?", + bodyText = "Are you sure you want to delete this session? Your studied hour will be reduced"+ + " by the duration of the session. This action is irreversible", + isOpen = isDeleteSessionDialogOpen , + onDismiss = {isDeleteSessionDialogOpen = false }, + onConfirmBtnClick = { + onEvent(SessionEvent.DeleteSession) + isDeleteSessionDialogOpen = false}) + + + SubjectListDropDown( + sheetState =sheetState , + isOpen = isSubjectDropDownOpen, + subjects = state.subjects , + onDismissRequest = {isSubjectDropDownOpen=false}, + onSubjectClick ={subject -> + scope.launch { sheetState.hide() }.invokeOnCompletion { + if (!sheetState.isVisible) { + isSubjectDropDownOpen = false + } + + } + onEvent(SessionEvent.OnRelatedSubjectChange(subject)) + } + ) + + + Scaffold( + topBar = { + SessionScreenTopBar( + onBackButtonClick = onBackButtonClick, + + ) + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) } + ) {paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + .padding(horizontal = 12.dp) + ) { + item { + TimerSection(modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + hours = hours, + minutes = minutes, + seconds = seconds) + + Text(text = "Related to subject") + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = state.relatedToSubject ?: "Select Subject", + style = MaterialTheme.typography.bodyLarge + ) + + IconButton( + onClick = { isSubjectDropDownOpen=true }, + enabled = seconds == "00" + ) { + Icon(imageVector = Icons.Default.KeyboardArrowDown, + contentDescription ="Select Subject" ) + + } + + } + + Spacer(modifier = Modifier.height(20.dp)) + } + item { + TimerButtonSection( + modifier = Modifier.fillMaxWidth(), + onStartButtonClick = { + if (state.subjectId!= null && state.relatedToSubject != null){ + ServiceHelper.triggerForegroundService( + context = context, + action = if (currentTimerState == StudySessionTimerService.TimerState.STARTED){ + Action_Service_Stop + } + else Action_Service_Start + ) + timerService.subjectId.value = state.subjectId + } + else{ + onEvent(SessionEvent.NotifyToUpdateSubject) + } + + }, + onCancelButtonClick = { + ServiceHelper.triggerForegroundService( + context = context, + action = Action_Service_Cancel + ) + }, + onFinishButtonClick = { + val duration = timerService.duration.toLong(DurationUnit.SECONDS) + onEvent(SessionEvent.SaveSession(duration)) + if (duration>= 36) + { + ServiceHelper.triggerForegroundService( + context = context, + action = Action_Service_Cancel + ) + } + }, + timerState = currentTimerState, + seconds = seconds + ) + Spacer(modifier = Modifier.height(20.dp)) + + } + + StudySessionList(sectionTitle ="RECENT STUDY SESSION" , + Sessions = state.sessions , + emptyListText ="You don't have any recent study session.\n Start a study session to begin your progress.", + onDeleteItemClick = { + isDeleteSessionDialogOpen=true + onEvent(SessionEvent.OnDeleteSessionButtonClicked(it)) + } + ) + + } + + } + + +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SessionScreenTopBar( + + onBackButtonClick: () -> Unit, +) +{ + TopAppBar( + navigationIcon = { + IconButton(onClick = onBackButtonClick ) { + Icon(imageVector = Icons.Default.ArrowBack, + contentDescription ="Navigate Back" ) + } + }, + title = { + Text(text = "Session Screen") + } + ) +} + +@Composable +private fun TimerSection + ( + modifier: Modifier, + hours:String, + minutes:String, + seconds:String +) +{ + Box(modifier = modifier, + contentAlignment = Alignment.Center) + { + Box( + modifier = Modifier + .size(250.dp) + .border(5.dp, MaterialTheme.colorScheme.surfaceVariant, CircleShape), + contentAlignment = Alignment.Center + ) { + Row { + AnimatedContent( + targetState = hours, + label = hours, + transitionSpec = { timerTextAnimation()} + ) { + hours -> + Text(text = "$hours:", + style = MaterialTheme.typography.titleLarge.copy(fontSize = 45.sp)) + } + AnimatedContent( + targetState = minutes, + label = minutes, + transitionSpec = { timerTextAnimation()} + ) { + minutes -> + Text(text = "$minutes:", + style = MaterialTheme.typography.titleLarge.copy(fontSize = 45.sp)) + } + AnimatedContent( + targetState = seconds, + label = seconds, + transitionSpec = { timerTextAnimation()} + ) { + seconds -> + Text(text = "$seconds", + style = MaterialTheme.typography.titleLarge.copy(fontSize = 45.sp)) + } + } + } + } +} + +@Composable +private fun TimerButtonSection( + modifier: Modifier, + onStartButtonClick: () -> Unit, + onCancelButtonClick: () -> Unit, + onFinishButtonClick: () -> Unit, + timerState: StudySessionTimerService.TimerState, + seconds:String +) +{ + Row(modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween) { + Button( + modifier = Modifier + .padding(horizontal = 5.dp, vertical = 5.dp), + onClick = onCancelButtonClick, + enabled = seconds != "00" && timerState != StudySessionTimerService.TimerState.STARTED) + { + Text(text = "Cancel") + } + + Button( + modifier = Modifier + .padding(horizontal = 5.dp, vertical = 5.dp), + onClick = onStartButtonClick, + + colors = ButtonDefaults.buttonColors( + containerColor = if (timerState == StudySessionTimerService.TimerState.STARTED) Red + else MaterialTheme.colorScheme.primary, + contentColor = Color.White + ) + + ) { + Text( + text = when(timerState) + { + + StudySessionTimerService.TimerState.STARTED -> "Stop" + StudySessionTimerService.TimerState.STOPPED -> "Resume" + else -> "Start" + }) + } + + Button( + modifier = Modifier + .padding(horizontal = 5.dp, vertical = 5.dp), + onClick = { onFinishButtonClick() }, + enabled = seconds != "00" && timerState != StudySessionTimerService.TimerState.STARTED + ) { + Text(text = "Finish") + } + } +} + +private fun timerTextAnimation(duration: Int = 600): ContentTransform { + return slideInVertically(animationSpec = tween(duration)) { fullHeight -> fullHeight } + + fadeIn(animationSpec = tween(duration))togetherWith + slideOutVertically(animationSpec = tween(duration)) { fullHeight -> -fullHeight }+ + fadeOut(animationSpec = tween(duration)) +} + + diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/session/SessionScreenViewModel.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/session/SessionScreenViewModel.kt new file mode 100644 index 00000000..e865d9df --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/session/SessionScreenViewModel.kt @@ -0,0 +1,146 @@ +package com.example.studybuddy.view.session + +import androidx.compose.material3.SnackbarDuration +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.studybuddy.domain.model.Session +import com.example.studybuddy.domain.model.repository.SessionRepository +import com.example.studybuddy.domain.model.repository.SubjectRepository +import com.example.studybuddy.utils.SnackbarEvent +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.time.Instant +import javax.inject.Inject + +@HiltViewModel +class SessionScreenViewModel @Inject constructor( + subjectRepository: SubjectRepository, + private val sessionRepository: SessionRepository + +):ViewModel() { + + + private val _state = MutableStateFlow(SessionState()) + val state = combine( + _state, + subjectRepository.getAllSubjects(), + sessionRepository.getAllSessions() + ) + { + state, subjects, sessions -> + state.copy( + subjects = subjects, + sessions = sessions + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = SessionState() + ) + private val _snackbarEventFlow = MutableSharedFlow() + val snackbarEventFlow = _snackbarEventFlow.asSharedFlow() + + + fun onEvent(event: SessionEvent){ + when(event){ + SessionEvent.NotifyToUpdateSubject-> notifyToUpdateSubject() + SessionEvent.DeleteSession -> deleteSession() + is SessionEvent.OnDeleteSessionButtonClicked -> { + _state.update { + it.copy(session = event.session) + } + } + is SessionEvent.OnRelatedSubjectChange -> { + _state.update { + it.copy( + relatedToSubject = event.subject.name, + subjectId = event.subject.SubjectId + ) + } + } + is SessionEvent.SaveSession -> insertSession(event.duration) + is SessionEvent.UpdateSubjectIdAndRelatedSubject -> { + _state.update { + it.copy( + relatedToSubject = event.relatedToSubject, + subjectId = event.subjectId + ) + } + } + } + } + + private fun notifyToUpdateSubject() { + viewModelScope.launch { + if (state.value.subjectId == null || state.value.relatedToSubject == null) + { + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Please choose related subject to this session")) + } + } + } + + private fun deleteSession() { + viewModelScope.launch { + try { + state.value.session?.let { + sessionRepository.deleteSession(it) + } + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Session deleted successfully")) + } + catch (e:Exception) + { + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Failed to delete Session ${e.message}", SnackbarDuration.Long)) + } + } + } + + private fun insertSession(duration: Long) { + viewModelScope.launch { + if (duration< 36){ + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Single session must be more than 36 sec") + ) + return@launch + + } + try { + withContext(Dispatchers.IO){ + sessionRepository.insertSession( + session = Session( + sessionSubjectId = state.value.subjectId ?: -1, + relatedToSubject = state.value.relatedToSubject ?: "", + date = Instant.now().toEpochMilli(), + duration = duration + ) + ) + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Session Saved successfully")) + _snackbarEventFlow.emit(SnackbarEvent.NavigateUp) + } + } + catch (e:Exception){ + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Failed to save Session ${e.message}", SnackbarDuration.Long)) + } + } + } + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/session/SessionState.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/session/SessionState.kt new file mode 100644 index 00000000..f9583031 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/session/SessionState.kt @@ -0,0 +1,12 @@ +package com.example.studybuddy.view.session + +import com.example.studybuddy.domain.model.Session +import com.example.studybuddy.domain.model.Subject + +data class SessionState( + val subjects: List = emptyList(), + val sessions: List = emptyList(), + val relatedToSubject: String? = null, + val subjectId: Int? = null, + val session: Session? = null +) diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/session/StudySessionTimerService.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/session/StudySessionTimerService.kt new file mode 100644 index 00000000..8d65a315 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/session/StudySessionTimerService.kt @@ -0,0 +1,154 @@ +package com.example.studybuddy.view.session + +import android.annotation.SuppressLint +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.os.Binder +import android.os.IBinder +import androidx.compose.runtime.mutableStateOf +import androidx.core.app.NotificationCompat +import com.example.studybuddy.utils.ServiceConstants.Action_Service_Cancel +import com.example.studybuddy.utils.ServiceConstants.Action_Service_Start +import com.example.studybuddy.utils.ServiceConstants.Action_Service_Stop +import com.example.studybuddy.utils.ServiceConstants.NOTIFICATION_CHANNEL_ID +import com.example.studybuddy.utils.ServiceConstants.NOTIFICATION_CHANNEL_NAME +import com.example.studybuddy.utils.ServiceConstants.NOTIFICATION_ID +import com.example.studybuddy.utils.SnackbarEvent.NavigateUp.TimeToString +import dagger.hilt.android.AndroidEntryPoint +import java.util.Timer +import javax.inject.Inject +import kotlin.concurrent.fixedRateTimer +import kotlin.time.Duration +import kotlin.time.Duration.Companion.ZERO +import kotlin.time.Duration.Companion.seconds + + +@AndroidEntryPoint +class StudySessionTimerService : Service() { + + @Inject + lateinit var notificationManager: NotificationManager + + @Inject + lateinit var notificationBuilder: NotificationCompat.Builder + + private val serviceBinder = StudySessionTimerBinder() + + private lateinit var timer: Timer + var duration: Duration = Duration.ZERO + var second = mutableStateOf("00") + private set + + var minute = mutableStateOf("00") + private set + + var hours = mutableStateOf("00") + private set + + var currentTimerState = mutableStateOf(TimerState.IDLE) + private set + + var subjectId = mutableStateOf(null) + + override fun onBind(intent: Intent?) = serviceBinder + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + intent?.action.let { + when (it) { + Action_Service_Start -> { + startForegroundService() + StartTimer{hours,minute,second -> + updateNotification(hours,minute,second) + } + } + + + Action_Service_Stop -> { + stopTimer() + } + + Action_Service_Cancel -> { + stopTimer() + cancelTimer() + stopForegroundService() + } + } + return super.onStartCommand(intent, flags, startId) + } + } + + + + private fun startForegroundService(){ + createNotificationChannel() + startForeground(NOTIFICATION_ID,notificationBuilder.build()) + } + + private fun createNotificationChannel(){ + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + NOTIFICATION_CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(channel) + } + + private fun updateNotification(hours:String,minute:String,second:String){ + notificationManager.notify(NOTIFICATION_ID, + notificationBuilder.setContentText("$hours:$minute:$second") + .build() + ) + } + + private fun StartTimer( + onTick:(h:String,m:String,s:String) ->Unit + ) + { + currentTimerState.value = TimerState.STARTED + timer = fixedRateTimer(initialDelay = 1000L, period = 1000L){ + duration = duration.plus(1.seconds) + updateTimeUnits() + onTick(hours.value,minute.value,second.value) + } + } + + private fun updateTimeUnits(){ + duration.toComponents { hours, minutes, seconds,_-> + this@StudySessionTimerService.hours.value =hours.toInt().TimeToString() + this@StudySessionTimerService.minute.value = minutes.TimeToString() + this@StudySessionTimerService.second.value = seconds.TimeToString() + } + + + } + + inner class StudySessionTimerBinder : Binder(){ + fun getService(): StudySessionTimerService = this@StudySessionTimerService + } + + private fun stopTimer(){ + if (this::timer.isInitialized){ + timer.cancel() + } + currentTimerState.value = TimerState.STOPPED + } + + private fun cancelTimer(){ + duration = ZERO + updateTimeUnits() + currentTimerState.value = TimerState.IDLE + } + private fun stopForegroundService(){ + notificationManager.cancel(NOTIFICATION_ID) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + + } + + enum class TimerState{ + IDLE, + STARTED, + STOPPED + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/splash/SplashScreen.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/splash/SplashScreen.kt new file mode 100644 index 00000000..5b4461f9 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/splash/SplashScreen.kt @@ -0,0 +1,82 @@ +package com.example.studybuddy.view.splash + +import android.content.Intent +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import com.example.studybuddy.R +import com.example.studybuddy.view.destinations.LoginScreenRouteDestination +import com.example.studybuddy.view.destinations.MainScreenRouteDestination +import com.example.studybuddy.view.destinations.SplashScreenDestination +import com.google.firebase.auth.FirebaseAuth +import com.ramcosta.composedestinations.annotation.DeepLink +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo +import kotlinx.coroutines.delay + +@Destination( + start = true, + deepLinks = [ + DeepLink( + action = Intent.ACTION_VIEW, + uriPattern = "study_buddy://splash") + ] +) + +@Composable +fun SplashScreen( + navigator: DestinationsNavigator +) { + LaunchedEffect(Unit) { + delay(2000) // Optional delay to show splash screen + + val isLoggedIn = FirebaseAuth.getInstance().currentUser != null + + if (isLoggedIn) { + navigator.navigate(MainScreenRouteDestination(0)) { + popUpTo(SplashScreenDestination) { inclusive = true } + } + } else { + navigator.navigate(LoginScreenRouteDestination) { + popUpTo(SplashScreenDestination) { inclusive = true } + } + } + } + + Surface(modifier = Modifier.fillMaxSize()) { + SplashScreenDesign() + } +} + + + +@Composable +fun SplashScreenDesign() { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + + + Image( + painter = painterResource(id = R.drawable.splash_screen), // Replace with your logo resource + contentDescription = "App Logo", + modifier = Modifier.fillMaxSize(), // Adjust size as needed + contentScale = ContentScale.Crop + ) + } +} + +// TODO: implement some sort of animation to the splash screen + + diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/subject/SubjectEvent.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/subject/SubjectEvent.kt new file mode 100644 index 00000000..e6e279d2 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/subject/SubjectEvent.kt @@ -0,0 +1,26 @@ +package com.example.studybuddy.view.subject + +import androidx.compose.ui.graphics.Color +import com.example.studybuddy.domain.model.Session +import com.example.studybuddy.domain.model.Task +import com.example.studybuddy.view.dashboard.DashboardEvent + +sealed class SubjectEvent { + data object UpdateSubject : SubjectEvent() + + data object DeleteSubject : SubjectEvent() + + data object DeleteSession : SubjectEvent() + + data object UpdateProgress: SubjectEvent() + + data class OnTaskIsCompleteChange(val task: Task): SubjectEvent() + + data class OnSubjectCardColorChange(val colors:List): SubjectEvent() + + data class OnSubjectNameChange(val name:String): SubjectEvent() + + data class OnGoalStudyHoursChange(val hours:String): SubjectEvent() + + data class OnDeleteSessionButtonClicked(val session: Session): SubjectEvent() +} diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/subject/SubjectScreen.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/subject/SubjectScreen.kt new file mode 100644 index 00000000..7c91a6a6 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/subject/SubjectScreen.kt @@ -0,0 +1,350 @@ +package com.example.studybuddy.view.subject + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.studybuddy.utils.SnackbarEvent +import com.example.studybuddy.view.components.AddSubjectDialog +import com.example.studybuddy.view.components.CountCards +import com.example.studybuddy.view.components.DeleteDialog +import com.example.studybuddy.view.components.StudySessionList +import com.example.studybuddy.view.components.TaskList +import com.example.studybuddy.view.destinations.MainScreenRouteDestination +import com.example.studybuddy.view.destinations.SubjectScreenRouteDestination +import com.example.studybuddy.view.destinations.TaskScreenRouteDestination +import com.example.studybuddy.view.task.TaskScreenNavArgs +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.collectLatest + +data class SubjectScreenNavArgs( + val subjectId:Int +) + +@Destination(navArgsDelegate = SubjectScreenNavArgs::class) +@Composable +fun SubjectScreenRoute( + navigator: DestinationsNavigator, +){ + + val viewModel:SubjectViewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + + SubjectScreen( + state =state , + onEvent =viewModel::onEvent, + snackbarEvent = viewModel.snackbarEventFlow, + onBackButtonClicked = { + navigator.navigate(MainScreenRouteDestination(0)) { + popUpTo(SubjectScreenRouteDestination) { inclusive = true } + } + }, + onAddTaskButtonClick = { + val navArgs = TaskScreenNavArgs(taskId = null, subjectId = state.currentSubjectId) + navigator.navigate(TaskScreenRouteDestination(navArgs = navArgs)) + }, + onTaskCardClick = { taskId -> + taskId?.let{ + val navArgs = TaskScreenNavArgs(taskId, subjectId = null) + navigator.navigate(TaskScreenRouteDestination(navArgs = navArgs)) + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SubjectScreen( + state: subjectState, + onEvent: (SubjectEvent) -> Unit, + snackbarEvent: SharedFlow, + onBackButtonClicked: () -> Unit, + onAddTaskButtonClick:() -> Unit, + onTaskCardClick: (Int?) -> Unit +){ + + + var isEditSubjectDialogOpen by rememberSaveable { mutableStateOf(false) } + + + var isDeleteSubjectDialogOpen by rememberSaveable { mutableStateOf(false) } + var isDeleteSessionDialogOpen by rememberSaveable { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(key1 = true) { + snackbarEvent.collectLatest { event -> + when (event) { + is SnackbarEvent.ShowSnackbar -> { + snackbarHostState.showSnackbar( + message = event.message, + duration = event.duration + ) + } + + SnackbarEvent.NavigateUp -> onBackButtonClicked() + } + } + } + + LaunchedEffect(key1 = state.studiedHours, key2 = state.goalStudyHours) { + onEvent(SubjectEvent.UpdateProgress) + } + + + AddSubjectDialog( + subjectName = state.subjectName, + goalHours = state.goalStudyHours, + onSubjectNameChange ={onEvent(SubjectEvent.OnSubjectNameChange(it))} , + onGoalHoursChange ={onEvent(SubjectEvent.OnGoalStudyHoursChange(it))} , + selectedColor = state.subjectCardColor, + onColourChange ={onEvent(SubjectEvent.OnSubjectCardColorChange(it))} , + isOpen = isEditSubjectDialogOpen , + onDismiss = {isEditSubjectDialogOpen = false }, + onConfirmBtnClick = {onEvent(SubjectEvent.UpdateSubject) + isEditSubjectDialogOpen = false}) + + + + DeleteDialog( + title = "Delete Session?", + bodyText = "Are you sure you want to delete this session? Your studied hour will be reduced"+ + " by the duration of the session. This action is irreversible", + isOpen = isDeleteSessionDialogOpen , + onDismiss = {isDeleteSessionDialogOpen = false }, + onConfirmBtnClick = {onEvent(SubjectEvent.DeleteSession) + isDeleteSessionDialogOpen = false}) + + DeleteDialog( + title = "Delete Subject?", + bodyText = "Are you sure you want to delete this subject? All related"+ + " task and session will be permanently deleted. This action is irreversible", + isOpen = isDeleteSubjectDialogOpen , + onDismiss = {isDeleteSubjectDialogOpen = false }, + onConfirmBtnClick = {onEvent(SubjectEvent.DeleteSubject) + isDeleteSubjectDialogOpen = false + }) + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val listState = rememberLazyListState() + val isFloatingBtnExpanded by remember { + derivedStateOf { listState.firstVisibleItemIndex==0 } + } + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = {SubjectScreenTopBar( + title = state.subjectName, + onBackButtonClicked = onBackButtonClicked, + onDeleteButtonClicked = { isDeleteSubjectDialogOpen=true }, + onEditButtonClicked = { isEditSubjectDialogOpen=true }, + scrollBehavior = scrollBehavior + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = onAddTaskButtonClick, + icon = { Icon(Icons.Default.Add, contentDescription = "Add Task") }, + text = { Text("Add Task")}, + expanded = isFloatingBtnExpanded + ) + }, + snackbarHost = {SnackbarHost(hostState = snackbarHostState)} + ) {paddingValues -> + + LazyColumn ( + state = listState, + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues)){ + + item { + SubjectOverviewSection( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + studiedHours = state.studiedHours.toString(), + goalHours = state.goalStudyHours, + progress =state.progress + ) + } + TaskList(sectionTitle ="UPCOMING TASKS" , + Tasks = state.upcomingTasks, + emptyListText ="You don't have any task yet.\n Click + button to add new task.", + onCheckBoxClick = {onEvent(SubjectEvent.OnTaskIsCompleteChange(it))}, + onTaskCardClick = onTaskCardClick + ) + item { + Spacer(modifier = Modifier.height(20.dp)) + } + + TaskList(sectionTitle ="COMPLETED TASKS" , + Tasks = state.completedTasks, + emptyListText ="You don't have any completed task yet.\n"+ + "Click the checkbox to complete task", + onCheckBoxClick = {onEvent(SubjectEvent.OnTaskIsCompleteChange(it))}, + onTaskCardClick = onTaskCardClick + ) + item { + Spacer(modifier = Modifier.height(20.dp)) + } + + StudySessionList(sectionTitle ="RECENT STUDY SESSION" , + Sessions = state.recentSessions , + emptyListText ="You don't have any recent study session.\n Start a study session to begin your progress.", + onDeleteItemClick = {isDeleteSessionDialogOpen=true + onEvent(SubjectEvent.OnDeleteSessionButtonClicked(it))} + ) + + item { + Spacer(modifier = Modifier.height(20.dp)) + } + } + + } + + +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SubjectScreenTopBar(title:String, + onBackButtonClicked:()->Unit, + onDeleteButtonClicked:()->Unit, + onEditButtonClicked:()->Unit, + scrollBehavior:TopAppBarScrollBehavior) { + LargeTopAppBar( + scrollBehavior =scrollBehavior , + navigationIcon = { + IconButton(onClick = onBackButtonClicked) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + title = { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.headlineSmall + ) + }, + actions = { + IconButton(onClick = onDeleteButtonClicked ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete Subject" + ) + + } + IconButton(onClick = onEditButtonClicked) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Edit Subject" + ) + } + } + + + ) +} + +@Composable +private fun SubjectOverviewSection( + modifier: Modifier, + studiedHours:String, + goalHours:String, + progress:Float +){ + val percentProgress = remember(progress) { + (progress * 100).toInt().coerceIn(0,100) + + } + Row( + modifier= modifier, + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically + ) { + CountCards(modifier = Modifier.weight(1f), + headingText = "Goal Study Hours", + count = goalHours) + + Spacer(modifier = Modifier.width(10.dp)) + + CountCards(modifier = Modifier.weight(1f), + headingText = "Study Hours", + count = studiedHours) + + Spacer(modifier = Modifier.width(10.dp)) + + Box(modifier = Modifier.size(75.dp), + contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier + .fillMaxSize(), + progress = 1f, + strokeWidth = 4.dp, + strokeCap = StrokeCap.Round, + color = MaterialTheme.colorScheme.surfaceVariant + ) + CircularProgressIndicator(modifier = Modifier + .fillMaxSize(), + progress = progress, + strokeWidth = 4.dp, + strokeCap = StrokeCap.Round + ) + + Text(text = "$percentProgress%") + } + + } +} + + + + diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/subject/SubjectViewModel.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/subject/SubjectViewModel.kt new file mode 100644 index 00000000..fd5245d8 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/subject/SubjectViewModel.kt @@ -0,0 +1,243 @@ +package com.example.studybuddy.view.subject + +import android.annotation.SuppressLint +import androidx.compose.material3.SnackbarDuration +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.studybuddy.domain.model.Subject +import com.example.studybuddy.domain.model.Task +import com.example.studybuddy.domain.model.repository.SessionRepository +import com.example.studybuddy.domain.model.repository.SubjectRepository +import com.example.studybuddy.domain.model.repository.TaskRepository +import com.example.studybuddy.utils.SnackbarEvent +import com.example.studybuddy.utils.toHours +import com.example.studybuddy.view.dashboard.dashboardVariable +import com.example.studybuddy.view.navArgs +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@HiltViewModel +class SubjectViewModel @Inject constructor( + private val subjectRepository: SubjectRepository, + private val sessionRepository: SessionRepository, + private val taskRepository: TaskRepository, + savedStateHandle: SavedStateHandle +): ViewModel() { + + + + private val navArgs: SubjectScreenNavArgs = savedStateHandle.navArgs() + +private val _state = MutableStateFlow(subjectState()) + val state = combine( + _state, + taskRepository.getTaskBySubjectId(navArgs.subjectId), + taskRepository.getCompletedTaskBySubjectId(navArgs.subjectId), + sessionRepository.getRecentFiveSessions(), + sessionRepository.getTotalSessionDurationBySubjectId(navArgs.subjectId) + ) + { + state, upcomingTask, completedTask, recentSession, totalSessionDuration -> + state.copy( + upcomingTasks = upcomingTask, + completedTasks = completedTask, + recentSessions = recentSession, + studiedHours = totalSessionDuration.toHours() + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = subjectState() + ) + + private val _snackbarEventFlow = MutableSharedFlow() + val snackbarEventFlow = _snackbarEventFlow.asSharedFlow() + + + init { + fetchSubject() + } + fun onEvent(event: SubjectEvent) + { + when(event){ + SubjectEvent.DeleteSession -> deleteSession() + SubjectEvent.DeleteSubject -> deleteSubject() + is SubjectEvent.OnDeleteSessionButtonClicked -> { + _state.update { + it.copy(session = event.session) + } + } + is SubjectEvent.OnGoalStudyHoursChange -> { + _state.update { + it.copy( + goalStudyHours = event.hours + ) + } + } + is SubjectEvent.OnSubjectCardColorChange -> { + _state.update { + it.copy( + subjectCardColor = event.colors + ) + } + } + is SubjectEvent.OnSubjectNameChange -> { + _state.update { + it.copy( + subjectName = event.name + ) + } + } + is SubjectEvent.OnTaskIsCompleteChange -> {updateTask(event.task)} + SubjectEvent.UpdateSubject -> updateSubject() + SubjectEvent.UpdateProgress -> { + val goalStudyHours = state.value.goalStudyHours.toFloatOrNull()?: 1f + _state.update { + it.copy( + progress = (state.value.studiedHours / goalStudyHours).coerceIn(0f,1f) + ) + } + } + } + } + + + private fun updateSubject() { + viewModelScope.launch { + try { + subjectRepository.upsertSubject( + subject = Subject( + SubjectId = state.value.currentSubjectId, + name = state.value.subjectName, + goalHour = state.value.goalStudyHours.toFloatOrNull()?: 1f, + color = state.value.subjectCardColor.map { it.toArgb() } + ) + ) + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Subject Updated Succesfully" + ) + ) + } + catch (e: Exception) + { + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Failed to update subject. ${e.message}" + ) + ) + } + } + } + + + private fun fetchSubject(){ + viewModelScope.launch { + subjectRepository + .getSubjectById(navArgs.subjectId)?.let { subject-> + _state.update { + it.copy( + subjectName = subject.name, + goalStudyHours = subject.goalHour.toString(), + subjectCardColor = subject.color.map { Color(it) }, + currentSubjectId = subject.SubjectId + ) + } + } + } + } + + private fun updateTask(task: Task) { + viewModelScope.launch { + try { + taskRepository.upsertTask( + task = task.copy(isCompleted = !task.isCompleted) + ) + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Task marked as upcoming task" + ) + ) + } + catch (e:Exception){ + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Couldn't update the task. ${e.message}", + SnackbarDuration.Long + ) + ) + } + } + + } + + @SuppressLint("SuspiciousIndentation") + private fun deleteSubject() + { + viewModelScope.launch { + try { + val currentSubjectId = state.value.currentSubjectId + + if (currentSubjectId!=null) + { + withContext(Dispatchers.IO){ + subjectRepository.deleteSubjectById(subjectId = currentSubjectId) + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar("Subject Deleted Successfully") + ) + _snackbarEventFlow.emit(SnackbarEvent.NavigateUp) + } + + } + + else{ + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar("Subject not found") + ) + } + + } + catch (e:Exception) + { + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar("Failed to delete subject. ${e.message}", + duration = SnackbarDuration.Long) + + ) + } + + } + } + private fun deleteSession() { + viewModelScope.launch { + try { + state.value.session?.let { + sessionRepository.deleteSession(it) + } + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Session deleted successfully")) + } + catch (e:Exception) + { + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Failed to delete Session ${e.message}", SnackbarDuration.Long)) + } + } + } + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/subject/subjectState.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/subject/subjectState.kt new file mode 100644 index 00000000..608ed0ed --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/subject/subjectState.kt @@ -0,0 +1,20 @@ +package com.example.studybuddy.view.subject + +import androidx.compose.ui.graphics.Color +import com.example.studybuddy.domain.model.Session +import com.example.studybuddy.domain.model.Subject +import com.example.studybuddy.domain.model.Task + +data class subjectState( + val currentSubjectId: Int? = null, + val subjectName: String = "", + val goalStudyHours: String = "", + val studiedHours: Float = 0f, + val subjectCardColor:List = Subject.subjectCardColor.random(), + val upcomingTasks:List = emptyList(), + val completedTasks:List = emptyList(), + val recentSessions:List = emptyList(), + val session:Session? = null, + val progress: Float = 0f, + val isLoading : Boolean = false +) diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/task/AlarmReceiver.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/task/AlarmReceiver.kt new file mode 100644 index 00000000..56d2cb09 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/task/AlarmReceiver.kt @@ -0,0 +1,85 @@ +package com.example.studybuddy.view.task + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.media.Ringtone +import android.media.RingtoneManager +import android.os.Handler +import android.widget.Toast +import androidx.core.app.NotificationCompat +import androidx.core.app.TaskStackBuilder +import com.example.studybuddy.MainActivity +import kotlin.random.Random + +class AlarmReceiver : BroadcastReceiver() { + + companion object { + private var ringtone: Ringtone? = null + } + + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == "com.example.studybuddy.STOP_ALARM") { + stopAlarm() + } else { + try { + val taskId = intent?.getIntExtra("task_id", -1) + val taskTitle = intent?.getStringExtra("taskTitle") + Toast.makeText(context, "Reminder for task: $taskTitle", Toast.LENGTH_SHORT).show() + showNotification(context, taskTitle, taskId) + ringAlarm(context) + } catch (e: Exception) { + Toast.makeText(context, "Error on receiver: $e", Toast.LENGTH_SHORT).show() + } + } + } + + private fun showNotification(context: Context?, taskTitle: String?, taskId: Int?) { + val manager = context?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notificationId = Random.nextInt() + val channelId = "reminder_channel" + val channelName = "Reminder Channel" + + val channel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_DEFAULT) + manager.createNotificationChannel(channel) + + val stopAlarmIntent = Intent(context, this.javaClass).apply { + action = "com.example.studybuddy.STOP_ALARM" // Unique action string + putExtra("notification_id", notificationId) + } + val stopAlarmPendingIntent = PendingIntent.getBroadcast(context, 0, stopAlarmIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + val intent = Intent(context, MainActivity::class.java).apply { + putExtra("task_id", taskId) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val pendingIntent = TaskStackBuilder.create(context).run { + addNextIntentWithParentStack(intent) + getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + + val builder = NotificationCompat.Builder(context, channelId) + .setContentTitle("Reminder") + .setContentText("Task: $taskTitle") + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentIntent(pendingIntent) + .addAction(android.R.drawable.ic_media_pause, "Stop Alarm", stopAlarmPendingIntent) + .setAutoCancel(true) + + manager.notify(notificationId, builder.build()) + } + + private fun ringAlarm(context: Context?) { + ringtone = RingtoneManager.getRingtone(context, RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)) + ringtone?.play() + + Handler().postDelayed({ stopAlarm() }, 20000) // Stop after 20 seconds + } + + private fun stopAlarm() { + ringtone?.stop() + } +} diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/task/TaskEvent.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/task/TaskEvent.kt new file mode 100644 index 00000000..580a41a6 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/task/TaskEvent.kt @@ -0,0 +1,26 @@ +package com.example.studybuddy.view.task + +import com.example.studybuddy.domain.model.Subject +import com.example.studybuddy.utils.Priority + +sealed class TaskEvent { + data class OnTitleChanged(val title: String) : TaskEvent() + + data class OnDescriptionChanged(val description: String) : TaskEvent() + + data class OnDateChanged(val millis: Long?) : TaskEvent() + + data class OnTimeChanged(val timeInMillis: Long?) : TaskEvent() + + data class onSetReminderchanged(val setReminder: Boolean):TaskEvent() + + data class OnPriorityChanged(val priority: Priority) : TaskEvent() + + data class OnRelatedSubjectSelect(val subject: Subject) : TaskEvent() + + data object OnIsCompletedChanged : TaskEvent() + + data object SaveTask : TaskEvent() + + data object DeleteTask : TaskEvent() +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/task/TaskScreen.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/task/TaskScreen.kt new file mode 100644 index 00000000..4d38d22e --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/task/TaskScreen.kt @@ -0,0 +1,485 @@ +package com.example.studybuddy.view.task + +import android.app.AlarmManager +import android.app.PendingIntent +import android.app.TimePickerDialog +import android.content.Context +import android.content.Intent +import android.icu.util.Calendar +import android.widget.TimePicker +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +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.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.studybuddy.R +import com.example.studybuddy.utils.Priority +import com.example.studybuddy.utils.SnackbarEvent +import com.example.studybuddy.utils.changeMillisToDateString +import com.example.studybuddy.utils.millisToTimeString +import com.example.studybuddy.view.components.CustomToggleButton +import com.example.studybuddy.view.components.DeleteDialog +import com.example.studybuddy.view.components.SubjectListDropDown +import com.example.studybuddy.view.components.TaskCheckBox +import com.example.studybuddy.view.components.TaskDatePicker +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + + +data class TaskScreenNavArgs( + val taskId:Int?, + val subjectId:Int? +) + + +@Destination(navArgsDelegate = TaskScreenNavArgs::class) +@Composable +fun TaskScreenRoute( + navigator:DestinationsNavigator +){ + + val viewModel :TaskViewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + + TaskScreen( + state =state , + onEvent = viewModel::onEvent, + snackbarEvent = viewModel.snackbarEventFlow, + onBackButtonClick = {navigator.navigateUp()} + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TaskScreen( + state: TaskState, + snackbarEvent: SharedFlow, + onEvent: (TaskEvent) -> Unit, + onBackButtonClick: () -> Unit +) +{ + + val context = LocalContext.current + val scope = rememberCoroutineScope() + var isSubjectDropDownOpen by rememberSaveable { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState() + var isTaskDatePickerOpen by rememberSaveable { mutableStateOf(false) } + val datePickerState = rememberDatePickerState(initialSelectedDateMillis = System.currentTimeMillis()) + var isDeleteTaskDialogOpen by rememberSaveable { mutableStateOf(false) } + var taskTitleError by remember{mutableStateOf(null)} + var subjectTitle by remember { mutableStateOf("") } + val snackbarHostState = remember{ SnackbarHostState() } + var isTaskTimePickerOpen by rememberSaveable { mutableStateOf(false) } + val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY) + val minute = Calendar.getInstance().get(Calendar.MINUTE) + var isAlarmSet by remember { mutableStateOf(false) } + + if (isTaskTimePickerOpen) { + TimePickerDialog( + context, + { _: TimePicker, selectedHour: Int, selectedMinute: Int -> + val calendar = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, selectedHour) + set(Calendar.MINUTE, selectedMinute) + } + onEvent(TaskEvent.OnTimeChanged(calendar.timeInMillis)) + isTaskTimePickerOpen = false + }, + hour, + minute, + false + ).show() + } + LaunchedEffect(key1 = true) { + snackbarEvent.collectLatest { event -> + when (event) { + is SnackbarEvent.ShowSnackbar -> { + snackbarHostState.showSnackbar( + message = event.message, + duration = event.duration + ) + } + + SnackbarEvent.NavigateUp -> onBackButtonClick() + } + } + } + + taskTitleError = when{ + state.title.isBlank() -> "Please enter task title" + state.title.length > 30 -> "Task title cannot be more than 30 characters" + state.title.length<4 -> "Task title cannot be less than 4 characters" + + else-> null + } + + DeleteDialog( + title = "Delete Task?", + bodyText = "Are you sure you want to delete this task? "+ + " This action is irreversible", + isOpen = isDeleteTaskDialogOpen , + onDismiss = {isDeleteTaskDialogOpen = false }, + onConfirmBtnClick = {onEvent(TaskEvent.DeleteTask) + isDeleteTaskDialogOpen = false} + ) + + TaskDatePicker( + state = datePickerState, + isOpen =isTaskDatePickerOpen , + onDismissRequest = { isTaskDatePickerOpen=false}, + onConfirmRequest = { + onEvent(TaskEvent.OnDateChanged(millis=datePickerState.selectedDateMillis)) + isTaskDatePickerOpen = false } + ) + + + + SubjectListDropDown( + sheetState =sheetState , + isOpen = isSubjectDropDownOpen, + subjects = state.subjects , + onDismissRequest = {isSubjectDropDownOpen=false}, + onSubjectClick ={subject -> + isSubjectDropDownOpen = false + subjectTitle = subject.name + + scope.launch { sheetState.hide() }.invokeOnCompletion { + if (!sheetState.isVisible) { + isSubjectDropDownOpen = false + } + onEvent(TaskEvent.OnRelatedSubjectSelect(subject)) + } + } + ) + + + Scaffold( + topBar = { + TaskScreenTopBar( + isTaskExist = state.currentTaskId!=null, + isCompleted = state.isTaskComplete, + checkBoxBorderColor = state.priority.color, + onBackButtonClick = onBackButtonClick, + onCheckBoxClick = {onEvent(TaskEvent.OnIsCompletedChanged)}, + onDeleteButtonClick = {isDeleteTaskDialogOpen = true} + ) + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState)} +) {paddingValues -> + + Column (modifier = Modifier + .verticalScroll(rememberScrollState()) + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 12.dp) + ){ + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.title, + onValueChange = {onEvent(TaskEvent.OnTitleChanged(it))}, + label = { Text(text = "Title")}, + singleLine = true, + isError = taskTitleError != null && state.title.isNotBlank(), + supportingText = { Text(text = taskTitleError.orEmpty())} + ) + + + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.description, + onValueChange ={onEvent(TaskEvent.OnDescriptionChanged(it))}, + label = { Text(text = "Description")}, + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Text(text = "Due Date", + style = MaterialTheme.typography.bodySmall + ) + Row(modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + + Text(text = state.dueDate.changeMillisToDateString(), + style = MaterialTheme.typography.bodyLarge + ) + IconButton(onClick = { isTaskDatePickerOpen = true }) { + Icon(imageVector = Icons.Default.DateRange, + contentDescription ="Select Due Date" ) + + } + + } + + Spacer(modifier = Modifier.height(20.dp)) + + Text(text = "Due Time", + style = MaterialTheme.typography.bodySmall + ) + Row(modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + + Text(text = millisToTimeString(state.dueTime), + style = MaterialTheme.typography.bodyLarge + ) + IconButton(onClick = { isTaskTimePickerOpen = true }) { + Icon(painter = painterResource(id = R.drawable.baseline_add_alarm_24), + contentDescription ="Select Due Time" ) + + } + + } + + + Spacer(modifier = Modifier.height(20.dp)) + + Text(text = "Set Alarm", + style = MaterialTheme.typography.bodySmall + ) + Row(modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + + Text(text = "Do you want to set alarm", + style = MaterialTheme.typography.bodyLarge + ) + CustomToggleButton( + checked = state.isReminderSet, + onCheckedClick = { + onEvent(TaskEvent.onSetReminderchanged(it)) + isAlarmSet = state.isReminderSet + } + ) + + } + + + Spacer(modifier = Modifier.height(10.dp)) + + Text(text = "Priority", + style = MaterialTheme.typography.bodySmall + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Row(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(5.dp)),) { + Priority.entries.forEach{ + PriorityButton( + modifier = Modifier.weight(1f), + label = it.name, + backgroundColor = it.color, + borderColor = if (it == state.priority){ + Color.White + } else Color.Transparent, + labelColor = if (it == state.priority){ + Color.White + } else Color.White.copy(alpha = 0.7f), + onClick = {onEvent(TaskEvent.OnPriorityChanged(it))} + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + Text(text = "Related to subject", + style = MaterialTheme.typography.bodySmall + ) + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val firstSubject = state.subjects.firstOrNull()?.name ?: "" + Text(text = state.relatedToSubject?:firstSubject, + style = MaterialTheme.typography.bodyLarge + ) + + IconButton(onClick = { isSubjectDropDownOpen=true }) { + Icon(imageVector = Icons.Default.KeyboardArrowDown, + contentDescription =state.relatedToSubject ) + + } + + } + + Button( + enabled = taskTitleError==null, + onClick = { + if (state.isReminderSet){ + setAlarm(context,state.dueDate!!,state.dueTime!!,state.title,state.currentTaskId!!) + } + else{ + cancelAlarm(context,state.currentTaskId!!) + } + onEvent(TaskEvent.SaveTask) + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + ) { + Text(text = "Save") + + } + + + + } +} +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TaskScreenTopBar( + isTaskExist: Boolean, + isCompleted: Boolean, + checkBoxBorderColor: Color, + onBackButtonClick: () -> Unit, + onDeleteButtonClick: () -> Unit, + onCheckBoxClick: () -> Unit +) +{ + TopAppBar( + navigationIcon = { + IconButton(onClick = onBackButtonClick ) { + Icon(imageVector = Icons.Default.ArrowBack, + contentDescription ="Navigate Back" ) + } + }, + title = { + Text(text = "Task") + }, + actions = { + if (isTaskExist) + { + TaskCheckBox(isCompleted = isCompleted, + borderColor = checkBoxBorderColor, + onCheckBoxClicked = onCheckBoxClick + ) + IconButton(onClick = onDeleteButtonClick ) { + Icon(imageVector = Icons.Default.Delete, + contentDescription ="Delete Task" ) + } + } + } + ) +} + + +@Composable +private fun PriorityButton( + modifier: Modifier = Modifier, + label: String, + backgroundColor: Color, + borderColor: Color, + labelColor: Color, + onClick: () -> Unit +) +{ + Box(modifier = modifier + .background(backgroundColor) + .clickable { onClick() } + .padding(5.dp) + .border(1.dp, borderColor, RoundedCornerShape(5.dp)) + .padding(5.dp), + contentAlignment = Alignment.Center + ){ + Text(text = label, + color = labelColor + ) + } + +} + + +fun setAlarm(context: Context, dueDate: Long, dueTime: Long, taskTitle: String, taskId: Int) { + val calendar = Calendar.getInstance().apply { + timeInMillis = dueDate + val dueTimeCalendar = Calendar.getInstance().apply { + timeInMillis = dueTime + } + set(Calendar.HOUR_OF_DAY, dueTimeCalendar.get(Calendar.HOUR_OF_DAY)) + set(Calendar.MINUTE, dueTimeCalendar.get(Calendar.MINUTE)) + set(Calendar.SECOND, 0) + } + + val timeInMillis = calendar.timeInMillis - (5 * 60 * 1000) // Set alarm 5 minutes before the due date and time + + Toast.makeText(context, "Alarm set for $timeInMillis", Toast.LENGTH_SHORT).show() + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val intent = Intent(context, AlarmReceiver::class.java).apply { + putExtra("taskTitle", taskTitle) + } + val pendingIntent = PendingIntent.getBroadcast(context, taskId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + alarmManager.set(AlarmManager.RTC_WAKEUP, timeInMillis, pendingIntent) +} + +private fun cancelAlarm(context: Context, taskId: Int) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val intent = Intent(context, AlarmReceiver::class.java) + val pendingIntent = PendingIntent.getBroadcast(context, taskId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + alarmManager.cancel(pendingIntent) +} + + diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/task/TaskState.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/task/TaskState.kt new file mode 100644 index 00000000..de4d1a7f --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/task/TaskState.kt @@ -0,0 +1,19 @@ +package com.example.studybuddy.view.task + +import com.example.studybuddy.domain.model.Subject +import com.example.studybuddy.utils.Priority + +data class TaskState( + val title:String ="", + val description:String ="", + val dueDate:Long? = null, + val dueTime: Long? = null, + val isReminderSet:Boolean = false, + val isTaskComplete:Boolean = false, + val priority: Priority = Priority.LOW, + val relatedToSubject:String? = null, + val subjects:List = emptyList(), + val subjectId:Int? = null, + val currentTaskId:Int? = null, + +) diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/task/TaskViewModel.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/task/TaskViewModel.kt new file mode 100644 index 00000000..d5732dd4 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/task/TaskViewModel.kt @@ -0,0 +1,231 @@ +package com.example.studybuddy.view.task + +import androidx.compose.material3.SnackbarDuration +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.studybuddy.domain.model.Task +import com.example.studybuddy.domain.model.repository.SubjectRepository +import com.example.studybuddy.domain.model.repository.TaskRepository +import com.example.studybuddy.utils.Priority +import com.example.studybuddy.utils.SnackbarEvent +import com.example.studybuddy.view.navArgs +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.time.Instant +import javax.inject.Inject + + +@HiltViewModel +class TaskViewModel @Inject constructor( +private val taskRepository: TaskRepository, + private val subjectRepository: SubjectRepository, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val navArgs:TaskScreenNavArgs = savedStateHandle.navArgs() + private val _state = MutableStateFlow(TaskState()) + val state = combine( + _state, + subjectRepository.getAllSubjects(), + ){ + state, subjects -> + state.copy(subjects = subjects) + }.stateIn( + scope = viewModelScope, + initialValue = TaskState(), + started = SharingStarted.WhileSubscribed(5000) + ) + + private val _snackbarEventFlow = MutableSharedFlow() + val snackbarEventFlow = _snackbarEventFlow.asSharedFlow() + + init { + fetchTaskById() + fetchSubject() + } + + + fun onEvent(event: TaskEvent){ + when(event){ + TaskEvent.DeleteTask -> deleteTask() + is TaskEvent.OnDateChanged -> { + _state.update { + it.copy(dueDate = event.millis) + } + } + is TaskEvent.OnDescriptionChanged -> { + _state.update { + it.copy(description = event.description) + } + } + TaskEvent.OnIsCompletedChanged -> { + _state.update { + it.copy(isTaskComplete = !_state.value.isTaskComplete, + isReminderSet = !_state.value.isReminderSet) + } + } + is TaskEvent.OnPriorityChanged -> { + _state.update { + it.copy(priority = event.priority) + } + } + is TaskEvent.OnRelatedSubjectSelect -> { + _state.update { + it.copy(relatedToSubject = event.subject.name, + subjectId = event.subject.SubjectId) + } + } + is TaskEvent.OnTitleChanged -> { + _state.update { + it.copy(title = event.title) + } + } + TaskEvent.SaveTask -> saveTask() + is TaskEvent.OnTimeChanged -> { + _state.update { + it.copy(dueTime = event.timeInMillis) + } + } + + is TaskEvent.onSetReminderchanged-> { + _state.value = state.value.copy(isReminderSet = event.setReminder) + } + + else -> {} + } + } + + private fun saveTask() { + viewModelScope.launch { + if(state.value.subjectId ==null|| state.value.relatedToSubject==null) + { + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Please fill all the fields before saving",SnackbarDuration.Long)) + + + return@launch + + } + + try { + withContext(Dispatchers.IO){//to run it on io thread + + taskRepository.upsertTask( + task = Task( + title = state.value.title, + description = state.value.description, + dueDate = state.value.dueDate ?: Instant.now().toEpochMilli(), + isCompleted = state.value.isTaskComplete, + priority = state.value.priority.value, + relatedToSubject = state.value.relatedToSubject!!, + taskSubjectId = state.value.subjectId!!, + TaskId = state.value.currentTaskId, + dueTime = state.value.dueTime?:Instant.now().toEpochMilli(), + setReminder = state.value.isReminderSet + ) + ) + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Task Saved successfully")) + _snackbarEventFlow.emit(SnackbarEvent.NavigateUp) + } + } + catch (e:Exception){ + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar( + "Failed to save Task ${e.message}", SnackbarDuration.Long)) + } + } + } + + + private fun deleteTask() + { + viewModelScope.launch { + try { + val currentTaskId = state.value.currentTaskId + + if (currentTaskId!=null) + { + withContext(Dispatchers.IO){ + taskRepository.deleteTaskById(currentTaskId) + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar("Task Deleted Successfully") + ) + _snackbarEventFlow.emit(SnackbarEvent.NavigateUp) + } + + } + + else{ + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar("Task not found") + ) + } + + } + catch (e:Exception) + { + _snackbarEventFlow.emit( + SnackbarEvent.ShowSnackbar("Failed to delete task. ${e.message}", + duration = SnackbarDuration.Long) + + ) + } + + } + } + + private fun fetchTaskById() { + viewModelScope.launch { + navArgs.taskId?.let { + taskRepository.getTaskByTaskId(it)?.let { task -> + _state.update { + it.copy( + title =task.title, + description = task.description, + dueDate = task.dueDate, + isTaskComplete = task.isCompleted, + priority = Priority.fromInt(task.priority), + relatedToSubject = task.relatedToSubject, + currentTaskId = task.TaskId, + subjectId = task.taskSubjectId, + dueTime = task.dueTime, + isReminderSet = task.setReminder + ) + } + } + } + } + } + + private fun fetchSubject(){ + viewModelScope.launch { + navArgs.subjectId?.let { id-> + subjectRepository + .getSubjectById(navArgs.subjectId)?.let { subject-> + _state.update { + it.copy( + subjectId = subject.SubjectId, + relatedToSubject = subject.name + ) + } + } + } + } + } + + + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/CloseActivity.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/CloseActivity.kt new file mode 100644 index 00000000..187e210e --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/CloseActivity.kt @@ -0,0 +1,11 @@ +package com.example.studybuddy.view.videocall + +import android.os.Bundle +import androidx.activity.ComponentActivity + +class CloseActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + finishAffinity() + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/ConferenceScreen.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/ConferenceScreen.kt new file mode 100644 index 00000000..6e8fc16a --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/ConferenceScreen.kt @@ -0,0 +1,70 @@ +package com.example.studybuddy.view.videocall + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.key +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import com.example.studybuddy.StudyBuddyApp +import com.example.studybuddy.view.components.ConfirmBackDialog +import com.example.studybuddy.view.components.SurfaceViewRendererComposable +import com.example.studybuddy.view.videocall.viewmodel.MainViewModel +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator + +@Destination + +@Composable +fun ConferenceScreenRoute( + roomId: String, + navigator: DestinationsNavigator, +) { + val mainViewModel: MainViewModel = hiltViewModel() + ConferenceScreen(roomId, navigator, mainViewModel) +} +@Composable +fun ConferenceScreen( + roomId: String, + navigator: DestinationsNavigator, + mainViewModel: MainViewModel +) { + val streamState = mainViewModel.mediaStreamsState.collectAsState().value ?: hashMapOf() + val totalNumberOfStreams = 1 + streamState.count { it.key != StudyBuddyApp.username } + + Column(Modifier.fillMaxSize()) { + Text(text = "Room name = $roomId") + + val streamModifier = Modifier.fillMaxWidth().weight(1f / totalNumberOfStreams) + + SurfaceViewRendererComposable( + modifier = streamModifier, + streamName = "Local", + onSurfaceReady = { mainViewModel.onRoomJoined(roomId!!, it) } + ) + + streamState.forEach { (streamId, mediaStream) -> + if (streamId != StudyBuddyApp.username) { + key(streamId) { + SurfaceViewRendererComposable( + modifier = streamModifier, + streamName = streamId, + onSurfaceReady = { surfaceView -> + mainViewModel.initRemoteSurfaceView(surfaceView) + mediaStream.videoTracks.firstOrNull()?.addSink(surfaceView) + } + ) + } + } + } + } + + ConfirmBackDialog { + mainViewModel::onLeaveConferenceClicked.invoke() + navigator.popBackStack() + + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/HomeScreen.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/HomeScreen.kt new file mode 100644 index 00000000..9e439035 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/HomeScreen.kt @@ -0,0 +1,127 @@ +package com.example.studybuddy.view.videocall + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.example.studybuddy.view.destinations.ConferenceScreenRouteDestination +import com.example.studybuddy.view.videocall.viewmodel.MainViewModel +import com.ramcosta.composedestinations.annotation.DeepLink +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator + + +@Destination( + deepLinks = [ + DeepLink(action = Intent.ACTION_VIEW, uriPattern = "study_buddy://videocall") + ] +) + +@Composable +fun HomeScreenRoute(navigator: DestinationsNavigator){ + val mainViewModel:MainViewModel = hiltViewModel() + + HomeScreen(mainViewModel, navigator) + +} + +@SuppressLint("InlinedApi") +@Composable +fun HomeScreen(mainViewModel: MainViewModel, + navigator: DestinationsNavigator // Add navigator as a parameter) { +){ + val context = LocalContext.current + val createRoomDialog = RoomNameAlertDialog(context, object : + RoomNameAlertDialog.RoomNameDialogListener { + override fun onCreateRoomName(roomName: String) { + mainViewModel.onCreateRoomClicked(roomName) + navigator.navigate(ConferenceScreenRouteDestination(roomName)) + } + }) + val requestPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + // All permissions are granted + if (permissions.all { it.value }) { + mainViewModel.init() + } + } + + LaunchedEffect(key1 = Unit){ + requestPermissionLauncher.launch( + arrayOf( + Manifest.permission.RECORD_AUDIO, + Manifest.permission.CAMERA, + Manifest.permission.POST_NOTIFICATIONS, + ) + ) + } + + val roomState = mainViewModel.roomsState.collectAsState() + Column(Modifier.fillMaxWidth().padding(top = 100.dp)) { + Button( + onClick = { + createRoomDialog.show() + }, + Modifier + .padding(10.dp) + .height(40.dp) + .fillMaxWidth() + ) { + Text(text = "Create Room") + } + roomState.value?.let { roomList -> + LazyColumn(Modifier.weight(15f)) { + items(items = roomList) { item -> + Row( + Modifier + .fillMaxWidth() + .padding(1.dp) + .border( + width = 1.dp, + color = Color.Gray, + shape = RoundedCornerShape(4.dp) + ) + .padding(5.dp) + .clickable { + navigator.navigate(ConferenceScreenRouteDestination(item.roomName)) + }, + horizontalArrangement = Arrangement.SpaceBetween, + + + ) { + Text(text = "Room name: ${item.roomName}") + Spacer(modifier = Modifier.padding(5.dp)) + Text(text = "Members: ${item.population}") + + } + } + } + + } + } + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/RoomNameAlertDialog.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/RoomNameAlertDialog.kt new file mode 100644 index 00000000..616d978a --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/RoomNameAlertDialog.kt @@ -0,0 +1,44 @@ +package com.example.studybuddy.view.videocall + +import android.app.AlertDialog +import android.content.Context +import android.text.TextUtils +import android.widget.EditText +import android.widget.Toast + +class RoomNameAlertDialog( + private val context: Context, + private val listener: RoomNameDialogListener +) { + + fun show() { + // Create an EditText for the room name input + val input = EditText(context).apply { + hint = "room name" + } + + // Create the AlertDialog + val dialog = AlertDialog.Builder(context).apply { + setTitle("Enter Room Name") + setView(input) + setPositiveButton("Create") { a, _ -> + val roomName = input.text.toString() + if (!TextUtils.isEmpty(roomName)) { + listener.onCreateRoomName(roomName) // Use the listener callback here + a.dismiss() + } else { + Toast.makeText(context, "Room name cannot be empty.", Toast.LENGTH_SHORT).show() + } + } + setNegativeButton("Cancel") { dialog, _ -> + dialog.cancel() + } + }.create() + + dialog.show() + } + + interface RoomNameDialogListener { + fun onCreateRoomName(roomName: String) + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/service/CallBroadcastReceiver.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/service/CallBroadcastReceiver.kt new file mode 100644 index 00000000..9e320ad9 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/service/CallBroadcastReceiver.kt @@ -0,0 +1,25 @@ +package com.example.studybuddy.view.videocall.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.example.studybuddy.view.videocall.CloseActivity + + +class CallBroadcastReceiver() : BroadcastReceiver() { + + override fun onReceive(context: Context?, intent: Intent?) { + intent?.action?.let { action -> + if (action == "ACTION_EXIT") { + context?.let { noneNullContext -> + CallService.stopService(noneNullContext) // Stop the service + noneNullContext.startActivity( + Intent(noneNullContext, CloseActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + ) + } + } + } + } +} diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/service/CallService.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/service/CallService.kt new file mode 100644 index 00000000..4db8665b --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/service/CallService.kt @@ -0,0 +1,374 @@ +package com.example.studybuddy.view.videocall.service + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Binder +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import com.example.studybuddy.MainActivity +import com.example.studybuddy.R +import com.example.studybuddy.StudyBuddyApp +import com.example.studybuddy.domain.model.MessageModel +import com.example.studybuddy.domain.model.RoomModel +import com.example.studybuddy.view.videocall.socket.SocketClient +import com.example.studybuddy.view.videocall.socket.SocketEventListener +import com.example.studybuddy.view.videocall.socket.SocketEventSender +import com.example.studybuddy.view.videocall.socket.SocketEvents.* +import com.example.studybuddy.view.videocall.webrtc.LocalStreamListener +import com.example.studybuddy.view.videocall.webrtc.MyPeerObserver +import com.example.studybuddy.view.videocall.webrtc.RTCClient +import com.example.studybuddy.view.videocall.webrtc.WebRTCFactory +import com.example.studybuddy.view.videocall.webrtc.WebRTCSignalListener +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import org.webrtc.IceCandidate +import org.webrtc.MediaStream +import org.webrtc.PeerConnection +import org.webrtc.SessionDescription +import org.webrtc.SurfaceViewRenderer +import javax.inject.Inject + +@AndroidEntryPoint +class CallService : Service(), SocketEventListener, WebRTCSignalListener { + + @Inject + lateinit var socketClient: SocketClient + + @Inject + lateinit var eventSender: SocketEventSender + + @Inject + lateinit var gson: Gson + + @Inject + lateinit var webRTCFactory: WebRTCFactory + + + //service section + private lateinit var mainNotification: NotificationCompat.Builder + private lateinit var notificationManager: NotificationManager + + //state + val roomsState: MutableStateFlow?> = MutableStateFlow(null) + val mediaStreamsState: MutableStateFlow> = MutableStateFlow( + hashMapOf() + ) + + private fun getMediaStreams() = mediaStreamsState.value + fun addMediaStreamToState(username: String, mediaStream: MediaStream) { + val updatedData = HashMap(getMediaStreams()).apply { + put(username, mediaStream) + } + mediaStreamsState.value = updatedData + } + + fun removeMediaStreamFromState(username: String) { + val updatedData = HashMap(getMediaStreams()).apply { + remove(username) + } + // Update the state with the new HashMap + mediaStreamsState.value = updatedData + } + + //connection list + private val connections: MutableMap = mutableMapOf() + + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + intent?.let { + when (it.action) { + CallServiceActions.START.name -> handleStartService() + CallServiceActions.STOP.name -> handleStopService() + else -> Unit + } + } + return START_STICKY + } + + private fun handleStartService() { + if (!isServiceRunning) { + isServiceRunning = true + Log.d("CallService", "Service started") + //start service here + startServiceWithNotification() + } + } + + private fun handleStopService() { + if (isServiceRunning) { + isServiceRunning = false + Log.d("CallService", "Service stopped") + } + socketClient.onStop() + connections.onEach { + runCatching { + it.value.onDestroy() + Log.d("CallService", "Connection destroyed: ${it.key}") + } + } + webRTCFactory.onDestroy() + stopForeground(STOP_FOREGROUND_REMOVE) + } + + override fun onCreate() { + super.onCreate() + notificationManager = getSystemService( + NotificationManager::class.java + ) + createNotifications() + socketClient.setListener(this) + Log.d("CallService", "Service created") + } + + + override fun onSocketOpened() { + Log.d("CallService", "Socket opened") + eventSender.storeUser() + } + + override fun onSocketClosed() { + } + + override fun onNewMessage(message: MessageModel) { + Log.d("CallService", "New message received: ${message.type}") + when (message.type) { + RoomStatus -> handleRoomStatus(message) + NewSession -> handleNewSession(message) + StartCall -> handleStartCall(message) + Offer -> handleOffer(message) + Answer -> handleAnswer(message) + Ice -> handleIceCandidates(message) + else -> Unit + } + } + + fun initializeSurface(view: SurfaceViewRenderer) { + webRTCFactory.init(view, object : LocalStreamListener { + override fun onLocalStreamReady(mediaStream: MediaStream) { + addMediaStreamToState(StudyBuddyApp.username, mediaStream) + Log.d("CallService", "Local stream ready for user: ${StudyBuddyApp.username}") + + } + }) + } + + private fun handleNewSession(message: MessageModel) { + message.name?.let { target -> + Log.d("CallService", "Handling new session for: $target") + startNewConnection(target) { + eventSender.startCall(target) + } + } + } + + private fun handleStartCall(message: MessageModel) { + //we create new connection here + startNewConnection(message.name!!) { + it.call() + } + } + + private fun startNewConnection(targetName: String, done: (RTCClient) -> Unit) { + Log.d("CallService", "Starting new connection for: $targetName") + webRTCFactory.createRtcClient(object : MyPeerObserver() { + override fun onIceCandidate(p0: IceCandidate?) { + super.onIceCandidate(p0) + findClient(targetName)?.let { + Log.d("CallService", "Sending ICE candidate to peer: $targetName") + if (p0 != null) { + it.sendIceCandidateToPeer(p0, targetName) + } + } + } + + override fun onAddStream(p0: MediaStream?) { + super.onAddStream(p0) + p0?.let { + addMediaStreamToState(targetName, it) + Log.d("CallService", "Sending ICE candidate to peer: $targetName") + } + } + + override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) { + super.onConnectionChange(newState) + Log.d("CallService", "Connection state changed for $targetName: $newState") + + if ( + newState == PeerConnection.PeerConnectionState.CLOSED || + newState == PeerConnection.PeerConnectionState.DISCONNECTED || + newState == PeerConnection.PeerConnectionState.FAILED + ) { + removeMediaStreamFromState(targetName) + } + } + }, targetName, this).also { + it?.let { + connections[targetName] = it + CoroutineScope(Dispatchers.IO).launch { + delay(1000) + done(it) + } + } + } + } + + private fun handleOffer(message: MessageModel) { + Log.d("CallService", "Handling offer from: ${message.name}") + findClient(message.name!!)?.let { + it.onRemoteSessionReceived( + SessionDescription( + SessionDescription.Type.OFFER, + message.data.toString() + ) + ) + it.answer() + } + } + + private fun handleAnswer(message: MessageModel) { + Log.d("CallService", "Handling answer from: ${message.name}") + findClient(message.name!!).apply { + this?.onRemoteSessionReceived( + SessionDescription( + SessionDescription.Type.ANSWER, + message.data.toString() + ) + ) + } + } + + private fun handleIceCandidates(message: MessageModel) { + Log.d("CallService", "Handling ICE candidates from: ${message.name}") + val ice = runCatching { + gson.fromJson(message.data.toString(), IceCandidate::class.java) + } + ice.onSuccess { + findClient(message.name!!).apply { + this?.addIceCandidateToPeer(it) + } + } + } + + fun leaveRoom(){ + connections.onEach { + it.value.onDestroy() + } + } + + private fun findClient(username: String): RTCClient? { + return connections[username] + } + + + private fun handleRoomStatus(message: MessageModel) { + val type = object : TypeToken>() {}.type + val rooms: List = gson.fromJson(message.data.toString(), type) + + roomsState.value = rooms + Log.d("CallService", "Room status updated: $rooms") + } + + + private fun startServiceWithNotification() { + startForeground(MAIN_NOTIFICATION_ID, mainNotification.build()) + Log.d("CallService", "Service running in foreground with notification") + } + + private val binder = LocalBinder() + + inner class LocalBinder : Binder() { + fun getService(): CallService = this@CallService + } + + override fun onBind(intent: Intent): IBinder { + return binder + } + + private fun createNotifications() { + val callChannel = NotificationChannel( + CALL_NOTIFICATION_CHANNEL_ID, + CALL_NOTIFICATION_CHANNEL_ID, + NotificationManager.IMPORTANCE_HIGH + ) + notificationManager.createNotificationChannel(callChannel) + val contentIntent = Intent( + this, MainActivity::class.java + ).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + val contentPendingIntent = PendingIntent.getActivity( + this, + System.currentTimeMillis().toInt(), + contentIntent, + PendingIntent.FLAG_IMMUTABLE + ) + + + val notificationChannel = NotificationChannel( + "chanel_terminal_bluetooth", + "chanel_terminal_bluetooth", + NotificationManager.IMPORTANCE_HIGH + ) + + + val intent = Intent(this, CallBroadcastReceiver::class.java).apply { + action = "ACTION_EXIT" + } + val pendingIntent: PendingIntent = + PendingIntent.getBroadcast(this, 0, intent, PendingIntent.FLAG_IMMUTABLE) + notificationManager.createNotificationChannel(notificationChannel) + mainNotification = NotificationCompat.Builder( + this, "chanel_terminal_bluetooth" + ).setSmallIcon(R.mipmap.ic_launcher) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setOnlyAlertOnce(false) + .addAction(R.mipmap.ic_launcher, "Exit", pendingIntent) + .setContentIntent(contentPendingIntent) + + Log.d("CallService", "Notification created") + } + + companion object { + var isServiceRunning = false + const val CALL_NOTIFICATION_CHANNEL_ID = "CALL_CHANNEL" + const val MAIN_NOTIFICATION_ID = 2323 + fun startService(context: Context) { + Log.d("CallService", "Starting service") + Thread { + startIntent(context, Intent(context, CallService::class.java).apply { + action = CallServiceActions.START.name + }) + }.start() + } + + fun stopService(context: Context) { + Log.d("CallService", "Stopping service") + startIntent(context, Intent(context, CallService::class.java).apply { + action = CallServiceActions.STOP.name + }) + } + + private fun startIntent(context: Context, intent: Intent) { + context.startForegroundService(intent) + } + } + + override fun onTransferEventToSocket(data: MessageModel) { + Log.d("CallService", "Transferring event to socket: ${data.type}") + socketClient.sendMessageToSocket(data) + } + + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/service/CallServiceActions.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/service/CallServiceActions.kt new file mode 100644 index 00000000..dcd0262d --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/service/CallServiceActions.kt @@ -0,0 +1,5 @@ +package com.example.studybuddy.view.videocall.service + +enum class CallServiceActions { + START,STOP +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/socket/SocketClient.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/socket/SocketClient.kt new file mode 100644 index 00000000..159ec328 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/socket/SocketClient.kt @@ -0,0 +1,89 @@ +package com.example.studybuddy.view.videocall.socket + +import com.example.studybuddy.domain.model.MessageModel +import com.google.gson.Gson +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.java_websocket.client.WebSocketClient +import org.java_websocket.handshake.ServerHandshake +import java.net.URI +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SocketClient @Inject constructor( + private val gson: Gson +) { + + + private var socketEventListener: SocketEventListener? = null + fun setListener(messageInterface: SocketEventListener) { + this.socketEventListener = messageInterface + } + + fun onStop() { + socketEventListener = null + runCatching { webSocket?.closeBlocking() } + } + + private var webSocket: WebSocketClient? = null + + init { + CoroutineScope(Dispatchers.IO).launch { + delay(1000) + initSocket() + } + } + + private fun initSocket() { + //if you are using android emulator your local websocket address is going to be "ws://10.0.2.2:3000" + //if you are using your phone as emulator your local address, use cmd and then write ipconfig + // and get your ethernet ipv4 , mine is : "ws://192.168.1.3:3000" + //but if your websocket is deployed you add your websocket address here + + webSocket = object : WebSocketClient(URI("wss://studdybuddyvideocall.onrender.com")) { + // webSocket = object : WebSocketClient(URI("ws://164.92.142.251:6000")) { + // webSocket = object : WebSocketClient(URI("ws://192.168.1.3:3000")) { + override fun onOpen(handshakedata: ServerHandshake?) { + socketEventListener?.onSocketOpened() + println("WebSocket connection established successfully!") + } + + override fun onMessage(message: String?) { + try { + socketEventListener?.onNewMessage( + gson.fromJson( + message, + MessageModel::class.java + ) + ) + + } catch (e: Exception) { + e.printStackTrace() + } + + } + + override fun onClose(code: Int, reason: String?, remote: Boolean) { + socketEventListener?.onSocketClosed() + + } + + override fun onError(ex: Exception?) { + } + + } + webSocket?.connect() + + } + + fun sendMessageToSocket(messageModel: MessageModel) { + runCatching { + webSocket?.send(Gson().toJson(messageModel)) + } + } + + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/socket/SocketEventListener.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/socket/SocketEventListener.kt new file mode 100644 index 00000000..cf2e963c --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/socket/SocketEventListener.kt @@ -0,0 +1,9 @@ +package com.example.studybuddy.view.videocall.socket + +import com.example.studybuddy.domain.model.MessageModel + +interface SocketEventListener { + fun onNewMessage(message: MessageModel) + fun onSocketOpened() + fun onSocketClosed() +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/socket/SocketEventSender.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/socket/SocketEventSender.kt new file mode 100644 index 00000000..05e50ab8 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/socket/SocketEventSender.kt @@ -0,0 +1,43 @@ +package com.example.studybuddy.view.videocall.socket + + +import com.example.studybuddy.StudyBuddyApp +import com.example.studybuddy.domain.model.MessageModel +import javax.inject.Inject + +class SocketEventSender @Inject constructor( + private val socketClient: SocketClient +) { + + private var username = StudyBuddyApp.username + + fun storeUser() { + socketClient.sendMessageToSocket( + MessageModel(type = SocketEvents.StoreUser, name = username) + ) + } + + fun createRoom(roomName: String) { + socketClient.sendMessageToSocket( + MessageModel(type = SocketEvents.CreateRoom, data = roomName, name = username) + ) + } + + fun joinRoom(roomName: String) { + socketClient.sendMessageToSocket( + MessageModel(type = SocketEvents.JoinRoom, data = roomName, name = username) + ) + } + + fun leaveAllRooms() { + socketClient.sendMessageToSocket( + MessageModel(type = SocketEvents.LeaveAllRooms, name = username) + ) + } + + fun startCall(target: String) { + socketClient.sendMessageToSocket( + MessageModel(type = SocketEvents.StartCall, name = username, target = target) + ) + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/socket/SocketEvents.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/socket/SocketEvents.kt new file mode 100644 index 00000000..81b95acc --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/socket/SocketEvents.kt @@ -0,0 +1,6 @@ +package com.example.studybuddy.view.videocall.socket + +enum class SocketEvents { + StoreUser,CreateRoom,JoinRoom,RoomStatus,LeaveAllRooms, + Offer, Answer, Ice,NewSession,StartCall +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/viewmodel/MainViewModel.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/viewmodel/MainViewModel.kt new file mode 100644 index 00000000..302a378e --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/viewmodel/MainViewModel.kt @@ -0,0 +1,116 @@ +package com.example.studybuddy.view.videocall.viewmodel + +import android.annotation.SuppressLint +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.util.Log + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.studybuddy.domain.model.RoomModel +import com.example.studybuddy.view.videocall.service.CallService +import com.example.studybuddy.view.videocall.service.CallServiceActions +import com.example.studybuddy.view.videocall.socket.SocketEventSender +import com.example.studybuddy.view.videocall.webrtc.WebRTCFactory +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.webrtc.MediaStream +import org.webrtc.SurfaceViewRenderer +import javax.inject.Inject + +@HiltViewModel +@SuppressLint("StaticFieldLeak") +class MainViewModel @Inject constructor( + private val context: Context, + private val eventSender: SocketEventSender, + private val webRTCFactory: WebRTCFactory +) : ViewModel() { + + private lateinit var callService: CallService + private var isBound = false + + //states + var roomsState: MutableStateFlow?> = MutableStateFlow(null) + var mediaStreamsState: MutableStateFlow?> = MutableStateFlow( + hashMapOf() + ) + + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(className: ComponentName?, service: IBinder?) { + val binder = service as CallService.LocalBinder + callService = binder.getService() + isBound = true + handleServiceBound() + Log.d("MainViewModel", "Service Bound Successfully") + } + + override fun onServiceDisconnected(arg0: ComponentName) { + isBound = false + Log.d("MainViewModel", "Service Disconnected") + } + } + + private fun handleServiceBound() { + callService.roomsState.onEach { rooms -> + roomsState.emit(rooms) + }.launchIn(viewModelScope) + + callService.mediaStreamsState.onEach { mediaStreams -> + // Directly propagate the state without setting a new value + mediaStreamsState.emit(mediaStreams) + }.launchIn(viewModelScope) + } + + fun onCreateRoomClicked(roomName: String) { + eventSender.createRoom(roomName) + } + + fun onRoomJoined(roomName: String, view:SurfaceViewRenderer) { + if (isBound) { // Ensure service is bound + callService.initializeSurface(view) + eventSender.joinRoom(roomName) + } else { + Log.e("MainViewModel", "CallService is not bound when trying to join room.") + // Handle the case where the service is not yet bound + } + } + + fun init() { + Intent(context, CallService::class.java).apply { + action = CallServiceActions.START.name + }.also { intent -> + CallService.startService(context) + context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) + } + + } + + + override fun onCleared() { + if (isBound) { + context.unbindService(serviceConnection) + isBound = false + Log.d("MainViewModel", "Service Unbound on ViewModel cleared.") + } + super.onCleared() + } + + fun onLeaveConferenceClicked() { + if (isBound) { + eventSender.leaveAllRooms() + callService.leaveRoom() + } else { + Log.e("MainViewModel", "CallService is not bound when trying to leave room.") + } + } + + fun initRemoteSurfaceView(view: SurfaceViewRenderer) { + webRTCFactory.initRemoteSurfaceView(view) + } + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/BluetoothManager.java b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/BluetoothManager.java new file mode 100644 index 00000000..89ed425d --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/BluetoothManager.java @@ -0,0 +1,439 @@ +package com.example.studybuddy.view.videocall.webrtc; + +import android.annotation.SuppressLint; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothHeadset; +import android.bluetooth.BluetoothProfile; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.os.Handler; +import android.os.Looper; +import android.os.Process; + +import org.webrtc.ThreadUtils; + +import java.util.List; +import java.util.Set; + +/** + * AppRTCProximitySensor manages functions related to Bluetoth devices in the + * AppRTC demo. + */ +@SuppressLint("MissingPermission") +public class BluetoothManager { + private static final String TAG = "AppRTCBluetoothManager"; + + // Timeout interval for starting or stopping audio to a Bluetooth SCO device. + private static final int BLUETOOTH_SCO_TIMEOUT_MS = 4000; + // Maximum number of SCO connection attempts. + private static final int MAX_SCO_CONNECTION_ATTEMPTS = 2; + private final Context apprtcContext; + private final RTCAudioManager apprtcAudioManager; + private final android.media.AudioManager audioManager; + private final Handler handler; + private final BluetoothProfile.ServiceListener bluetoothServiceListener; + private final BroadcastReceiver bluetoothHeadsetReceiver; + private int scoConnectionAttempts; + private State bluetoothState; + private BluetoothAdapter bluetoothAdapter; + private BluetoothHeadset bluetoothHeadset; + private BluetoothDevice bluetoothDevice; + // Runs when the Bluetooth timeout expires. We use that timeout after calling + // startScoAudio() or stopScoAudio() because we're not guaranteed to get a + // callback after those calls. + private final Runnable bluetoothTimeoutRunnable = this::bluetoothTimeout; + + private BluetoothManager(Context context, RTCAudioManager audioManager) { + ThreadUtils.checkIsOnMainThread(); + apprtcContext = context; + apprtcAudioManager = audioManager; + this.audioManager = getAudioManager(context); + bluetoothState = State.UNINITIALIZED; + bluetoothServiceListener = new BluetoothServiceListener(); + bluetoothHeadsetReceiver = new BluetoothHeadsetBroadcastReceiver(); + handler = new Handler(Looper.getMainLooper()); + } + + /** + * Construction. + */ + static BluetoothManager create(Context context, RTCAudioManager audioManager) { + return new BluetoothManager(context, audioManager); + } + + /** + * Returns the internal state. + */ + public State getState() { + ThreadUtils.checkIsOnMainThread(); + return bluetoothState; + } + + /** + * Activates components required to detect Bluetooth devices and to enable + * BT SCO (audio is routed via BT SCO) for the headset profile. The end + * state will be HEADSET_UNAVAILABLE but a state machine has started which + * will start a state change sequence where the final outcome depends on + * if/when the BT headset is enabled. + * Example of state change sequence when start() is called while BT device + * is connected and enabled: + * UNINITIALIZED --> HEADSET_UNAVAILABLE --> HEADSET_AVAILABLE --> + * SCO_CONNECTING --> SCO_CONNECTED <==> audio is now routed via BT SCO. + * Note that the AppRTCAudioManager is also involved in driving this state + * change. + */ + @SuppressLint("MissingPermission") + public void start() { + ThreadUtils.checkIsOnMainThread(); + if (!hasPermission()) { + return; + } + if (bluetoothState != State.UNINITIALIZED) { + return; + } + bluetoothHeadset = null; + bluetoothDevice = null; + scoConnectionAttempts = 0; + // Get a handle to the default local Bluetooth adapter. + bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); + if (bluetoothAdapter == null) { + return; + } + // Ensure that the device supports use of BT SCO audio for off call use cases. + if (!audioManager.isBluetoothScoAvailableOffCall()) { + return; + } + logBluetoothAdapterInfo(bluetoothAdapter); + // Establish a connection to the HEADSET profile (includes both Bluetooth Headset and + // Hands-Free) proxy object and install a listener. + if (!getBluetoothProfileProxy(apprtcContext, bluetoothServiceListener)) { + return; + } + // Register receivers for BluetoothHeadset change notifications. + IntentFilter bluetoothHeadsetFilter = new IntentFilter(); + // Register receiver for change in connection state of the Headset profile. + bluetoothHeadsetFilter.addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED); + // Register receiver for change in audio connection state of the Headset profile. + bluetoothHeadsetFilter.addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED); + registerReceiver(bluetoothHeadsetReceiver, bluetoothHeadsetFilter); + bluetoothState = State.HEADSET_UNAVAILABLE; + } + + /** + * Stops and closes all components related to Bluetooth audio. + */ + public void stop() { + ThreadUtils.checkIsOnMainThread(); + if (bluetoothAdapter == null) { + return; + } + // Stop BT SCO connection with remote device if needed. + stopScoAudio(); + // Close down remaining BT resources. + if (bluetoothState == State.UNINITIALIZED) { + return; + } + unregisterReceiver(bluetoothHeadsetReceiver); + cancelTimer(); + if (bluetoothHeadset != null) { + bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, bluetoothHeadset); + bluetoothHeadset = null; + } + bluetoothAdapter = null; + bluetoothDevice = null; + bluetoothState = State.UNINITIALIZED; + } + + /** + * Starts Bluetooth SCO connection with remote device. + * Note that the phone application always has the priority on the usage of the SCO connection + * for telephony. If this method is called while the phone is in call it will be ignored. + * Similarly, if a call is received or sent while an application is using the SCO connection, + * the connection will be lost for the application and NOT returned automatically when the call + * ends. Also note that: up to and including API version JELLY_BEAN_MR1, this method initiates a + * virtual voice call to the Bluetooth headset. After API version JELLY_BEAN_MR2 only a raw SCO + * audio connection is established. + * TODO(henrika): should we add support for virtual voice call to BT headset also for JBMR2 and + * higher. It might be required to initiates a virtual voice call since many devices do not + * accept SCO audio without a "call". + */ + public boolean startScoAudio() { + ThreadUtils.checkIsOnMainThread(); + if (scoConnectionAttempts >= MAX_SCO_CONNECTION_ATTEMPTS) { + return false; + } + if (bluetoothState != State.HEADSET_AVAILABLE) { + return false; + } + // Start BT SCO channel and wait for ACTION_AUDIO_STATE_CHANGED. + // The SCO connection establishment can take several seconds, hence we cannot rely on the + // connection to be available when the method returns but instead register to receive the + // intent ACTION_SCO_AUDIO_STATE_UPDATED and wait for the state to be SCO_AUDIO_STATE_CONNECTED. + bluetoothState = State.SCO_CONNECTING; + audioManager.startBluetoothSco(); + audioManager.setBluetoothScoOn(true); + scoConnectionAttempts++; + startTimer(); + return true; + } + + /** + * Stops Bluetooth SCO connection with remote device. + */ + public void stopScoAudio() { + ThreadUtils.checkIsOnMainThread(); + if (bluetoothState != State.SCO_CONNECTING && bluetoothState != State.SCO_CONNECTED) { + return; + } + cancelTimer(); + audioManager.stopBluetoothSco(); + audioManager.setBluetoothScoOn(false); + bluetoothState = State.SCO_DISCONNECTING; + } + + /** + * Use the BluetoothHeadset proxy object (controls the Bluetooth Headset + * Service via IPC) to update the list of connected devices for the HEADSET + * profile. The internal state will change to HEADSET_UNAVAILABLE or to + * HEADSET_AVAILABLE and |bluetoothDevice| will be mapped to the connected + * device if available. + */ + public void updateDevice() { + if (bluetoothState == State.UNINITIALIZED || bluetoothHeadset == null) { + return; + } + // Get connected devices for the headset profile. Returns the set of + // devices which are in state STATE_CONNECTED. The BluetoothDevice class + // is just a thin wrapper for a Bluetooth hardware address. + List devices = bluetoothHeadset.getConnectedDevices(); + if (devices.isEmpty()) { + bluetoothDevice = null; + bluetoothState = State.HEADSET_UNAVAILABLE; + } else { + // Always use first device in list. Android only supports one device. + bluetoothDevice = devices.get(0); + bluetoothState = State.HEADSET_AVAILABLE; + } + } + + /** + * Stubs for test mocks. + */ + protected android.media.AudioManager getAudioManager(Context context) { + return (android.media.AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + } + + protected void registerReceiver(BroadcastReceiver receiver, IntentFilter filter) { + apprtcContext.registerReceiver(receiver, filter); + } + + protected void unregisterReceiver(BroadcastReceiver receiver) { + apprtcContext.unregisterReceiver(receiver); + } + + protected boolean getBluetoothProfileProxy(Context context, BluetoothProfile.ServiceListener listener) { + return bluetoothAdapter.getProfileProxy(context, listener, BluetoothProfile.HEADSET); + } + + protected boolean hasPermission() { + return apprtcContext.checkPermission(android.Manifest.permission.BLUETOOTH, Process.myPid(), Process.myUid()) == PackageManager.PERMISSION_GRANTED; + } + + /** + * Logs the state of the local Bluetooth adapter. + */ + @SuppressLint("HardwareIds") + protected void logBluetoothAdapterInfo(BluetoothAdapter localAdapter) { + // Log the set of BluetoothDevice objects that are bonded (paired) to the local adapter. + Set pairedDevices = localAdapter.getBondedDevices(); + + } + + /** + * Ensures that the audio manager updates its list of available audio devices. + */ + private void updateAudioDeviceState() { + ThreadUtils.checkIsOnMainThread(); + apprtcAudioManager.updateAudioDeviceState(); + } + + /** + * Starts timer which times out after BLUETOOTH_SCO_TIMEOUT_MS milliseconds. + */ + private void startTimer() { + ThreadUtils.checkIsOnMainThread(); + handler.postDelayed(bluetoothTimeoutRunnable, BLUETOOTH_SCO_TIMEOUT_MS); + } + + /** + * Cancels any outstanding timer tasks. + */ + private void cancelTimer() { + ThreadUtils.checkIsOnMainThread(); + handler.removeCallbacks(bluetoothTimeoutRunnable); + } + + /** + * Called when start of the BT SCO channel takes too long time. Usually + * happens when the BT device has been turned on during an ongoing call. + */ + private void bluetoothTimeout() { + ThreadUtils.checkIsOnMainThread(); + if (bluetoothState == State.UNINITIALIZED || bluetoothHeadset == null) { + return; + } + if (bluetoothState != State.SCO_CONNECTING) { + return; + } + // Bluetooth SCO should be connecting; check the latest result. + boolean scoConnected = false; + List devices = bluetoothHeadset.getConnectedDevices(); + if (devices.size() > 0) { + bluetoothDevice = devices.get(0); + if (bluetoothHeadset.isAudioConnected(bluetoothDevice)) { + scoConnected = true; + } + } + if (scoConnected) { + // We thought BT had timed out, but it's actually on; updating state. + bluetoothState = State.SCO_CONNECTED; + scoConnectionAttempts = 0; + } else { + // Give up and "cancel" our request by calling stopBluetoothSco(). + stopScoAudio(); + } + updateAudioDeviceState(); + } + + /** + * Checks whether audio uses Bluetooth SCO. + */ + private boolean isScoOn() { + return audioManager.isBluetoothScoOn(); + } + + /** + * Converts BluetoothAdapter states into local string representations. + */ + private String stateToString(int state) { + switch (state) { + case BluetoothAdapter.STATE_DISCONNECTED: + return "DISCONNECTED"; + case BluetoothAdapter.STATE_CONNECTED: + return "CONNECTED"; + case BluetoothAdapter.STATE_CONNECTING: + return "CONNECTING"; + case BluetoothAdapter.STATE_DISCONNECTING: + return "DISCONNECTING"; + case BluetoothAdapter.STATE_OFF: + return "OFF"; + case BluetoothAdapter.STATE_ON: + return "ON"; + case BluetoothAdapter.STATE_TURNING_OFF: + // Indicates the local Bluetooth adapter is turning off. Local clients should immediately + // attempt graceful disconnection of any remote links. + return "TURNING_OFF"; + case BluetoothAdapter.STATE_TURNING_ON: + // Indicates the local Bluetooth adapter is turning on. However local clients should wait + // for STATE_ON before attempting to use the adapter. + return "TURNING_ON"; + default: + return "INVALID"; + } + } + + // Bluetooth connection state. + public enum State { + // Bluetooth is not available; no adapter or Bluetooth is off. + UNINITIALIZED, // Bluetooth error happened when trying to start Bluetooth. + ERROR, // Bluetooth proxy object for the Headset profile exists, but no connected headset devices, + // SCO is not started or disconnected. + HEADSET_UNAVAILABLE, // Bluetooth proxy object for the Headset profile connected, connected Bluetooth headset + // present, but SCO is not started or disconnected. + HEADSET_AVAILABLE, // Bluetooth audio SCO connection with remote device is closing. + SCO_DISCONNECTING, // Bluetooth audio SCO connection with remote device is initiated. + SCO_CONNECTING, // Bluetooth audio SCO connection with remote device is established. + SCO_CONNECTED + } + + /** + * Implementation of an interface that notifies BluetoothProfile IPC clients when they have been + * connected to or disconnected from the service. + */ + private class BluetoothServiceListener implements BluetoothProfile.ServiceListener { + @Override + // Called to notify the client when the proxy object has been connected to the service. + // Once we have the profile proxy object, we can use it to monitor the state of the + // connection and perform other operations that are relevant to the headset profile. + public void onServiceConnected(int profile, BluetoothProfile proxy) { + if (profile != BluetoothProfile.HEADSET || bluetoothState == State.UNINITIALIZED) { + return; + } + // Android only supports one connected Bluetooth Headset at a time. + bluetoothHeadset = (BluetoothHeadset) proxy; + updateAudioDeviceState(); + } + + @Override + public void onServiceDisconnected(int profile) { + if (profile != BluetoothProfile.HEADSET || bluetoothState == State.UNINITIALIZED) { + return; + } + stopScoAudio(); + bluetoothHeadset = null; + bluetoothDevice = null; + bluetoothState = State.HEADSET_UNAVAILABLE; + updateAudioDeviceState(); + } + } + + // Intent broadcast receiver which handles changes in Bluetooth device availability. + // Detects headset changes and Bluetooth SCO state changes. + private class BluetoothHeadsetBroadcastReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (bluetoothState == State.UNINITIALIZED) { + return; + } + final String action = intent.getAction(); + // Change in connection state of the Headset profile. Note that the + // change does not tell us anything about whether we're streaming + // audio to BT over SCO. Typically received when user turns on a BT + // headset while audio is active using another audio device. + if (action.equals(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)) { + final int state = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED); + if (state == BluetoothHeadset.STATE_CONNECTED) { + scoConnectionAttempts = 0; + updateAudioDeviceState(); + } else if (state == BluetoothHeadset.STATE_DISCONNECTED) { + // Bluetooth is probably powered off during the call. + stopScoAudio(); + updateAudioDeviceState(); + } + // Change in the audio (SCO) connection state of the Headset profile. + // Typically received after call to startScoAudio() has finalized. + } else if (action.equals(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)) { + final int state = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_AUDIO_DISCONNECTED); + if (state == BluetoothHeadset.STATE_AUDIO_CONNECTED) { + cancelTimer(); + if (bluetoothState == State.SCO_CONNECTING) { + bluetoothState = State.SCO_CONNECTED; + scoConnectionAttempts = 0; + updateAudioDeviceState(); + } else { + } + } else if (state == BluetoothHeadset.STATE_AUDIO_CONNECTING) { + } else if (state == BluetoothHeadset.STATE_AUDIO_DISCONNECTED) { + if (isInitialStickyBroadcast()) { + return; + } + updateAudioDeviceState(); + } + } + } + } +} diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/LocalStreamListener.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/LocalStreamListener.kt new file mode 100644 index 00000000..3ca9dc4d --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/LocalStreamListener.kt @@ -0,0 +1,7 @@ +package com.example.studybuddy.view.videocall.webrtc + +import org.webrtc.MediaStream + +interface LocalStreamListener { + fun onLocalStreamReady(mediaStream: MediaStream) +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/MyPeerObserver.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/MyPeerObserver.kt new file mode 100644 index 00000000..bf520212 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/MyPeerObserver.kt @@ -0,0 +1,39 @@ +package com.example.studybuddy.view.videocall.webrtc + +import org.webrtc.* + +open class MyPeerObserver : PeerConnection.Observer{ + override fun onSignalingChange(p0: PeerConnection.SignalingState?) { + + } + + override fun onIceConnectionChange(p0: PeerConnection.IceConnectionState?) { + } + + override fun onIceConnectionReceivingChange(p0: Boolean) { + } + + override fun onIceGatheringChange(p0: PeerConnection.IceGatheringState?) { + } + + override fun onIceCandidate(p0: IceCandidate?) { + } + + override fun onIceCandidatesRemoved(p0: Array?) { + } + + override fun onAddStream(p0: MediaStream?) { + } + + override fun onRemoveStream(p0: MediaStream?) { + } + + override fun onDataChannel(p0: DataChannel?) { + } + + override fun onRenegotiationNeeded() { + } + + override fun onAddTrack(p0: RtpReceiver?, p1: Array?) { + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/MySdpObserver.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/MySdpObserver.kt new file mode 100644 index 00000000..78d1d381 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/MySdpObserver.kt @@ -0,0 +1,18 @@ +package com.example.studybuddy.view.videocall.webrtc + +import org.webrtc.SdpObserver +import org.webrtc.SessionDescription + +open class MySdpObserver : SdpObserver{ + override fun onCreateSuccess(desc: SessionDescription?) { + } + + override fun onSetSuccess() { + } + + override fun onCreateFailure(p0: String?) { + } + + override fun onSetFailure(p0: String?) { + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/ProximitySensor.java b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/ProximitySensor.java new file mode 100644 index 00000000..dbb71737 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/ProximitySensor.java @@ -0,0 +1,140 @@ +package com.example.studybuddy.view.videocall.webrtc; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; + +import org.webrtc.ThreadUtils; + +/** + * AppRTCProximitySensor manages functions related to the proximity sensor in + * the AppRTC demo. + * On most device, the proximity sensor is implemented as a boolean-sensor. + * It returns just two values "NEAR" or "FAR". Thresholding is done on the LUX + * value i.e. the LUX value of the light sensor is compared with a threshold. + * A LUX-value more than the threshold means the proximity sensor returns "FAR". + * Anything less than the threshold value and the sensor returns "NEAR". + */ +@SuppressLint("MissingPermission") +public class ProximitySensor implements SensorEventListener { + private static final String TAG = ProximitySensor.class.getSimpleName(); + + // This class should be created, started and stopped on one thread + // (e.g. the main thread). We use |nonThreadSafe| to ensure that this is + // the case. Only active when |DEBUG| is set to true. + private final ThreadUtils.ThreadChecker threadChecker = new ThreadUtils.ThreadChecker(); + + private final Runnable onSensorStateListener; + private final SensorManager sensorManager; + private Sensor proximitySensor = null; + private boolean lastStateReportIsNear = false; + + private ProximitySensor(Context context, Runnable sensorStateListener) { + onSensorStateListener = sensorStateListener; + sensorManager = ((SensorManager) context.getSystemService(Context.SENSOR_SERVICE)); + } + + /** + * Construction + */ + static ProximitySensor create(Context context, Runnable sensorStateListener) { + return new ProximitySensor(context, sensorStateListener); + } + + /** + * Activate the proximity sensor. Also do initialization if called for the + * first time. + */ + public boolean start() { + threadChecker.checkIsOnValidThread(); + if (!initDefaultSensor()) { + // Proximity sensor is not supported on this device. + return false; + } + sensorManager.registerListener(this, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL); + return true; + } + + /** + * Deactivate the proximity sensor. + */ + public void stop() { + threadChecker.checkIsOnValidThread(); + if (proximitySensor == null) { + return; + } + sensorManager.unregisterListener(this, proximitySensor); + } + + /** + * Getter for last reported state. Set to true if "near" is reported. + */ + public boolean sensorReportsNearState() { + threadChecker.checkIsOnValidThread(); + return lastStateReportIsNear; + } + + @Override + public final void onAccuracyChanged(Sensor sensor, int accuracy) { + threadChecker.checkIsOnValidThread(); + + } + + @Override + public final void onSensorChanged(SensorEvent event) { + threadChecker.checkIsOnValidThread(); + // As a best practice; do as little as possible within this method and + // avoid blocking. + float distanceInCentimeters = event.values[0]; + lastStateReportIsNear = distanceInCentimeters < proximitySensor.getMaximumRange(); + + // Report about new state to listening client. Client can then call + // sensorReportsNearState() to query the current state (NEAR or FAR). + if (onSensorStateListener != null) { + onSensorStateListener.run(); + } + + } + + /** + * Get default proximity sensor if it exists. Tablet devices (e.g. Nexus 7) + * does not support this type of sensor and false will be returned in such + * cases. + */ + private boolean initDefaultSensor() { + if (proximitySensor != null) { + return true; + } + proximitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY); + if (proximitySensor == null) { + return false; + } + logProximitySensorInfo(); + return true; + } + + /** + * Helper method for logging information about the proximity sensor. + */ + private void logProximitySensorInfo() { + if (proximitySensor == null) { + return; + } + StringBuilder info = new StringBuilder("Proximity sensor: "); + info.append("name=").append(proximitySensor.getName()); + info.append(", vendor: ").append(proximitySensor.getVendor()); + info.append(", power: ").append(proximitySensor.getPower()); + info.append(", resolution: ").append(proximitySensor.getResolution()); + info.append(", max range: ").append(proximitySensor.getMaximumRange()); + info.append(", min delay: ").append(proximitySensor.getMinDelay()); + // Added in API level 20. + info.append(", type: ").append(proximitySensor.getStringType()); + // Added in API level 21. + info.append(", max delay: ").append(proximitySensor.getMaxDelay()); + info.append(", reporting mode: ").append(proximitySensor.getReportingMode()); + info.append(", isWakeUpSensor: ").append(proximitySensor.isWakeUpSensor()); + } +} diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/RTCAudioManager.java b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/RTCAudioManager.java new file mode 100644 index 00000000..4d84a1cc --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/RTCAudioManager.java @@ -0,0 +1,523 @@ +package com.example.studybuddy.view.videocall.webrtc; + +import android.annotation.SuppressLint; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.media.AudioDeviceInfo; +import android.preference.PreferenceManager; + +import org.webrtc.ThreadUtils; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + + +/** + * AppRTCAudioManager manages all audio related parts of the AppRTC demo. + */ +@SuppressLint("MissingPermission") +public class RTCAudioManager { + private static final String TAG = RTCAudioManager.class.getSimpleName(); + private static final String SPEAKERPHONE_AUTO = "auto"; + private static final String SPEAKERPHONE_TRUE = "true"; + private static final String SPEAKERPHONE_FALSE = "false"; + private final Context apprtcContext; + // Contains speakerphone setting: auto, true or false + private final String useSpeakerphone; + // Handles all tasks related to Bluetooth headset devices. + private final BluetoothManager bluetoothManager; + private final android.media.AudioManager audioManager; + private AudioManagerEvents audioManagerEvents; + private AudioManagerState amState; + private int savedAudioMode = android.media.AudioManager.MODE_INVALID; + private boolean savedIsSpeakerPhoneOn = false; + private boolean savedIsMicrophoneMute = false; + private boolean hasWiredHeadset = false; + // Default audio device; speaker phone for video calls or earpiece for audio + // only calls. + private AudioDevice defaultAudioDevice; + // Contains the currently selected audio device. + // This device is changed automatically using a certain scheme where e.g. + // a wired headset "wins" over speaker phone. It is also possible for a + // user to explicitly select a device (and overrid any predefined scheme). + // See |userSelectedAudioDevice| for details. + private AudioDevice selectedAudioDevice; + // Contains the user-selected audio device which overrides the predefined + // selection scheme. + // TODO(henrika): always set to AudioDevice.NONE today. Add support for + // explicit selection based on choice by userSelectedAudioDevice. + private AudioDevice userSelectedAudioDevice; + // Proximity sensor object. It measures the proximity of an object in cm + // relative to the view screen of a device and can therefore be used to + // assist device switching (close to ear <=> use headset earpiece if + // available, far from ear <=> use speaker phone). + private ProximitySensor proximitySensor; + // Contains a list of available audio devices. A Set collection is used to + // avoid duplicate elements. + private Set audioDevices = new HashSet<>(); + // Broadcast receiver for wired headset intent broadcasts. + private final BroadcastReceiver wiredHeadsetReceiver; + // Callback method for changes in audio focus. + private android.media.AudioManager.OnAudioFocusChangeListener audioFocusChangeListener; + + private RTCAudioManager(Context context) { + ThreadUtils.checkIsOnMainThread(); + apprtcContext = context; + audioManager = ((android.media.AudioManager) context.getSystemService(Context.AUDIO_SERVICE)); + bluetoothManager = BluetoothManager.create(context, this); + wiredHeadsetReceiver = new WiredHeadsetReceiver(); + amState = AudioManagerState.UNINITIALIZED; + + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + useSpeakerphone = sharedPreferences.getString("speakerphone_preference", "auto"); + if (useSpeakerphone.equals(SPEAKERPHONE_FALSE)) { + defaultAudioDevice = AudioDevice.EARPIECE; + } else { + defaultAudioDevice = AudioDevice.SPEAKER_PHONE; + } + + // Create and initialize the proximity sensor. + // Tablet devices (e.g. Nexus 7) does not support proximity sensors. + // Note that, the sensor will not be active until start() has been called. + proximitySensor = ProximitySensor.create(context, new Runnable() { + // This method will be called each time a state change is detected. + // Example: user holds his hand over the device (closer than ~5 cm), + // or removes his hand from the device. + public void run() { + onProximitySensorChangedState(); + } + }); + + } + + /** + * Construction. + */ + public static RTCAudioManager create(Context context) { + return new RTCAudioManager(context); + } + + /** + * This method is called when the proximity sensor reports a state change, + * e.g. from "NEAR to FAR" or from "FAR to NEAR". + */ + private void onProximitySensorChangedState() { + if (!useSpeakerphone.equals(SPEAKERPHONE_AUTO)) { + return; + } + + // The proximity sensor should only be activated when there are exactly two + // available audio devices. + if (audioDevices.size() == 2 && audioDevices.contains(AudioDevice.EARPIECE) && audioDevices.contains(AudioDevice.SPEAKER_PHONE)) { + if (proximitySensor.sensorReportsNearState()) { + // Sensor reports that a "handset is being held up to a person's ear", + // or "something is covering the light sensor". + setAudioDeviceInternal(AudioDevice.EARPIECE); + } else { + // Sensor reports that a "handset is removed from a person's ear", or + // "the light sensor is no longer covered". + setAudioDeviceInternal(AudioDevice.SPEAKER_PHONE); + } + } + } + + public void start(AudioManagerEvents audioManagerEvents) { + ThreadUtils.checkIsOnMainThread(); + if (amState == AudioManagerState.RUNNING) { + return; + } + // TODO(henrika): perhaps call new method called preInitAudio() here if UNINITIALIZED. + + this.audioManagerEvents = audioManagerEvents; + amState = AudioManagerState.RUNNING; + + // Store current audio state so we can restore it when stop() is called. + savedAudioMode = audioManager.getMode(); + savedIsSpeakerPhoneOn = audioManager.isSpeakerphoneOn(); + savedIsMicrophoneMute = audioManager.isMicrophoneMute(); + hasWiredHeadset = hasWiredHeadset(); + + // Create an AudioManager.OnAudioFocusChangeListener instance. + // Called on the listener to notify if the audio focus for this listener has been changed. +// The |focusChange| value indicates whether the focus was gained, whether the focus was lost, +// and whether that loss is transient, or whether the new focus holder will hold it for an +// unknown amount of time. +// TODO(henrika): possibly extend support of handling audio-focus changes. Only contains +// logging for now. + audioFocusChangeListener = focusChange -> { + String typeOfChange = "AUDIOFOCUS_NOT_DEFINED"; + switch (focusChange) { + case android.media.AudioManager.AUDIOFOCUS_GAIN: + typeOfChange = "AUDIOFOCUS_GAIN"; + break; + case android.media.AudioManager.AUDIOFOCUS_GAIN_TRANSIENT: + typeOfChange = "AUDIOFOCUS_GAIN_TRANSIENT"; + break; + case android.media.AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE: + typeOfChange = "AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE"; + break; + case android.media.AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK: + typeOfChange = "AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK"; + break; + case android.media.AudioManager.AUDIOFOCUS_LOSS: + typeOfChange = "AUDIOFOCUS_LOSS"; + break; + case android.media.AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + typeOfChange = "AUDIOFOCUS_LOSS_TRANSIENT"; + break; + case android.media.AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + typeOfChange = "AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK"; + break; + default: + typeOfChange = "AUDIOFOCUS_INVALID"; + break; + } + }; + + // Request audio playout focus (without ducking) and install listener for changes in focus. + int result = audioManager.requestAudioFocus(audioFocusChangeListener, android.media.AudioManager.STREAM_VOICE_CALL, android.media.AudioManager.AUDIOFOCUS_GAIN_TRANSIENT); + + // Start by setting MODE_IN_COMMUNICATION as default audio mode. It is + // required to be in this mode when playout and/or recording starts for + // best possible VoIP performance. + audioManager.setMode(android.media.AudioManager.MODE_IN_COMMUNICATION); + + // Always disable microphone mute during a WebRTC call. + setMicrophoneMute(false); + + // Set initial device states. + userSelectedAudioDevice = AudioDevice.NONE; + selectedAudioDevice = AudioDevice.NONE; + audioDevices.clear(); + + // Initialize and start Bluetooth if a BT device is available or initiate + // detection of new (enabled) BT devices. + bluetoothManager.start(); + + // Do initial selection of audio device. This setting can later be changed + // either by adding/removing a BT or wired headset or by covering/uncovering + // the proximity sensor. + updateAudioDeviceState(); + + // Register receiver for broadcast intents related to adding/removing a + // wired headset. + registerReceiver(wiredHeadsetReceiver, new IntentFilter(Intent.ACTION_HEADSET_PLUG)); + } + + @SuppressLint("WrongConstant") + public void stop() { + ThreadUtils.checkIsOnMainThread(); + if (amState != AudioManagerState.RUNNING) { + return; + } + amState = AudioManagerState.UNINITIALIZED; + + unregisterReceiver(wiredHeadsetReceiver); + + bluetoothManager.stop(); + + // Restore previously stored audio states. + setSpeakerphoneOn(savedIsSpeakerPhoneOn); + setMicrophoneMute(savedIsMicrophoneMute); + audioManager.setMode(savedAudioMode); + + // Abandon audio focus. Gives the previous focus owner, if any, focus. + audioManager.abandonAudioFocus(audioFocusChangeListener); + audioFocusChangeListener = null; + + if (proximitySensor != null) { + proximitySensor.stop(); + proximitySensor = null; + } + + audioManagerEvents = null; + } + + /** + * Changes selection of the currently active audio device. + */ + private void setAudioDeviceInternal(AudioDevice device) { + switch (device) { + case SPEAKER_PHONE: + setSpeakerphoneOn(true); + break; + case EARPIECE: + case BLUETOOTH: + case WIRED_HEADSET: + setSpeakerphoneOn(false); + break; + default: + break; + } + selectedAudioDevice = device; + } + + /** + * Changes default audio device. + * TODO(henrika): add usage of this method in the AppRTCMobile client. + */ + public void setDefaultAudioDevice(AudioDevice defaultDevice) { + ThreadUtils.checkIsOnMainThread(); + switch (defaultDevice) { + case SPEAKER_PHONE: + defaultAudioDevice = defaultDevice; + break; + case EARPIECE: + if (hasEarpiece()) { + defaultAudioDevice = defaultDevice; + } else { + defaultAudioDevice = AudioDevice.SPEAKER_PHONE; + } + break; + default: + break; + } + updateAudioDeviceState(); + } + + /** + * Changes selection of the currently active audio device. + */ + public void selectAudioDevice(AudioDevice device) { + ThreadUtils.checkIsOnMainThread(); + if (!audioDevices.contains(device)) { + } + userSelectedAudioDevice = device; + updateAudioDeviceState(); + } + + /** + * Returns current set of available/selectable audio devices. + */ + public Set getAudioDevices() { + ThreadUtils.checkIsOnMainThread(); + return Collections.unmodifiableSet(new HashSet<>(audioDevices)); + } + + /** + * Returns the currently selected audio device. + */ + public AudioDevice getSelectedAudioDevice() { + ThreadUtils.checkIsOnMainThread(); + return selectedAudioDevice; + } + + /** + * Helper method for receiver registration. + */ + private void registerReceiver(BroadcastReceiver receiver, IntentFilter filter) { + apprtcContext.registerReceiver(receiver, filter); + } + + /** + * Helper method for unregistration of an existing receiver. + */ + private void unregisterReceiver(BroadcastReceiver receiver) { + apprtcContext.unregisterReceiver(receiver); + } + + /** + * Sets the speaker phone mode. + */ + private void setSpeakerphoneOn(boolean on) { + boolean wasOn = audioManager.isSpeakerphoneOn(); + if (wasOn == on) { + return; + } + audioManager.setSpeakerphoneOn(on); + } + + /** + * Sets the microphone mute state. + */ + private void setMicrophoneMute(boolean on) { + boolean wasMuted = audioManager.isMicrophoneMute(); + if (wasMuted == on) { + return; + } + audioManager.setMicrophoneMute(on); + } + + /** + * Gets the current earpiece state. + */ + private boolean hasEarpiece() { + return apprtcContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEPHONY); + } + + /** + * Checks whether a wired headset is connected or not. + * This is not a valid indication that audio playback is actually over + * the wired headset as audio routing depends on other conditions. We + * only use it as an early indicator (during initialization) of an attached + * wired headset. + */ + @Deprecated + private boolean hasWiredHeadset() { + @SuppressLint("WrongConstant") final AudioDeviceInfo[] devices = audioManager.getDevices(android.media.AudioManager.GET_DEVICES_ALL); + for (AudioDeviceInfo device : devices) { + final int type = device.getType(); + if (type == AudioDeviceInfo.TYPE_WIRED_HEADSET) { + return true; + } else if (type == AudioDeviceInfo.TYPE_USB_DEVICE) { + return true; + } + } + return false; + } + + /** + * Updates list of possible audio devices and make new device selection. + * TODO(henrika): add unit test to verify all state transitions. + */ + public void updateAudioDeviceState() { + ThreadUtils.checkIsOnMainThread(); + + // Check if any Bluetooth headset is connected. The internal BT state will + // change accordingly. + // TODO(henrika): perhaps wrap required state into BT manager. + if (bluetoothManager.getState() == BluetoothManager.State.HEADSET_AVAILABLE || bluetoothManager.getState() == BluetoothManager.State.HEADSET_UNAVAILABLE || bluetoothManager.getState() == BluetoothManager.State.SCO_DISCONNECTING) { + bluetoothManager.updateDevice(); + } + + // Update the set of available audio devices. + Set newAudioDevices = new HashSet<>(); + + if (bluetoothManager.getState() == BluetoothManager.State.SCO_CONNECTED || bluetoothManager.getState() == BluetoothManager.State.SCO_CONNECTING || bluetoothManager.getState() == BluetoothManager.State.HEADSET_AVAILABLE) { + newAudioDevices.add(AudioDevice.BLUETOOTH); + } + + if (hasWiredHeadset) { + // If a wired headset is connected, then it is the only possible option. + newAudioDevices.add(AudioDevice.WIRED_HEADSET); + } else { + // No wired headset, hence the audio-device list can contain speaker + // phone (on a tablet), or speaker phone and earpiece (on mobile phone). + newAudioDevices.add(AudioDevice.SPEAKER_PHONE); + if (hasEarpiece()) { + newAudioDevices.add(AudioDevice.EARPIECE); + } + } + // Store state which is set to true if the device list has changed. + boolean audioDeviceSetUpdated = !audioDevices.equals(newAudioDevices); + // Update the existing audio device set. + audioDevices = newAudioDevices; + // Correct user selected audio devices if needed. + if (bluetoothManager.getState() == BluetoothManager.State.HEADSET_UNAVAILABLE && userSelectedAudioDevice == AudioDevice.BLUETOOTH) { + // If BT is not available, it can't be the user selection. + userSelectedAudioDevice = AudioDevice.NONE; + } + if (hasWiredHeadset && userSelectedAudioDevice == AudioDevice.SPEAKER_PHONE) { + // If user selected speaker phone, but then plugged wired headset then make + // wired headset as user selected device. + userSelectedAudioDevice = AudioDevice.WIRED_HEADSET; + } + if (!hasWiredHeadset && userSelectedAudioDevice == AudioDevice.WIRED_HEADSET) { + // If user selected wired headset, but then unplugged wired headset then make + // speaker phone as user selected device. + userSelectedAudioDevice = AudioDevice.SPEAKER_PHONE; + } + + // Need to start Bluetooth if it is available and user either selected it explicitly or + // user did not select any output device. + boolean needBluetoothAudioStart = bluetoothManager.getState() == BluetoothManager.State.HEADSET_AVAILABLE && (userSelectedAudioDevice == AudioDevice.NONE || userSelectedAudioDevice == AudioDevice.BLUETOOTH); + + // Need to stop Bluetooth audio if user selected different device and + // Bluetooth SCO connection is established or in the process. + boolean needBluetoothAudioStop = (bluetoothManager.getState() == BluetoothManager.State.SCO_CONNECTED || bluetoothManager.getState() == BluetoothManager.State.SCO_CONNECTING) && (userSelectedAudioDevice != AudioDevice.NONE && userSelectedAudioDevice != AudioDevice.BLUETOOTH); + + if (bluetoothManager.getState() != BluetoothManager.State.HEADSET_AVAILABLE && bluetoothManager.getState() != BluetoothManager.State.SCO_CONNECTING) { + bluetoothManager.getState(); + } + + // Start or stop Bluetooth SCO connection given states set earlier. + if (needBluetoothAudioStop) { + bluetoothManager.stopScoAudio(); + bluetoothManager.updateDevice(); + } + + if (needBluetoothAudioStart && !needBluetoothAudioStop) { + // Attempt to start Bluetooth SCO audio (takes a few second to start). + if (!bluetoothManager.startScoAudio()) { + // Remove BLUETOOTH from list of available devices since SCO failed. + audioDevices.remove(AudioDevice.BLUETOOTH); + audioDeviceSetUpdated = true; + } + } + + // Update selected audio device. + AudioDevice newAudioDevice; + + if (bluetoothManager.getState() == BluetoothManager.State.SCO_CONNECTED) { + // If a Bluetooth is connected, then it should be used as output audio + // device. Note that it is not sufficient that a headset is available; + // an active SCO channel must also be up and running. + newAudioDevice = AudioDevice.BLUETOOTH; + } else if (hasWiredHeadset) { + // If a wired headset is connected, but Bluetooth is not, then wired headset is used as + // audio device. + newAudioDevice = AudioDevice.WIRED_HEADSET; + } else { + // No wired headset and no Bluetooth, hence the audio-device list can contain speaker + // phone (on a tablet), or speaker phone and earpiece (on mobile phone). + // |defaultAudioDevice| contains either AudioDevice.SPEAKER_PHONE or AudioDevice.EARPIECE + // depending on the user's selection. + newAudioDevice = defaultAudioDevice; + } + // Switch to new device but only if there has been any changes. + if (newAudioDevice != selectedAudioDevice || audioDeviceSetUpdated) { + // Do the required device switch. + setAudioDeviceInternal(newAudioDevice); + + if (audioManagerEvents != null) { + // Notify a listening client that audio device has been changed. + audioManagerEvents.onAudioDeviceChanged(selectedAudioDevice, audioDevices); + } + } + } + + /** + * AudioDevice is the names of possible audio devices that we currently + * support. + */ + public enum AudioDevice { + SPEAKER_PHONE, WIRED_HEADSET, EARPIECE, BLUETOOTH, NONE + } + + /** + * AudioManager state. + */ + public enum AudioManagerState { + UNINITIALIZED, PREINITIALIZED, RUNNING, + } + + /** + * Selected audio device change event. + */ + public interface AudioManagerEvents { + // Callback fired once audio device is changed or list of available audio devices changed. + void onAudioDeviceChanged(AudioDevice selectedAudioDevice, Set availableAudioDevices); + } + + /* Receiver which handles changes in wired headset availability. */ + private class WiredHeadsetReceiver extends BroadcastReceiver { + private static final int STATE_UNPLUGGED = 0; + private static final int STATE_PLUGGED = 1; + private static final int HAS_NO_MIC = 0; + private static final int HAS_MIC = 1; + + @Override + public void onReceive(Context context, Intent intent) { + int state = intent.getIntExtra("state", STATE_UNPLUGGED); + int microphone = intent.getIntExtra("microphone", HAS_NO_MIC); + String name = intent.getStringExtra("name"); + hasWiredHeadset = (state == STATE_PLUGGED); + updateAudioDeviceState(); + } + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/RTCClient.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/RTCClient.kt new file mode 100644 index 00000000..118951b5 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/RTCClient.kt @@ -0,0 +1,17 @@ +package com.example.studybuddy.view.videocall.webrtc + +import org.webrtc.IceCandidate +import org.webrtc.PeerConnection +import org.webrtc.SessionDescription + +interface RTCClient { + + val peerConnection:PeerConnection + + fun onDestroy() + fun call() + fun answer() + fun onRemoteSessionReceived(sessionDescription: SessionDescription) + fun addIceCandidateToPeer(iceCandidate: IceCandidate) + fun sendIceCandidateToPeer(candidate: IceCandidate, target: String) +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/RTCClientImpl.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/RTCClientImpl.kt new file mode 100644 index 00000000..c7413620 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/RTCClientImpl.kt @@ -0,0 +1,90 @@ +package com.example.studybuddy.view.videocall.webrtc + +import com.example.studybuddy.domain.model.MessageModel +import com.example.studybuddy.view.videocall.socket.SocketEvents +import com.google.gson.Gson +import org.webrtc.IceCandidate +import org.webrtc.MediaConstraints +import org.webrtc.PeerConnection +import org.webrtc.SessionDescription + +class RTCClientImpl( + private val connection: PeerConnection, + private val username: String, + private val target: String, + private val gson: Gson, + private var listener: WebRTCSignalListener? = null, + private val destroyClient: () -> Unit +) : RTCClient { + + private val mediaConstraint = MediaConstraints().apply { + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true")) + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true")) + } + + override val peerConnection = connection + override fun call() { + peerConnection.createOffer(object : MySdpObserver() { + override fun onCreateSuccess(desc: SessionDescription?) { + super.onCreateSuccess(desc) + peerConnection.setLocalDescription(object : MySdpObserver() { + override fun onSetSuccess() { + super.onSetSuccess() + listener?.onTransferEventToSocket( + MessageModel( + type = SocketEvents.Offer, name = username, target = target, + data = desc?.description + ) + ) + } + }, desc) + } + }, mediaConstraint) + } + + override fun answer() { + peerConnection.createAnswer(object : MySdpObserver() { + override fun onCreateSuccess(desc: SessionDescription?) { + super.onCreateSuccess(desc) + peerConnection.setLocalDescription(object : MySdpObserver() { + override fun onSetSuccess() { + super.onSetSuccess() + listener?.onTransferEventToSocket( + MessageModel( + type = SocketEvents.Answer, + name = username, + target = target, + data = desc?.description + ) + ) + } + }, desc) + } + }, mediaConstraint) + } + + override fun onRemoteSessionReceived(sessionDescription: SessionDescription) { + peerConnection.setRemoteDescription(MySdpObserver(), sessionDescription) + } + + override fun addIceCandidateToPeer(iceCandidate: IceCandidate) { + peerConnection.addIceCandidate(iceCandidate) + } + + override fun sendIceCandidateToPeer(candidate: IceCandidate, target: String) { + addIceCandidateToPeer(candidate) + listener?.onTransferEventToSocket( + MessageModel( + type = SocketEvents.Ice, + name = username, + target = target, + data = gson.toJson(candidate) + ) + ) + } + + override fun onDestroy() { + connection.close() + destroyClient() + } +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/WebRTCFactory.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/WebRTCFactory.kt new file mode 100644 index 00000000..5ac567bc --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/WebRTCFactory.kt @@ -0,0 +1,181 @@ +package com.example.studybuddy.view.videocall.webrtc + +import android.content.Context +import android.util.Log +import com.example.studybuddy.StudyBuddyApp +import com.google.gson.Gson +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.webrtc.AudioTrack +import org.webrtc.Camera2Enumerator +import org.webrtc.CameraVideoCapturer +import org.webrtc.DefaultVideoDecoderFactory +import org.webrtc.DefaultVideoEncoderFactory +import org.webrtc.EglBase +import org.webrtc.MediaConstraints +import org.webrtc.MediaStream +import org.webrtc.PeerConnection +import org.webrtc.PeerConnectionFactory +import org.webrtc.SurfaceTextureHelper +import org.webrtc.SurfaceViewRenderer +import org.webrtc.VideoCapturer +import org.webrtc.VideoTrack +import javax.inject.Inject +import javax.inject.Singleton + + +@Singleton +class WebRTCFactory @Inject constructor( + private val context: Context, + private val gson: Gson +) { + + private lateinit var localStreamListener: LocalStreamListener + private val eglBaseContext = EglBase.create().eglBaseContext + private lateinit var localSurfaceView: SurfaceViewRenderer + private lateinit var rtcAudioManager: RTCAudioManager + + private val peerConnectionFactory by lazy { createPeerConnectionFactory() } + + private val iceServer = listOf() + + + private var screenCapturer: VideoCapturer? = null + private val localVideoSource by lazy { peerConnectionFactory.createVideoSource(false) } + private val localAudioSource by lazy { peerConnectionFactory.createAudioSource(MediaConstraints()) } + + private val localTrackId = "local_track" + private val localStreamId = "${StudyBuddyApp.username}_local_stream_android" + private var videoCapturer: CameraVideoCapturer? = null + private var localAudioTrack: AudioTrack? = null + private var localVideoTrack: VideoTrack? = null + private var localStream: MediaStream? = null + private val TAG = "WebRTCFactory" + + + fun init(surface: SurfaceViewRenderer, localStreamListener: LocalStreamListener) { + this.localStreamListener = localStreamListener + rtcAudioManager = RTCAudioManager.create(context) + rtcAudioManager.setDefaultAudioDevice(RTCAudioManager.AudioDevice.SPEAKER_PHONE) + rtcAudioManager.start { selectedAudioDevice, availableAudioDevices -> + Log.d( + TAG, + "init: selected $selectedAudioDevice ,available ${availableAudioDevices.toList()}" + ) + } + CoroutineScope(Dispatchers.Default).launch { + + initPeerConnectionFactory(context) + } +// this.permissionIntent = permissionIntent + initSurfaceView(surface) + } + + private fun initSurfaceView(view: SurfaceViewRenderer) { + this.localSurfaceView = view + view.run { + setMirror(false) + setEnableHardwareScaler(true) + init(eglBaseContext, null) + } + startLocalVideo(view) + } + + fun initRemoteSurfaceView(view: SurfaceViewRenderer) { + view.run { + setMirror(false) + setEnableHardwareScaler(true) + init(eglBaseContext, null) + } + } + + private fun startLocalVideo(surface: SurfaceViewRenderer) { + val surfaceTextureHelper = + SurfaceTextureHelper.create(Thread.currentThread().name, eglBaseContext) + videoCapturer = getVideoCapturer() + videoCapturer?.initialize( + surfaceTextureHelper, + surface.context, localVideoSource.capturerObserver + ) + videoCapturer?.startCapture(480, 320, 10) + localVideoTrack = + peerConnectionFactory.createVideoTrack(localTrackId + "_video", localVideoSource) + localVideoTrack?.addSink(surface) + videoCapturer?.switchCamera(null) + localAudioTrack = + peerConnectionFactory.createAudioTrack(localTrackId + "_audio", localAudioSource) + localStream = peerConnectionFactory.createLocalMediaStream(localStreamId) + localStream?.addTrack(localAudioTrack) + localStream?.addTrack(localVideoTrack) + localStreamListener.onLocalStreamReady(localStream!!) + + } + + private fun getVideoCapturer(): CameraVideoCapturer { + return Camera2Enumerator(context).run { + deviceNames.find { + isFrontFacing(it) + }?.let { + createCapturer(it, null) + } ?: throw IllegalStateException() + } + } + + + private fun initPeerConnectionFactory(application: Context) { + val options = PeerConnectionFactory.InitializationOptions.builder(application) + .setEnableInternalTracer(true).setFieldTrials("WebRTC-H264HighProfile/Enabled/") + .createInitializationOptions() + PeerConnectionFactory.initialize(options) + } + + private fun createPeerConnectionFactory(): PeerConnectionFactory { + return PeerConnectionFactory.builder().setVideoDecoderFactory( + DefaultVideoDecoderFactory(eglBaseContext) + ).setVideoEncoderFactory( + DefaultVideoEncoderFactory( + eglBaseContext, true, true + ) + ).setOptions(PeerConnectionFactory.Options().apply { + disableEncryption = false + disableNetworkMonitor = false + }).createPeerConnectionFactory() + } + + + fun onDestroy() { + runCatching { + screenCapturer?.stopCapture() + screenCapturer?.dispose() + localStream?.dispose() + } + } + + + fun createRtcClient( + observer: PeerConnection.Observer, target: String, + listener: WebRTCSignalListener + ): RTCClient? { + val connection = peerConnectionFactory.createPeerConnection( + PeerConnection.RTCConfiguration(iceServer).apply { + enableCpuOveruseDetection = true + }, observer + ) + connection?.addStream(localStream) + return connection?.let { + RTCClientImpl(it, StudyBuddyApp.username, target, gson, listener) { + destroyProcess() + } + } + } + + private fun destroyProcess() { + runCatching { + videoCapturer?.stopCapture() + videoCapturer?.dispose() + } + } + + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/WebRTCSignalListener.kt b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/WebRTCSignalListener.kt new file mode 100644 index 00000000..37525c97 --- /dev/null +++ b/StudyBuddy/app/src/main/java/com/example/studybuddy/view/videocall/webrtc/WebRTCSignalListener.kt @@ -0,0 +1,9 @@ +package com.example.studybuddy.view.videocall.webrtc + +import com.example.studybuddy.domain.model.MessageModel + + +interface WebRTCSignalListener { + fun onTransferEventToSocket(data: MessageModel) + +} \ No newline at end of file diff --git a/StudyBuddy/app/src/main/res/drawable/baseline_add_alarm_24.xml b/StudyBuddy/app/src/main/res/drawable/baseline_add_alarm_24.xml new file mode 100644 index 00000000..310f70a0 --- /dev/null +++ b/StudyBuddy/app/src/main/res/drawable/baseline_add_alarm_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/StudyBuddy/app/src/main/res/drawable/baseline_call_24.xml b/StudyBuddy/app/src/main/res/drawable/baseline_call_24.xml new file mode 100644 index 00000000..fbd2b0a2 --- /dev/null +++ b/StudyBuddy/app/src/main/res/drawable/baseline_call_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/StudyBuddy/app/src/main/res/drawable/baseline_check_24.xml b/StudyBuddy/app/src/main/res/drawable/baseline_check_24.xml new file mode 100644 index 00000000..562b6210 --- /dev/null +++ b/StudyBuddy/app/src/main/res/drawable/baseline_check_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/StudyBuddy/app/src/main/res/drawable/baseline_visibility_24.xml b/StudyBuddy/app/src/main/res/drawable/baseline_visibility_24.xml new file mode 100644 index 00000000..f843e291 --- /dev/null +++ b/StudyBuddy/app/src/main/res/drawable/baseline_visibility_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/StudyBuddy/app/src/main/res/drawable/baseline_visibility_off_24.xml b/StudyBuddy/app/src/main/res/drawable/baseline_visibility_off_24.xml new file mode 100644 index 00000000..5993ca39 --- /dev/null +++ b/StudyBuddy/app/src/main/res/drawable/baseline_visibility_off_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/StudyBuddy/app/src/main/res/drawable/books.png b/StudyBuddy/app/src/main/res/drawable/books.png new file mode 100644 index 00000000..a4215e65 Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/books.png differ diff --git a/StudyBuddy/app/src/main/res/drawable/boyimg.png b/StudyBuddy/app/src/main/res/drawable/boyimg.png new file mode 100644 index 00000000..c839df4e Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/boyimg.png differ diff --git a/StudyBuddy/app/src/main/res/drawable/chatfilled.xml b/StudyBuddy/app/src/main/res/drawable/chatfilled.xml new file mode 100644 index 00000000..7f6fda16 --- /dev/null +++ b/StudyBuddy/app/src/main/res/drawable/chatfilled.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/StudyBuddy/app/src/main/res/drawable/chatoutlined.xml b/StudyBuddy/app/src/main/res/drawable/chatoutlined.xml new file mode 100644 index 00000000..7ce81fa5 --- /dev/null +++ b/StudyBuddy/app/src/main/res/drawable/chatoutlined.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/StudyBuddy/app/src/main/res/drawable/focus.png b/StudyBuddy/app/src/main/res/drawable/focus.png new file mode 100644 index 00000000..73eff55f Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/focus.png differ diff --git a/StudyBuddy/app/src/main/res/drawable/focusfill.png b/StudyBuddy/app/src/main/res/drawable/focusfill.png new file mode 100644 index 00000000..ef6e4480 Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/focusfill.png differ diff --git a/StudyBuddy/app/src/main/res/drawable/homefilled.xml b/StudyBuddy/app/src/main/res/drawable/homefilled.xml new file mode 100644 index 00000000..20cb4d6c --- /dev/null +++ b/StudyBuddy/app/src/main/res/drawable/homefilled.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/StudyBuddy/app/src/main/res/drawable/homeoutlined.xml b/StudyBuddy/app/src/main/res/drawable/homeoutlined.xml new file mode 100644 index 00000000..4d8dd31c --- /dev/null +++ b/StudyBuddy/app/src/main/res/drawable/homeoutlined.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/StudyBuddy/app/src/main/res/drawable/ic_launcher_background.xml b/StudyBuddy/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/StudyBuddy/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StudyBuddy/app/src/main/res/drawable/ic_launcher_foreground.xml b/StudyBuddy/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/StudyBuddy/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/StudyBuddy/app/src/main/res/drawable/instagram.png b/StudyBuddy/app/src/main/res/drawable/instagram.png new file mode 100644 index 00000000..9b403cfc Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/instagram.png differ diff --git a/StudyBuddy/app/src/main/res/drawable/lamp.jpg b/StudyBuddy/app/src/main/res/drawable/lamp.jpg new file mode 100644 index 00000000..7947994f Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/lamp.jpg differ diff --git a/StudyBuddy/app/src/main/res/drawable/login.png b/StudyBuddy/app/src/main/res/drawable/login.png new file mode 100644 index 00000000..bf2159ba Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/login.png differ diff --git a/StudyBuddy/app/src/main/res/drawable/logincomplete.png b/StudyBuddy/app/src/main/res/drawable/logincomplete.png new file mode 100644 index 00000000..ad8aa41d Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/logincomplete.png differ diff --git a/StudyBuddy/app/src/main/res/drawable/logo.jpg b/StudyBuddy/app/src/main/res/drawable/logo.jpg new file mode 100644 index 00000000..084740e5 Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/logo.jpg differ diff --git a/StudyBuddy/app/src/main/res/drawable/questionfill.xml b/StudyBuddy/app/src/main/res/drawable/questionfill.xml new file mode 100644 index 00000000..10b1312e --- /dev/null +++ b/StudyBuddy/app/src/main/res/drawable/questionfill.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/StudyBuddy/app/src/main/res/drawable/questionoutline.xml b/StudyBuddy/app/src/main/res/drawable/questionoutline.xml new file mode 100644 index 00000000..d494015a --- /dev/null +++ b/StudyBuddy/app/src/main/res/drawable/questionoutline.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/StudyBuddy/app/src/main/res/drawable/signup.png b/StudyBuddy/app/src/main/res/drawable/signup.png new file mode 100644 index 00000000..33796708 Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/signup.png differ diff --git a/StudyBuddy/app/src/main/res/drawable/splash_screen.png b/StudyBuddy/app/src/main/res/drawable/splash_screen.png new file mode 100644 index 00000000..730892cf Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/splash_screen.png differ diff --git a/StudyBuddy/app/src/main/res/drawable/todo.jpg b/StudyBuddy/app/src/main/res/drawable/todo.jpg new file mode 100644 index 00000000..9322d71d Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/todo.jpg differ diff --git a/StudyBuddy/app/src/main/res/drawable/user.png b/StudyBuddy/app/src/main/res/drawable/user.png new file mode 100644 index 00000000..10e1f2a3 Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/user.png differ diff --git a/StudyBuddy/app/src/main/res/drawable/user1.png b/StudyBuddy/app/src/main/res/drawable/user1.png new file mode 100644 index 00000000..2c71e6c5 Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/user1.png differ diff --git a/StudyBuddy/app/src/main/res/drawable/user2.png b/StudyBuddy/app/src/main/res/drawable/user2.png new file mode 100644 index 00000000..897a9f30 Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/user2.png differ diff --git a/StudyBuddy/app/src/main/res/drawable/user3.png b/StudyBuddy/app/src/main/res/drawable/user3.png new file mode 100644 index 00000000..e064e66b Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/user3.png differ diff --git a/StudyBuddy/app/src/main/res/drawable/user4.png b/StudyBuddy/app/src/main/res/drawable/user4.png new file mode 100644 index 00000000..086adc78 Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/user4.png differ diff --git a/StudyBuddy/app/src/main/res/drawable/user5.png b/StudyBuddy/app/src/main/res/drawable/user5.png new file mode 100644 index 00000000..d2aaefab Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/user5.png differ diff --git a/StudyBuddy/app/src/main/res/drawable/user6.png b/StudyBuddy/app/src/main/res/drawable/user6.png new file mode 100644 index 00000000..1db461c6 Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/user6.png differ diff --git a/StudyBuddy/app/src/main/res/drawable/user7.png b/StudyBuddy/app/src/main/res/drawable/user7.png new file mode 100644 index 00000000..288bbc98 Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/user7.png differ diff --git a/StudyBuddy/app/src/main/res/drawable/user8.png b/StudyBuddy/app/src/main/res/drawable/user8.png new file mode 100644 index 00000000..40f4bd9e Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/user8.png differ diff --git a/StudyBuddy/app/src/main/res/drawable/videocall.xml b/StudyBuddy/app/src/main/res/drawable/videocall.xml new file mode 100644 index 00000000..908ef0c9 --- /dev/null +++ b/StudyBuddy/app/src/main/res/drawable/videocall.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/StudyBuddy/app/src/main/res/drawable/videocallfill.xml b/StudyBuddy/app/src/main/res/drawable/videocallfill.xml new file mode 100644 index 00000000..fe48ccca --- /dev/null +++ b/StudyBuddy/app/src/main/res/drawable/videocallfill.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/StudyBuddy/app/src/main/res/drawable/videocalloutline.xml b/StudyBuddy/app/src/main/res/drawable/videocalloutline.xml new file mode 100644 index 00000000..dfcca18a --- /dev/null +++ b/StudyBuddy/app/src/main/res/drawable/videocalloutline.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/StudyBuddy/app/src/main/res/drawable/whatsapp.png b/StudyBuddy/app/src/main/res/drawable/whatsapp.png new file mode 100644 index 00000000..2e7d5fbf Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/whatsapp.png differ diff --git a/StudyBuddy/app/src/main/res/drawable/youtube.png b/StudyBuddy/app/src/main/res/drawable/youtube.png new file mode 100644 index 00000000..2678f09d Binary files /dev/null and b/StudyBuddy/app/src/main/res/drawable/youtube.png differ diff --git a/StudyBuddy/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/StudyBuddy/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/StudyBuddy/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/StudyBuddy/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/StudyBuddy/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/StudyBuddy/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/StudyBuddy/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/StudyBuddy/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..c209e78e Binary files /dev/null and b/StudyBuddy/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/StudyBuddy/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/StudyBuddy/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..b2dfe3d1 Binary files /dev/null and b/StudyBuddy/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/StudyBuddy/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/StudyBuddy/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..4f0f1d64 Binary files /dev/null and b/StudyBuddy/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/StudyBuddy/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/StudyBuddy/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..62b611da Binary files /dev/null and b/StudyBuddy/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/StudyBuddy/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/StudyBuddy/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..948a3070 Binary files /dev/null and b/StudyBuddy/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/StudyBuddy/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/StudyBuddy/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..1b9a6956 Binary files /dev/null and b/StudyBuddy/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/StudyBuddy/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/StudyBuddy/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..28d4b77f Binary files /dev/null and b/StudyBuddy/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/StudyBuddy/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/StudyBuddy/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..9287f508 Binary files /dev/null and b/StudyBuddy/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/StudyBuddy/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/StudyBuddy/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..aa7d6427 Binary files /dev/null and b/StudyBuddy/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/StudyBuddy/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/StudyBuddy/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..9126ae37 Binary files /dev/null and b/StudyBuddy/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/StudyBuddy/app/src/main/res/values/arrays.xml b/StudyBuddy/app/src/main/res/values/arrays.xml new file mode 100644 index 00000000..141bfc01 --- /dev/null +++ b/StudyBuddy/app/src/main/res/values/arrays.xml @@ -0,0 +1,17 @@ + + + + @array/com_google_android_gms_fonts_certs_dev + @array/com_google_android_gms_fonts_certs_prod + + + + MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs= + + + + + MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK + + + \ No newline at end of file diff --git a/StudyBuddy/app/src/main/res/values/colors.xml b/StudyBuddy/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..f8c6127d --- /dev/null +++ b/StudyBuddy/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/StudyBuddy/app/src/main/res/values/strings.xml b/StudyBuddy/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..ba2db659 --- /dev/null +++ b/StudyBuddy/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + Study Buddy + Service to block certain apps + \ No newline at end of file diff --git a/StudyBuddy/app/src/main/res/values/themes.xml b/StudyBuddy/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..90096a85 --- /dev/null +++ b/StudyBuddy/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/StudyBuddy/app/src/main/res/xml/accessibility_service_config.xml b/StudyBuddy/app/src/main/res/xml/accessibility_service_config.xml new file mode 100644 index 00000000..4689e818 --- /dev/null +++ b/StudyBuddy/app/src/main/res/xml/accessibility_service_config.xml @@ -0,0 +1,12 @@ + + diff --git a/StudyBuddy/app/src/main/res/xml/backup_rules.xml b/StudyBuddy/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 00000000..fa0f996d --- /dev/null +++ b/StudyBuddy/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/StudyBuddy/app/src/main/res/xml/data_extraction_rules.xml b/StudyBuddy/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..9ee9997b --- /dev/null +++ b/StudyBuddy/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/StudyBuddy/app/src/test/java/com/example/studybuddy/ExampleUnitTest.kt b/StudyBuddy/app/src/test/java/com/example/studybuddy/ExampleUnitTest.kt new file mode 100644 index 00000000..0d4d3b74 --- /dev/null +++ b/StudyBuddy/app/src/test/java/com/example/studybuddy/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.example.studybuddy + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/StudyBuddy/build.gradle.kts b/StudyBuddy/build.gradle.kts new file mode 100644 index 00000000..1dfc60e2 --- /dev/null +++ b/StudyBuddy/build.gradle.kts @@ -0,0 +1,8 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.jetbrains.kotlin.android) apply false + id("com.google.dagger.hilt.android") version "2.48" apply false + id("com.google.devtools.ksp") version "1.9.10-1.0.13" apply false + alias(libs.plugins.google.gms.google.services) apply false +} \ No newline at end of file diff --git a/StudyBuddy/gradle.properties b/StudyBuddy/gradle.properties new file mode 100644 index 00000000..20e2a015 --- /dev/null +++ b/StudyBuddy/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/StudyBuddy/gradle/libs.versions.toml b/StudyBuddy/gradle/libs.versions.toml new file mode 100644 index 00000000..d04f462c --- /dev/null +++ b/StudyBuddy/gradle/libs.versions.toml @@ -0,0 +1,66 @@ +[versions] +agp = "8.4.0-rc02" +composeBomVersion = "2024.06.00" +destinationVersion = "1.10.0" +desugar_jdk_libs = "2.0.4" +hiltAndroid = "2.48" +hiltAndroidCompiler = "2.48" +hiltNavigationCompose = "1.2.0" +kotlin = "1.9.0" +coreKtx = "1.13.1" +junit = "4.13.2" +junitVersion = "1.2.1" +espressoCore = "3.6.1" +lifecycleRuntimeCompose = "2.8.3" +lifecycleRuntimeKtx = "2.8.2" +activityCompose = "1.9.0" +composeBom = "2023.08.00" +roomVersion = "2.6.1" +uiTextGoogleFonts = "1.6.8" +googleGmsGoogleServices = "4.4.2" +firebaseAuth = "23.0.0" +runtimeLivedata = "1.6.8" +appcompat = "1.7.0" +material = "1.12.0" +activity = "1.9.2" + +[libraries] +androidx-compose-bom-v20240600 = { module = "androidx.compose:compose-bom", version.ref = "composeBomVersion" } +androidx-compose-material-icons-extended-android = { module = "androidx.compose.material:compose-material-icons-extended-android" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltNavigationCompose" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomVersion" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomVersion" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomVersion" } +androidx-ui-text-google-fonts = { module = "androidx.compose.ui:ui-text-google-fonts", version.ref = "uiTextGoogleFonts" } +compose-destinations-ksp = { module = "io.github.raamcosta.compose-destinations:ksp", version.ref = "destinationVersion" } +core = { module = "io.github.raamcosta.compose-destinations:core", version.ref = "destinationVersion" } +dagger-hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroid" } +desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } +hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroidCompiler" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +firebase-auth = { group = "com.google.firebase", name = "firebase-auth", version.ref = "firebaseAuth" } +androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "runtimeLivedata" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +google-gms-google-services = { id = "com.google.gms.google-services", version.ref = "googleGmsGoogleServices" } + diff --git a/StudyBuddy/gradle/wrapper/gradle-wrapper.jar b/StudyBuddy/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..e708b1c0 Binary files /dev/null and b/StudyBuddy/gradle/wrapper/gradle-wrapper.jar differ diff --git a/StudyBuddy/gradle/wrapper/gradle-wrapper.properties b/StudyBuddy/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..3be2b499 --- /dev/null +++ b/StudyBuddy/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jul 05 19:53:52 IST 2024 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/StudyBuddy/gradlew b/StudyBuddy/gradlew new file mode 100644 index 00000000..4f906e0c --- /dev/null +++ b/StudyBuddy/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/StudyBuddy/gradlew.bat b/StudyBuddy/gradlew.bat new file mode 100644 index 00000000..107acd32 --- /dev/null +++ b/StudyBuddy/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/StudyBuddy/settings.gradle.kts b/StudyBuddy/settings.gradle.kts new file mode 100644 index 00000000..c8a8828a --- /dev/null +++ b/StudyBuddy/settings.gradle.kts @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Study Buddy" +include(":app") + \ No newline at end of file