From 71879eeecb264e73f4946b26ad547e54a67817ba Mon Sep 17 00:00:00 2001 From: Chris1320 Date: Sat, 25 Oct 2025 17:15:23 +0800 Subject: [PATCH 01/42] refactor(android): move `ChatManager` to `dataManager` package Signed-off-by: Chris1320 --- .../{chatbot => dataManager}/ChatManager.kt | 34 +++++++++++++------ .../reconnected/ui/menus/AIAssistant.kt | 2 +- 2 files changed, 24 insertions(+), 12 deletions(-) rename ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/{chatbot => dataManager}/ChatManager.kt (70%) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/chatbot/ChatManager.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/ChatManager.kt similarity index 70% rename from ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/chatbot/ChatManager.kt rename to ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/ChatManager.kt index b565468..cee6e5c 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/chatbot/ChatManager.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/ChatManager.kt @@ -1,8 +1,8 @@ -package com.getreconnected.reconnected.core.chatbot +package com.getreconnected.reconnected.core.dataManager import com.getreconnected.reconnected.core.Chatbot -import com.getreconnected.reconnected.core.models.Chat import com.google.firebase.Firebase +import com.google.firebase.ai.Chat import com.google.firebase.ai.GenerativeModel import com.google.firebase.ai.ai import com.google.firebase.ai.type.GenerativeBackend @@ -19,19 +19,31 @@ object ChatManager { * * @param name The name of the user starting the chat. This will be included in the initial message. */ - fun startChat(name: String?): com.google.firebase.ai.Chat { + fun startChat(name: String?): Chat { model = Firebase - .ai(backend = GenerativeBackend.googleAI()) + .ai(backend = GenerativeBackend.Companion.googleAI()) .generativeModel( modelName = Chatbot.MODEL, systemInstruction = content { text(generateInitialPrompt(name)) }, safetySettings = listOf( - SafetySetting(HarmCategory.HARASSMENT, HarmBlockThreshold.ONLY_HIGH), - SafetySetting(HarmCategory.DANGEROUS_CONTENT, HarmBlockThreshold.ONLY_HIGH), - SafetySetting(HarmCategory.HATE_SPEECH, HarmBlockThreshold.ONLY_HIGH), - SafetySetting(HarmCategory.SEXUALLY_EXPLICIT, HarmBlockThreshold.ONLY_HIGH), + SafetySetting( + HarmCategory.Companion.HARASSMENT, + HarmBlockThreshold.Companion.ONLY_HIGH, + ), + SafetySetting( + HarmCategory.Companion.DANGEROUS_CONTENT, + HarmBlockThreshold.Companion.ONLY_HIGH, + ), + SafetySetting( + HarmCategory.Companion.HATE_SPEECH, + HarmBlockThreshold.Companion.ONLY_HIGH, + ), + SafetySetting( + HarmCategory.Companion.SEXUALLY_EXPLICIT, + HarmBlockThreshold.Companion.ONLY_HIGH, + ), ), ) if (model == null) { @@ -52,19 +64,19 @@ object ChatManager { * @param prompt The input string used to generate a response. * @return A Chat object containing the generated response text and additional metadata. */ - suspend fun getResponse(prompt: String): Chat { + suspend fun getResponse(prompt: String): com.getreconnected.reconnected.core.models.Chat { try { if (model == null) { throw Exception("Model is null") } val response = model!!.generateContent(prompt) - return Chat( + return com.getreconnected.reconnected.core.models.Chat( prompt = response.text ?: "error", bitmap = null, isFromUser = false, ) } catch (e: Exception) { - return Chat( + return com.getreconnected.reconnected.core.models.Chat( prompt = e.message ?: "error", bitmap = null, isFromUser = false, diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/AIAssistant.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/AIAssistant.kt index 0d767bd..ec2eaa7 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/AIAssistant.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/AIAssistant.kt @@ -43,7 +43,7 @@ import androidx.compose.ui.unit.sp import com.getreconnected.reconnected.R import com.getreconnected.reconnected.core.Chatbot import com.getreconnected.reconnected.core.auth.GoogleAuth -import com.getreconnected.reconnected.core.chatbot.ChatManager +import com.getreconnected.reconnected.core.dataManager.ChatManager import com.getreconnected.reconnected.core.models.Chat import com.google.firebase.Firebase import com.google.firebase.auth.auth From 2b3c3f970fc108321d866815afaa81ad27200d9e Mon Sep 17 00:00:00 2001 From: Chris1320 Date: Mon, 27 Oct 2025 02:28:57 +0800 Subject: [PATCH 02/42] build(android): add `datastore` and `gson` dependencies Signed-off-by: Chris1320 --- ReconnectED-Android/app/build.gradle.kts | 2 ++ ReconnectED-Android/gradle/libs.versions.toml | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/ReconnectED-Android/app/build.gradle.kts b/ReconnectED-Android/app/build.gradle.kts index 2b95f0f..9bef23a 100644 --- a/ReconnectED-Android/app/build.gradle.kts +++ b/ReconnectED-Android/app/build.gradle.kts @@ -43,6 +43,8 @@ android { dependencies { implementation(libs.firebase.ai) + implementation(libs.androidx.datastore) + implementation(libs.gson) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.junit) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/ReconnectED-Android/gradle/libs.versions.toml b/ReconnectED-Android/gradle/libs.versions.toml index 8a67f87..1a7be94 100644 --- a/ReconnectED-Android/gradle/libs.versions.toml +++ b/ReconnectED-Android/gradle/libs.versions.toml @@ -30,6 +30,8 @@ uiTooling = "1.9.3" uiToolingPreview = "1.10.0-alpha05" vico = "2.2.0" firebaseAi = "17.4.0" +datastore = "1.2.0-beta01" +gson = "2.13.1" [libraries] accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "accompanistDrawablepainter" } @@ -69,6 +71,8 @@ material = { group = "com.google.android.material", name = "material", version.r play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playServicesAuth" } vico-compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico" } firebase-ai = { group = "com.google.firebase", name = "firebase-ai", version.ref = "firebaseAi" } +androidx-datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" } +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 6779c3a8ce565e8e5546039e06bbfd66a33e4dee Mon Sep 17 00:00:00 2001 From: Chris1320 Date: Mon, 27 Oct 2025 02:30:45 +0800 Subject: [PATCH 03/42] feat(wip,android): implementing user avatar loading and local saving of user data - Remove Room database set up. - Move `AppUsageInfo` and `Chat` to `entities` package. - Remove legacy modules. Signed-off-by: Chris1320 --- .../reconnected/activities/MainActivity.kt | 7 +- .../reconnected/core/AppUsageRepository.kt | 27 +++++- .../reconnected/core/DatabaseManager.kt | 67 -------------- .../getreconnected/reconnected/core/Info.kt | 5 ++ .../getreconnected/reconnected/core/Utils.kt | 72 +++++++++++++++ .../core/dataManager/ChatManager.kt | 6 +- .../core/dataManager/UserManager.kt | 48 ++++++++++ .../models/{ => entities}/AppUsageInfo.kt | 2 +- .../core/models/{ => entities}/Chat.kt | 2 +- .../reconnected/core/models/entities/User.kt | 57 +----------- .../core/models/entities/WeeklyScreenTime.kt | 8 ++ .../viewModels/ScreenTimeTrackerViewModel.kt | 2 +- .../reconnected/legacy/data/Converters.kt | 11 --- .../reconnected/legacy/data/GetDailyStats.kt | 77 ---------------- .../reconnected/legacy/data/GetWeeklyStats.kt | 65 -------------- .../legacy/data/WeeklyScreenTime.kt | 12 --- .../legacy/data/WeeklyScreenTimeDao.kt | 19 ---- .../reconnected/ui/AppNavigation.kt | 3 +- .../ui/composables/elements/WeeklyCard.kt | 2 +- .../reconnected/ui/menus/AIAssistant.kt | 2 +- .../reconnected/ui/menus/Dashboard.kt | 88 ++++++++++++++----- .../reconnected/ui/menus/ScreenTimeTracker.kt | 2 +- 22 files changed, 237 insertions(+), 347 deletions(-) delete mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/DatabaseManager.kt create mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/UserManager.kt rename ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/{ => entities}/AppUsageInfo.kt (89%) rename ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/{ => entities}/Chat.kt (68%) create mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/WeeklyScreenTime.kt delete mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/Converters.kt delete mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/GetDailyStats.kt delete mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/GetWeeklyStats.kt delete mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/WeeklyScreenTime.kt delete mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/WeeklyScreenTimeDao.kt diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/activities/MainActivity.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/activities/MainActivity.kt index e4dfe50..f142728 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/activities/MainActivity.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/activities/MainActivity.kt @@ -5,7 +5,6 @@ import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import com.getreconnected.reconnected.core.DatabaseManager import com.getreconnected.reconnected.ui.AppNavigation import com.getreconnected.reconnected.ui.theme.ReconnectEDTheme @@ -19,11 +18,7 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) - // Get database instance - Log.d("MainActivity", "Getting database instance") - val database = DatabaseManager.getDatabase(this) - Log.d("MainActivity", "Setting content") - setContent { ReconnectEDTheme { AppNavigation(database) } } + setContent { ReconnectEDTheme { AppNavigation() } } } } diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/AppUsageRepository.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/AppUsageRepository.kt index 878f8c1..d592a6f 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/AppUsageRepository.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/AppUsageRepository.kt @@ -1,12 +1,14 @@ package com.getreconnected.reconnected.core +import android.app.usage.UsageStats import android.app.usage.UsageStatsManager import android.content.Context import android.content.pm.PackageManager -import com.getreconnected.reconnected.core.models.AppUsageInfo +import com.getreconnected.reconnected.core.models.entities.AppUsageInfo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.util.Calendar +import java.util.concurrent.TimeUnit /** * Repository for fetching application usage statistics from the device. @@ -62,4 +64,27 @@ class AppUsageRepository( } }.sortedByDescending { it.usageTime } } + + /** + * Retrieves the weekly application usage statistics. Uses the `UsageStatsManager` to + * collect usage data over the last 7 days and returns a list of `UsageStats` objects + * containing information about app usage. + * + * @param context The context used to access system services such as `UsageStatsManager`. + * @return A list of `UsageStats`, where each object represents the usage statistics + * for an app during the past week. Returns an empty list if no data is available. + */ + fun getWeeklyUsageStats(context: Context): List { + val usageStatsManager = + context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager + + val endTime = System.currentTimeMillis() + val startTime = endTime - TimeUnit.DAYS.toMillis(7) // last 7 days + + return usageStatsManager.queryUsageStats( + UsageStatsManager.INTERVAL_DAILY, + startTime, + endTime, + ) ?: emptyList() + } } diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/DatabaseManager.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/DatabaseManager.kt deleted file mode 100644 index e470a99..0000000 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/DatabaseManager.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.getreconnected.reconnected.core - -import android.content.Context -import androidx.room.Database -import androidx.room.Room -import androidx.room.RoomDatabase -import com.getreconnected.reconnected.core.models.entities.User -import com.getreconnected.reconnected.core.models.entities.UserDao - -/** - * Abstract class for managing the application's database using Room. - * - * This class serves as the main access point to the database. It defines the entities associated - * with the database and the version of the database schema. It also provides an abstract method - * to access the DAO (Data Access Object) for interacting with the `User` entity. - * - * Annotations: - * - `@Database` specifies the list of entities (`User`) related to this database and the schema version. - */ -@Database(entities = [User::class], version = 1) -abstract class AppDatabase : RoomDatabase() { - abstract fun userDao(): UserDao -} - -/** - * Responsible for managing a single instance of the `AppDatabase` using the Singleton pattern. - * - * This class ensures that the database is initialized only once and provides a synchronized method - * to access the instance of the `AppDatabase`. It uses Room to build the database instance and serves - * as the central access point for the application's database. - * - * Thread Safety: - * The database instance is fetched or created in a thread-safe manner by using a synchronized block - * and the `@Volatile` annotation to ensure visibility and consistency across threads. - * - */ -class DatabaseManager private constructor() { - companion object { - @Volatile - @Suppress("ktlint:standard:property-naming") - private var INSTANCE: AppDatabase? = null - - /** - * Retrieves the singleton instance of the `AppDatabase`. - * - * @param context The application context. - * @return The singleton instance of the `AppDatabase`. - */ - fun getDatabase(context: Context): AppDatabase { - val tempInstance = INSTANCE - if (tempInstance != null) { - return tempInstance - } - synchronized(this) { - val instance = - Room - .databaseBuilder( - context.applicationContext, - AppDatabase::class.java, - "reconnected", - ).build() - INSTANCE = instance - return instance - } - } - } -} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Info.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Info.kt index e12e60c..38e3a83 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Info.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Info.kt @@ -5,6 +5,11 @@ data object Application { const val DESCRIPTION = "Minimize the overconsumption of the internet." } +data object FilePaths { + const val USER = "user-data.json" + const val AVATAR = "user-avatar.png" +} + data object Chatbot { const val MODEL = "gemini-2.5-flash" val INITIAL_PROMPT = diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Utils.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Utils.kt index 9b04eb6..c8062a7 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Utils.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Utils.kt @@ -1,5 +1,12 @@ package com.getreconnected.reconnected.core +import android.app.usage.UsageEvents +import android.app.usage.UsageStatsManager +import android.content.Context +import com.getreconnected.reconnected.core.util.hasUsageStatsPermission +import java.util.Calendar +import java.util.concurrent.TimeUnit + /** * Formats the given time in milliseconds to a human-readable string. * @@ -16,3 +23,68 @@ fun formatTime(t: Long): String { else -> "${seconds}s" } } + +/** + * Calculates the number of days the application has been active since its first installation. + * + * @param context The application context used to retrieve package information. + * @return The number of days the application has been active (installation date inclusive). + */ +fun getDaysActive(context: Context): Long { + val firstInstallTime = context.packageManager.getPackageInfo(context.packageName, 0).firstInstallTime + val currentTime = System.currentTimeMillis() + val diff = currentTime - firstInstallTime + return TimeUnit.MILLISECONDS.toDays(diff) + 1 +} + +/** + * Retrieves the total screen time in milliseconds for the current day. + * + * @param context The context used to access system services like `UsageStatsManager`. + * @return The total screen time in milliseconds for the current day. If the app does not + * have the required usage stats permission, it returns 0L. + */ +fun getScreenTimeInMillis(context: Context): Long { + if (!hasUsageStatsPermission(context)) return 0L + + val usageStatsManager = context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager + + // Start of today (midnight) + val calendar = + Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + val startTime = calendar.timeInMillis + val endTime = System.currentTimeMillis() + + val usageEvents = usageStatsManager.queryEvents(startTime, endTime) + var totalScreenTime = 0L + var lastResumeTimestamp: Long? = null + + val event = UsageEvents.Event() + while (usageEvents.hasNextEvent()) { + usageEvents.getNextEvent(event) + when (event.eventType) { + UsageEvents.Event.ACTIVITY_RESUMED -> { + lastResumeTimestamp = event.timeStamp + } + + UsageEvents.Event.ACTIVITY_PAUSED -> { + if (lastResumeTimestamp != null) { + totalScreenTime += (event.timeStamp - lastResumeTimestamp!!) + lastResumeTimestamp = null + } + } + } + } + + // Handle ongoing session (user still in foreground at query time) + if (lastResumeTimestamp != null) { + totalScreenTime += (endTime - lastResumeTimestamp!!) + } + + return totalScreenTime +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/ChatManager.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/ChatManager.kt index cee6e5c..f7cbc92 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/ChatManager.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/ChatManager.kt @@ -64,19 +64,19 @@ object ChatManager { * @param prompt The input string used to generate a response. * @return A Chat object containing the generated response text and additional metadata. */ - suspend fun getResponse(prompt: String): com.getreconnected.reconnected.core.models.Chat { + suspend fun getResponse(prompt: String): com.getreconnected.reconnected.core.models.entities.Chat { try { if (model == null) { throw Exception("Model is null") } val response = model!!.generateContent(prompt) - return com.getreconnected.reconnected.core.models.Chat( + return com.getreconnected.reconnected.core.models.entities.Chat( prompt = response.text ?: "error", bitmap = null, isFromUser = false, ) } catch (e: Exception) { - return com.getreconnected.reconnected.core.models.Chat( + return com.getreconnected.reconnected.core.models.entities.Chat( prompt = e.message ?: "error", bitmap = null, isFromUser = false, diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/UserManager.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/UserManager.kt new file mode 100644 index 0000000..be4e920 --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/UserManager.kt @@ -0,0 +1,48 @@ +package com.getreconnected.reconnected.core.dataManager + +import android.graphics.Bitmap +import com.getreconnected.reconnected.core.models.entities.User +import com.google.firebase.auth.FirebaseUser + +/** + * Manages user-related operations and data within the application. + * + * This object is intended to serve as a central point for handling user information, + * providing functionalities for user management and integration with other system components. + */ +object UserManager { + var isLoggedIn: Boolean = false + private set + + /** + * Save user data to local storage. + * + * @param userInfo Firebase user information to be saved. + */ + fun login(userInfo: FirebaseUser) { + val avatarBitmap: Bitmap? = null + User( + displayName = userInfo.displayName ?: "", + email = userInfo.email ?: "", + avatar = avatarBitmap, + created = 0, + lastSignIn = 0, + ) + isLoggedIn = true + } + + /** + * Remove locally-saved information about the user. + */ + fun logout() {} + + /** + * Update user information in the Firestore database. + */ + fun update() {} + + /** + * Remove user data from the Firestore database. + */ + fun unregister() {} +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/AppUsageInfo.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/AppUsageInfo.kt similarity index 89% rename from ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/AppUsageInfo.kt rename to ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/AppUsageInfo.kt index 544f449..f3627fb 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/AppUsageInfo.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/AppUsageInfo.kt @@ -1,4 +1,4 @@ -package com.getreconnected.reconnected.core.models +package com.getreconnected.reconnected.core.models.entities import android.graphics.drawable.Drawable diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/Chat.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/Chat.kt similarity index 68% rename from ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/Chat.kt rename to ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/Chat.kt index 562bdab..56e51ef 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/Chat.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/Chat.kt @@ -1,4 +1,4 @@ -package com.getreconnected.reconnected.core.models +package com.getreconnected.reconnected.core.models.entities import android.graphics.Bitmap diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/User.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/User.kt index b8ef79f..40d5d63 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/User.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/User.kt @@ -1,71 +1,20 @@ package com.getreconnected.reconnected.core.models.entities import android.graphics.Bitmap -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Entity -import androidx.room.Ignore -import androidx.room.Insert -import androidx.room.PrimaryKey -import androidx.room.Query /** * Represents a user entity in the system. * - * This class defines the main user attributes and maps them to a database entity using Room annotations. - * - * @property uid Unique identifier for the user. - * @property firstName Optional first name of the user. - * @property lastName Optional last name of the user. + * @property displayName Display name of the user. * @property email Email address of the user. * @property created Timestamp indicating when the user was created. * @property lastSignIn Timestamp indicating the most recent login time. * @property avatar Optional avatar image for the user, ignored for database persistence. */ -@Entity data class User( - @PrimaryKey val uid: String, - val firstName: String? = null, - val lastName: String? = null, + val displayName: String? = null, val email: String, val created: Int, val lastSignIn: Int, - // FIXME: Add avatar support - // @Ignore val avatar: Bitmap? = null, + val avatar: Bitmap? = null, ) - -/** - * Data Access Object (DAO) interface for managing User entities in the database. - * - * Provides methods to perform operations such as retrieving all users, finding a user by their unique identifier, - * and deleting a user record from the database. - * - * This interface uses Room annotations to define the corresponding SQL queries for database interactions. - */ -@Dao -interface UserDao { - /** - * Retrieves all user records from the database. - * - * @return A list of all users as entities of type User. - */ - @Query("SELECT * FROM user") - fun getAll(): List - - /** - * Retrieves a user record from the database by their unique identifier (UID). - * - * @param uid The unique identifier of the user to be found. - * @return The user entity matching the specified UID, or null if no user is found. - */ - @Query("SELECT * FROM user WHERE uid = :uid LIMIT 1") - fun findByUid(uid: String): User? - - /** - * Deletes the specified user from the database. - * - * @param user The user entity to be deleted. - */ - @Delete - fun delete(user: User) -} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/WeeklyScreenTime.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/WeeklyScreenTime.kt new file mode 100644 index 0000000..23e4648 --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/WeeklyScreenTime.kt @@ -0,0 +1,8 @@ +package com.getreconnected.reconnected.core.models.entities + +data class WeeklyScreenTime( + val id: Int = 0, + val weekNumber: Int, + val totalTimeMillis: Long, + val topApps: List, +) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeTrackerViewModel.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeTrackerViewModel.kt index 1cdc81a..91ebb95 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeTrackerViewModel.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeTrackerViewModel.kt @@ -4,7 +4,7 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.getreconnected.reconnected.core.AppUsageRepository -import com.getreconnected.reconnected.core.models.AppUsageInfo +import com.getreconnected.reconnected.core.models.entities.AppUsageInfo import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/Converters.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/Converters.kt deleted file mode 100644 index da3e5dc..0000000 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/Converters.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.getreconnected.reconnected.legacy.data - -import androidx.room.TypeConverter - -class Converters { - @TypeConverter - fun fromList(value: List): String = value.joinToString(",") - - @TypeConverter - fun toList(value: String): List = value.split(",") -} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/GetDailyStats.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/GetDailyStats.kt deleted file mode 100644 index 6b0793d..0000000 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/GetDailyStats.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.getreconnected.reconnected.legacy.data - -import android.app.usage.UsageEvents -import android.app.usage.UsageStatsManager -import android.content.Context -import com.getreconnected.reconnected.core.util.hasUsageStatsPermission -import java.util.Calendar -import java.util.concurrent.TimeUnit - -fun getDaysActive(context: Context): Long = - try { - val firstInstallTime = context.packageManager.getPackageInfo(context.packageName, 0).firstInstallTime - val currentTime = System.currentTimeMillis() - val diff = currentTime - firstInstallTime - TimeUnit.MILLISECONDS.toDays(diff) + 1 - } catch (e: Exception) { - 1L - } - -fun getScreenTimeInMillis(context: Context): Long { - if (!hasUsageStatsPermission(context)) return 0L - - val usageStatsManager = context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager - - // Start of today (midnight) - val calendar = - Calendar.getInstance().apply { - set(Calendar.HOUR_OF_DAY, 0) - set(Calendar.MINUTE, 0) - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - } - val startTime = calendar.timeInMillis - val endTime = System.currentTimeMillis() - - val usageEvents = usageStatsManager.queryEvents(startTime, endTime) - var totalScreenTime = 0L - var lastResumeTimestamp: Long? = null - - val event = UsageEvents.Event() - while (usageEvents.hasNextEvent()) { - usageEvents.getNextEvent(event) - when (event.eventType) { - UsageEvents.Event.ACTIVITY_RESUMED -> { - lastResumeTimestamp = event.timeStamp - } - - UsageEvents.Event.ACTIVITY_PAUSED -> { - if (lastResumeTimestamp != null) { - totalScreenTime += (event.timeStamp - lastResumeTimestamp!!) - lastResumeTimestamp = null - } - } - } - } - - // Handle ongoing session (user still in foreground at query time) - if (lastResumeTimestamp != null) { - totalScreenTime += (endTime - lastResumeTimestamp!!) - } - - return totalScreenTime -} - -// Formats the millis into h/m -fun formatScreenTime(millis: Long): String { - if (millis <= 0L) return "0m" - - val hours = TimeUnit.MILLISECONDS.toHours(millis) - val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) % 60 - - return when { - hours > 0 -> "${hours}h ${minutes}m" - minutes > 0 -> "${minutes}m" - else -> "< 1m" - } -} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/GetWeeklyStats.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/GetWeeklyStats.kt deleted file mode 100644 index b1b7f61..0000000 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/GetWeeklyStats.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.getreconnected.reconnected.legacy.data - -import android.app.usage.UsageStats -import android.app.usage.UsageStatsManager -import android.content.Context -import android.graphics.drawable.Drawable -import java.util.concurrent.TimeUnit - -fun getWeeklyUsageStats(context: Context): List { - val usageStatsManager = - context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager - - val endTime = System.currentTimeMillis() - val startTime = endTime - TimeUnit.DAYS.toMillis(7) // last 7 days - - return usageStatsManager.queryUsageStats( - UsageStatsManager.INTERVAL_DAILY, - startTime, - endTime, - ) ?: emptyList() -} - -fun getAppInfo( - context: Context, - packageName: String, -): Pair? = - try { - val pm = context.packageManager - val appInfo = pm.getApplicationInfo(packageName, 0) - val name = pm.getApplicationLabel(appInfo).toString() - val icon = pm.getApplicationIcon(packageName) - name to icon - } catch (e: Exception) { - null - } - -fun getWeekNumber(installTime: Long): Int { - val diff = System.currentTimeMillis() - installTime - return (diff / TimeUnit.DAYS.toMillis(7)).toInt() + 1 -} - -suspend fun saveWeeklyUsage( - context: Context, - dao: WeeklyScreenTimeDao, - installTime: Long, -) { - val stats = getWeeklyUsageStats(context) - val totalTime = stats.sumOf { it.totalTimeInForeground } - - val topApps = - stats - .sortedByDescending { it.totalTimeInForeground } - .take(5) - .map { it.packageName } - - val weekNumber = getWeekNumber(installTime) - - dao.insertOrUpdate( - WeeklyScreenTime( - weekNumber = weekNumber, - totalTimeMillis = totalTime, - topApps = topApps, - ), - ) -} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/WeeklyScreenTime.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/WeeklyScreenTime.kt deleted file mode 100644 index c6f980f..0000000 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/WeeklyScreenTime.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.getreconnected.reconnected.legacy.data - -import androidx.room.Entity -import androidx.room.PrimaryKey - -@Entity(tableName = "weekly_screen_time") -data class WeeklyScreenTime( - @PrimaryKey(autoGenerate = true) val id: Int = 0, - val weekNumber: Int, - val totalTimeMillis: Long, - val topApps: List, -) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/WeeklyScreenTimeDao.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/WeeklyScreenTimeDao.kt deleted file mode 100644 index f3f1272..0000000 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/WeeklyScreenTimeDao.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.getreconnected.reconnected.legacy.data - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import kotlinx.coroutines.flow.Flow - -@Dao -interface WeeklyScreenTimeDao { - @Query("SELECT * FROM weekly_screen_time ORDER BY weekNumber ASC") - fun getAll(): Flow> - - @Query("SELECT * FROM weekly_screen_time WHERE weekNumber = :weekNumber LIMIT 1") - suspend fun getWeek(weekNumber: Int): WeeklyScreenTime? - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertOrUpdate(weeklyScreenTime: WeeklyScreenTime) -} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/AppNavigation.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/AppNavigation.kt index 1a25709..519ce9c 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/AppNavigation.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/AppNavigation.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import com.getreconnected.reconnected.core.AppDatabase import com.getreconnected.reconnected.core.models.Screens import com.getreconnected.reconnected.core.viewModels.UIRouteViewModel import com.getreconnected.reconnected.ui.screens.LoginScreen @@ -14,7 +13,7 @@ import com.getreconnected.reconnected.ui.screens.SplashScreen @Composable @Suppress("ktlint:standard:function-naming") -fun AppNavigation(database: AppDatabase) { +fun AppNavigation() { val viewModel: UIRouteViewModel = viewModel() // Initialize the view model for UI routes val navController = rememberNavController() NavHost(navController = navController, startDestination = Screens.Splash.name) { diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/elements/WeeklyCard.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/elements/WeeklyCard.kt index 8b0e51a..b5008fc 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/elements/WeeklyCard.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/elements/WeeklyCard.kt @@ -25,7 +25,7 @@ import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap import com.getreconnected.reconnected.R import com.getreconnected.reconnected.core.formatTime -import com.getreconnected.reconnected.legacy.data.WeeklyScreenTime +import com.getreconnected.reconnected.core.models.entities.WeeklyScreenTime /** * A composable that displays an icon for a given drawable. diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/AIAssistant.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/AIAssistant.kt index ec2eaa7..87dce91 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/AIAssistant.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/AIAssistant.kt @@ -44,7 +44,7 @@ import com.getreconnected.reconnected.R import com.getreconnected.reconnected.core.Chatbot import com.getreconnected.reconnected.core.auth.GoogleAuth import com.getreconnected.reconnected.core.dataManager.ChatManager -import com.getreconnected.reconnected.core.models.Chat +import com.getreconnected.reconnected.core.models.entities.Chat import com.google.firebase.Firebase import com.google.firebase.auth.auth import kotlinx.coroutines.launch diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt index 314ff39..ed3e08e 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt @@ -47,13 +47,13 @@ import androidx.navigation.NavController import com.getreconnected.reconnected.R import com.getreconnected.reconnected.activities.MainActivity import com.getreconnected.reconnected.core.Application +import com.getreconnected.reconnected.core.formatTime +import com.getreconnected.reconnected.core.getDaysActive +import com.getreconnected.reconnected.core.getScreenTimeInMillis import com.getreconnected.reconnected.core.models.Menus import com.getreconnected.reconnected.core.models.getMenuRoute import com.getreconnected.reconnected.core.util.hasUsageStatsPermission import com.getreconnected.reconnected.core.viewModels.UIRouteViewModel -import com.getreconnected.reconnected.legacy.data.formatScreenTime -import com.getreconnected.reconnected.legacy.data.getDaysActive -import com.getreconnected.reconnected.legacy.data.getScreenTimeInMillis import com.getreconnected.reconnected.ui.composables.elements.StatCard import com.getreconnected.reconnected.ui.theme.LocalReconnectEDColors import com.getreconnected.reconnected.ui.theme.interDisplayFamily @@ -139,7 +139,7 @@ fun Dashboard( val screenTimeValue = when { !hasPermission -> "Tap to permit" - else -> formatScreenTime(screenTimeMillis) + else -> formatTime(screenTimeMillis) } fun navigateTo(route: String) { @@ -172,10 +172,16 @@ fun Dashboard( CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface, ), - modifier = Modifier.fillMaxWidth().height(125.dp), + modifier = + Modifier + .fillMaxWidth() + .height(125.dp), ) { Column( - modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -205,7 +211,10 @@ fun Dashboard( } Row( - modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), + modifier = + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { StatCard( @@ -214,18 +223,26 @@ fun Dashboard( icon = painterResource(R.drawable.daily_screen_time), color = Color(0xFF008F46), // dark green modifier = - Modifier.weight(1f).fillMaxWidth().fillMaxHeight().clickable { - if (!hasPermission) { - context.startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) - } - }, + Modifier + .weight(1f) + .fillMaxWidth() + .fillMaxHeight() + .clickable { + if (!hasPermission) { + context.startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) + } + }, ) StatCard( title = "Days Active", value = "$daysActive days", icon = painterResource(R.drawable.days_active), color = Color(0xFF0453AE), // dark green - modifier = Modifier.weight(1f).fillMaxWidth().fillMaxHeight(), + modifier = + Modifier + .weight(1f) + .fillMaxWidth() + .fillMaxHeight(), ) } @@ -259,7 +276,10 @@ fun Dashboard( } } Row( - modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), + modifier = + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { ElevatedCard( @@ -269,12 +289,18 @@ fun Dashboard( containerColor = MaterialTheme.colorScheme.surface, ), modifier = - Modifier.weight(1f).fillMaxHeight().clickable { - navigateTo(Menus.AIAssistant.name) - }, + Modifier + .weight(1f) + .fillMaxHeight() + .clickable { + navigateTo(Menus.AIAssistant.name) + }, ) { Column( - modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 16.dp), + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 16.dp), horizontalAlignment = Alignment.Start, ) { Text( @@ -294,7 +320,11 @@ fun Dashboard( Image( painter = painterResource(id = R.drawable.gemini_logo), contentDescription = "Gemini AI Logo", - modifier = Modifier.height(128.dp).width(128.dp).padding(16.dp), + modifier = + Modifier + .height(128.dp) + .width(128.dp) + .padding(16.dp), ) } Text( @@ -316,12 +346,18 @@ fun Dashboard( containerColor = MaterialTheme.colorScheme.surface, ), modifier = - Modifier.weight(1f).fillMaxHeight().clickable { - navigateTo(Menus.ScreenTimeLimit.name) - }, + Modifier + .weight(1f) + .fillMaxHeight() + .clickable { + navigateTo(Menus.ScreenTimeLimit.name) + }, ) { Column( - modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 16.dp), + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 16.dp), horizontalAlignment = Alignment.Start, ) { Text( @@ -341,7 +377,11 @@ fun Dashboard( Image( painter = painterResource(id = R.drawable.screen_time_limit_green), contentDescription = "Limit App Usage", - modifier = Modifier.height(128.dp).width(128.dp).padding(16.dp), + modifier = + Modifier + .height(128.dp) + .width(128.dp) + .padding(16.dp), ) } Text( diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/ScreenTimeTracker.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/ScreenTimeTracker.kt index c2f78dc..e11ff07 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/ScreenTimeTracker.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/ScreenTimeTracker.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.getreconnected.reconnected.core.formatTime -import com.getreconnected.reconnected.core.models.AppUsageInfo +import com.getreconnected.reconnected.core.models.entities.AppUsageInfo import com.getreconnected.reconnected.core.viewModels.ScreenTimeTrackerViewModel import com.google.accompanist.drawablepainter.rememberDrawablePainter From 81744f81d6b3c9fa4b6981a093e34c136463d7f4 Mon Sep 17 00:00:00 2001 From: Chris1320 Date: Mon, 27 Oct 2025 02:52:51 +0800 Subject: [PATCH 04/42] feat(android): stylize app tracker menu Signed-off-by: Chris1320 --- .../reconnected/ui/menus/ScreenTimeTracker.kt | 120 +++++++++++++++--- 1 file changed, 105 insertions(+), 15 deletions(-) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/ScreenTimeTracker.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/ScreenTimeTracker.kt index e11ff07..42b6fce 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/ScreenTimeTracker.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/ScreenTimeTracker.kt @@ -1,16 +1,23 @@ package com.getreconnected.reconnected.ui.menus import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.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.material3.MaterialTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -18,10 +25,19 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment 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.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.getreconnected.reconnected.R import com.getreconnected.reconnected.core.formatTime import com.getreconnected.reconnected.core.models.entities.AppUsageInfo import com.getreconnected.reconnected.core.viewModels.ScreenTimeTrackerViewModel +import com.getreconnected.reconnected.ui.theme.interDisplayFamily import com.google.accompanist.drawablepainter.rememberDrawablePainter /** @@ -42,9 +58,62 @@ fun ScreenTimeTracker( viewModel.loadUsageStats() } - LazyColumn(modifier = modifier) { - items(appUsageStats) { appUsageInfo -> - AppUsageItem(appUsageInfo = appUsageInfo) + Column( + modifier = + modifier + .fillMaxSize() + .background( + brush = + Brush.verticalGradient( + colors = + listOf( + Color(0xFFD1FAE5), + Color(0xFFDBEAFE), + ), + ), + ).padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + modifier = Modifier.padding(top = 16.dp), + text = "Track your application usage", + style = + TextStyle( + fontFamily = interDisplayFamily, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = Color.Black, + ), + ) + AppTrackerContainer(appList = appUsageStats) + } +} + +/** + * A composable function that represents a container displaying app screen time information. + */ +@Composable +@Suppress("ktlint:standard:function-naming") +fun AppTrackerContainer(appList: List) { + Column { + Card( + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + // Allow card to take remaining space + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items(appList) { app -> + AppUsageItem(appUsageInfo = app) + } + } } } } @@ -61,20 +130,41 @@ fun AppUsageItem(appUsageInfo: AppUsageInfo) { modifier = Modifier .fillMaxWidth() - .padding(8.dp), + .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - Image( - painter = rememberDrawablePainter(drawable = appUsageInfo.appIcon), - contentDescription = "${appUsageInfo.appName} icon", - modifier = Modifier.size(40.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = + Modifier + .size(40.dp) + .clip(RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + Image( + painter = rememberDrawablePainter(drawable = appUsageInfo.appIcon), + contentDescription = "${appUsageInfo.appName} icon", + modifier = Modifier.size(40.dp), + ) + } Column(modifier = Modifier.weight(1f)) { - Text(text = appUsageInfo.appName, style = MaterialTheme.typography.bodyMedium) + Text( + text = appUsageInfo.appName, + style = + TextStyle( + fontFamily = interDisplayFamily, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + ), + ) Text( text = formatTime(appUsageInfo.usageTime), - style = MaterialTheme.typography.bodySmall, + style = + TextStyle( + fontFamily = interDisplayFamily, + fontSize = 16.sp, + color = Color.Gray, + ), ) } } From bb2af3b671186eefbcb618868293efc990abcc7c Mon Sep 17 00:00:00 2001 From: Chris1320 Date: Mon, 27 Oct 2025 03:04:17 +0800 Subject: [PATCH 05/42] feat(android): add internet permission Signed-off-by: Chris1320 --- ReconnectED-Android/app/src/main/AndroidManifest.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ReconnectED-Android/app/src/main/AndroidManifest.xml b/ReconnectED-Android/app/src/main/AndroidManifest.xml index 55e31ed..a7d0b6e 100644 --- a/ReconnectED-Android/app/src/main/AndroidManifest.xml +++ b/ReconnectED-Android/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + @@ -21,7 +22,6 @@ android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" - android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.App.Starting"> Date: Mon, 27 Oct 2025 03:04:52 +0800 Subject: [PATCH 06/42] chore(android): update app logo Signed-off-by: Chris1320 --- .../app/src/main/ic_launcher-playstore.png | 3 + .../res/drawable/ic_launcher_background.xml | 270 +++++------------- .../res/mipmap-anydpi-v26/ic_launcher.xml | 7 +- .../mipmap-anydpi-v26/ic_launcher_round.xml | 7 +- .../main/res/mipmap-anydpi/ic_launcher.xml | 6 - .../res/mipmap-anydpi/ic_launcher_round.xml | 6 - .../src/main/res/mipmap-hdpi/ic_launcher.webp | 4 +- .../mipmap-hdpi/ic_launcher_foreground.webp | 3 + .../res/mipmap-hdpi/ic_launcher_round.webp | 4 +- .../src/main/res/mipmap-mdpi/ic_launcher.webp | 4 +- .../mipmap-mdpi/ic_launcher_foreground.webp | 3 + .../res/mipmap-mdpi/ic_launcher_round.webp | 4 +- .../main/res/mipmap-xhdpi/ic_launcher.webp | 4 +- .../mipmap-xhdpi/ic_launcher_foreground.webp | 3 + .../res/mipmap-xhdpi/ic_launcher_round.webp | 4 +- .../main/res/mipmap-xxhdpi/ic_launcher.webp | 4 +- .../mipmap-xxhdpi/ic_launcher_foreground.webp | 3 + .../res/mipmap-xxhdpi/ic_launcher_round.webp | 4 +- .../main/res/mipmap-xxxhdpi/ic_launcher.webp | 4 +- .../ic_launcher_foreground.webp | 3 + .../res/mipmap-xxxhdpi/ic_launcher_round.webp | 4 +- .../res/values/ic_launcher_background.xml | 4 + 22 files changed, 119 insertions(+), 239 deletions(-) create mode 100644 ReconnectED-Android/app/src/main/ic_launcher-playstore.png delete mode 100644 ReconnectED-Android/app/src/main/res/mipmap-anydpi/ic_launcher.xml delete mode 100644 ReconnectED-Android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml create mode 100644 ReconnectED-Android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp create mode 100644 ReconnectED-Android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp create mode 100644 ReconnectED-Android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp create mode 100644 ReconnectED-Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp create mode 100644 ReconnectED-Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp create mode 100644 ReconnectED-Android/app/src/main/res/values/ic_launcher_background.xml diff --git a/ReconnectED-Android/app/src/main/ic_launcher-playstore.png b/ReconnectED-Android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..8e92fc6 --- /dev/null +++ b/ReconnectED-Android/app/src/main/ic_launcher-playstore.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dde531b768884e8c6b2ae670c39a74084ac1d04b26fd965b0e26c4fc27f2e0a3 +size 199918 diff --git a/ReconnectED-Android/app/src/main/res/drawable/ic_launcher_background.xml b/ReconnectED-Android/app/src/main/res/drawable/ic_launcher_background.xml index 46bfdc3..ca3826a 100644 --- a/ReconnectED-Android/app/src/main/res/drawable/ic_launcher_background.xml +++ b/ReconnectED-Android/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,202 +1,74 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:height="108dp" + android:width="108dp" + android:viewportHeight="108" + android:viewportWidth="108" + xmlns:android="http://schemas.android.com/apk/res/android"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ReconnectED-Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/ReconnectED-Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 3fe2441..036d09b 100644 --- a/ReconnectED-Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/ReconnectED-Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,5 @@ - - - - + + + \ No newline at end of file diff --git a/ReconnectED-Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/ReconnectED-Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 3fe2441..036d09b 100644 --- a/ReconnectED-Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/ReconnectED-Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,6 +1,5 @@ - - - - + + + \ No newline at end of file diff --git a/ReconnectED-Android/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/ReconnectED-Android/app/src/main/res/mipmap-anydpi/ic_launcher.xml deleted file mode 100644 index 3fe2441..0000000 --- a/ReconnectED-Android/app/src/main/res/mipmap-anydpi/ic_launcher.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/ReconnectED-Android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/ReconnectED-Android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml deleted file mode 100644 index 3fe2441..0000000 --- a/ReconnectED-Android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/ReconnectED-Android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/ReconnectED-Android/app/src/main/res/mipmap-hdpi/ic_launcher.webp index 9e42a21..f6201ff 100644 --- a/ReconnectED-Android/app/src/main/res/mipmap-hdpi/ic_launcher.webp +++ b/ReconnectED-Android/app/src/main/res/mipmap-hdpi/ic_launcher.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dd00996198640ed28fbc09cdcd7a3807cf8707f3eb255b659634da3ca6a6ff01 -size 1404 +oid sha256:ed851b5feb8b4576732cfb3dc20126d8614b84203776a57c144762c385d84a2d +size 3744 diff --git a/ReconnectED-Android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/ReconnectED-Android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..e09db24 --- /dev/null +++ b/ReconnectED-Android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1470e0bb96062ec3a20308be605eac2e61ff741b7f8ace3c6d96ad9b8aa3ae43 +size 9726 diff --git a/ReconnectED-Android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/ReconnectED-Android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index b6ca907..b83f0d2 100644 --- a/ReconnectED-Android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp +++ b/ReconnectED-Android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1ed73f5341a69d3b41c7e02e126803f50cc8c3284adf4bbb737f0c93577aef07 -size 2898 +oid sha256:310c80f2848c58b7c016446111c3591c69b4f5b17e149317e71132549b6cb99d +size 6226 diff --git a/ReconnectED-Android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/ReconnectED-Android/app/src/main/res/mipmap-mdpi/ic_launcher.webp index 093094e..426e82e 100644 --- a/ReconnectED-Android/app/src/main/res/mipmap-mdpi/ic_launcher.webp +++ b/ReconnectED-Android/app/src/main/res/mipmap-mdpi/ic_launcher.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:846219e6f72fe9a6c104ca8919cbee36a101e7d2ff8da9da67b689a5888f060d -size 982 +oid sha256:6e64a05b0dc0c4a80af9b8434bd863d3f632214528b07e4ba8b58338b5a5e3e1 +size 2204 diff --git a/ReconnectED-Android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/ReconnectED-Android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..d08f697 --- /dev/null +++ b/ReconnectED-Android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a7e5f6dc0452bc9a3da94ea3e3dfbe0bb3c54aa93ec31a7a2383cec273f2ff7b +size 5408 diff --git a/ReconnectED-Android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/ReconnectED-Android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index acb4b1a..652db50 100644 --- a/ReconnectED-Android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp +++ b/ReconnectED-Android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4e2c58b91de01130e6479e00cbbaaf6e77cd961dd2c8e303cf13cd077fa92632 -size 1772 +oid sha256:197e48c03e0a716881cbdd26379c29eddbab0914896f1bcfe879d5580cb8bcac +size 3520 diff --git a/ReconnectED-Android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/ReconnectED-Android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index 7cafaf7..050f1aa 100644 --- a/ReconnectED-Android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp +++ b/ReconnectED-Android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:398340dad816fc9a6338cb151a8cf1e45b926f9bfb70628b24c4bd2523cc94d4 -size 1900 +oid sha256:bcac011d71a8043c501bca7861680a0e5e2c9671b55f80659e6131e22a74be84 +size 5644 diff --git a/ReconnectED-Android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/ReconnectED-Android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..d03bf43 --- /dev/null +++ b/ReconnectED-Android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a2bab5e0e16a614ac198af648d6956c5033d4814447dc3f87325b6b681399b47 +size 15016 diff --git a/ReconnectED-Android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/ReconnectED-Android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 1457001..559b50b 100644 --- a/ReconnectED-Android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp +++ b/ReconnectED-Android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:91b490aef86574901137f0a252272e1f1add99c1ad709e0696ed68929b88d261 -size 3918 +oid sha256:800102d56eff32e7422c61c8f033f022a4d761d3016dab79561ff8b9d339c189 +size 9224 diff --git a/ReconnectED-Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/ReconnectED-Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index fba5dcf..c357c95 100644 --- a/ReconnectED-Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp +++ b/ReconnectED-Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:58ae87fa0c5b5d1562d27fd648d2c061553fe20e3ed570bde588162d01ea7a27 -size 2884 +oid sha256:464e88104a707c3283e0a297d14d6258b6003369961cf4b6418d895ce5549adc +size 10104 diff --git a/ReconnectED-Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/ReconnectED-Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..b9a1ef3 --- /dev/null +++ b/ReconnectED-Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db6cadb14d0e853a3afe7845356351b6c1d75ad26cf7272b4abe1f16629ee195 +size 27506 diff --git a/ReconnectED-Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/ReconnectED-Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index d2f6e79..c82c810 100644 --- a/ReconnectED-Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp +++ b/ReconnectED-Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3009fad079f5772f30ecd767f98924367fbe0f81c30048d672d4fda2d5ca7d12 -size 5914 +oid sha256:8ad8f39e71f86141e675c3bb4bc2dac6507bc78bcb1774a8d30c820e54dac37b +size 16100 diff --git a/ReconnectED-Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/ReconnectED-Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index 1c94e00..5ed40ab 100644 --- a/ReconnectED-Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp +++ b/ReconnectED-Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f98fef5bc3bfe5b65692c40ad1cbae2bec4faf9f1b249c397626740db71b62d8 -size 3844 +oid sha256:cc75cff37663ad83dcbf12864e3c8f6538fcbeb52c625634051d49c8960835db +size 15234 diff --git a/ReconnectED-Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/ReconnectED-Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..2cad21e --- /dev/null +++ b/ReconnectED-Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0cf9ac152cd04f9a1d0caa7bf9ca8346616fb2c56835d534b33ae3a96254079 +size 42860 diff --git a/ReconnectED-Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/ReconnectED-Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index f2143ae..618f74f 100644 --- a/ReconnectED-Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp +++ b/ReconnectED-Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5faf033745c8c882c43bb1b372d4e4a5890caec63d9892a2e756c954108b85dc -size 7778 +oid sha256:3fce41476c54967cf17a299abd1128c745901ded7727da2ea69f6c03e9bcfc5e +size 23336 diff --git a/ReconnectED-Android/app/src/main/res/values/ic_launcher_background.xml b/ReconnectED-Android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..e527c8e --- /dev/null +++ b/ReconnectED-Android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #28911A + \ No newline at end of file From ae69801584d0520189dfb69ccd8a11371d14f627 Mon Sep 17 00:00:00 2001 From: Chris1320 Date: Mon, 27 Oct 2025 03:39:16 +0800 Subject: [PATCH 07/42] feat(android): randomize quotes - refactor utils into separate packages. Signed-off-by: Chris1320 --- .../app/src/main/assets/quotes.json | 286 ++++++++++++++++++ .../getreconnected/reconnected/core/Info.kt | 1 + .../core/dataManager/QuotesManager.kt | 30 ++ .../reconnected/core/models/entities/Quote.kt | 6 + .../core/{Utils.kt => util/ScreenTime.kt} | 20 +- .../reconnected/core/util/TimeManagement.kt | 18 ++ .../ui/composables/elements/WeeklyCard.kt | 2 +- .../reconnected/ui/menus/Dashboard.kt | 25 +- .../reconnected/ui/menus/ScreenTimeTracker.kt | 6 +- 9 files changed, 358 insertions(+), 36 deletions(-) create mode 100644 ReconnectED-Android/app/src/main/assets/quotes.json create mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/QuotesManager.kt create mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/Quote.kt rename ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/{Utils.kt => util/ScreenTime.kt} (79%) create mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/TimeManagement.kt diff --git a/ReconnectED-Android/app/src/main/assets/quotes.json b/ReconnectED-Android/app/src/main/assets/quotes.json new file mode 100644 index 0000000..845536f --- /dev/null +++ b/ReconnectED-Android/app/src/main/assets/quotes.json @@ -0,0 +1,286 @@ +[ + { + "quote": "Digital detox is not about disconnecting, but reconnecting.", + "author": "Anonymous" + }, + { + "quote": "Almost everything will work again if you unplug it for a few minutes, including you.", + "author": "Anne Lamott" + }, + { + "quote": "You don’t have to disconnect from the world, just from the noise.", + "author": "Anonymous" + }, + { + "quote": "Clarity comes when the screens go dark.", + "author": "Anonymous" + }, + { + "quote": "Technology is a useful servant but a dangerous master.", + "author": "Christian Lous Lange" + }, + { + "quote": "The best productivity hack is still a good night’s sleep.", + "author": "Anonymous" + }, + { + "quote": "Focus is the new IQ.", + "author": "Cal Newport" + }, + { + "quote": "Your attention is your most valuable currency.", + "author": "Anonymous" + }, + { + "quote": "Detox your mind by decluttering your digital life.", + "author": "Anonymous" + }, + { + "quote": "Productivity is never an accident. It is always the result of a commitment to excellence, intelligent planning, and focused effort.", + "author": "Paul J. Meyer" + }, + { + "quote": "Almost everything distracting you is less important than you think.", + "author": "Anonymous" + }, + { + "quote": "The price of productivity is the courage to say no.", + "author": "Anonymous" + }, + { + "quote": "Do not disturb is a productivity superpower.", + "author": "Anonymous" + }, + { + "quote": "The more you check your phone, the less you check in with yourself.", + "author": "Anonymous" + }, + { + "quote": "Simplicity is the ultimate sophistication.", + "author": "Leonardo da Vinci" + }, + { + "quote": "Time is what we want most, but what we use worst.", + "author": "William Penn" + }, + { + "quote": "Your phone should serve you, not enslave you.", + "author": "Anonymous" + }, + { + "quote": "The ability to focus is a competitive advantage.", + "author": "Anonymous" + }, + { + "quote": "Don’t confuse being busy with being productive.", + "author": "Anonymous" + }, + { + "quote": "Sometimes the most productive thing you can do is rest.", + "author": "Mark Black" + }, + { + "quote": "Disconnect to reconnect with what matters.", + "author": "Anonymous" + }, + { + "quote": "Beware the barrenness of a busy life.", + "author": "Socrates" + }, + { + "quote": "Productivity is being able to do things that you were never able to do before.", + "author": "Franz Kafka" + }, + { + "quote": "Your attention is being mined. Guard it fiercely.", + "author": "Anonymous" + }, + { + "quote": "The key is not to prioritize what’s on your schedule, but to schedule your priorities.", + "author": "Stephen Covey" + }, + { + "quote": "Digital detox is a gift of presence to yourself and others.", + "author": "Anonymous" + }, + { + "quote": "Focus on being productive instead of busy.", + "author": "Tim Ferriss" + }, + { + "quote": "Almost all creativity requires purposeful un-plugging.", + "author": "Anonymous" + }, + { + "quote": "The shorter way to do many things is to only do one thing at a time.", + "author": "Mozart" + }, + { + "quote": "Your phone is a tool, not a tether.", + "author": "Anonymous" + }, + { + "quote": "The future depends on what you do today.", + "author": "Mahatma Gandhi" + }, + { + "quote": "Silence is sometimes the best productivity hack.", + "author": "Anonymous" + }, + { + "quote": "Don’t get so busy making a living that you forget to make a life.", + "author": "Dolly Parton" + }, + { + "quote": "The best way to get something done is to begin.", + "author": "Anonymous" + }, + { + "quote": "Digital detox is not deprivation, it’s liberation.", + "author": "Anonymous" + }, + { + "quote": "Lost time is never found again.", + "author": "Benjamin Franklin" + }, + { + "quote": "The art of being wise is knowing what to overlook.", + "author": "William James" + }, + { + "quote": "Your brain is not designed for constant notifications.", + "author": "Anonymous" + }, + { + "quote": "Concentration is the secret of strength.", + "author": "Ralph Waldo Emerson" + }, + { + "quote": "Technology should enhance life, not consume it.", + "author": "Anonymous" + }, + { + "quote": "The way to get started is to quit talking and begin doing.", + "author": "Walt Disney" + }, + { + "quote": "Digital detox is a reset button for your mind.", + "author": "Anonymous" + }, + { + "quote": "Productivity is less about time management and more about mind management.", + "author": "Anonymous" + }, + { + "quote": "The ability to simplify means to eliminate the unnecessary so that the necessary may speak.", + "author": "Hans Hofmann" + }, + { + "quote": "Your best ideas rarely come when you’re scrolling.", + "author": "Anonymous" + }, + { + "quote": "Don’t watch the clock; do what it does. Keep going.", + "author": "Sam Levenson" + }, + { + "quote": "The more you subtract, the more you add to your life.", + "author": "Anonymous" + }, + { + "quote": "Focus is the art of knowing what to ignore.", + "author": "Anonymous" + }, + { + "quote": "Digital detox is self-care in the modern age.", + "author": "Anonymous" + }, + { + "quote": "The successful warrior is the average man, with laser-like focus.", + "author": "Bruce Lee" + }, + { + "quote": "Your phone steals your mornings if you let it.", + "author": "Anonymous" + }, + { + "quote": "Don’t confuse motion with progress.", + "author": "Anonymous" + }, + { + "quote": "The greatest wealth is to live content with little.", + "author": "Plato" + }, + { + "quote": "Digital detox is not about less life, but more real life.", + "author": "Anonymous" + }, + { + "quote": "Productivity is never about doing more things, but doing the right things.", + "author": "Anonymous" + }, + { + "quote": "The best way to focus is to remove distractions, not add willpower.", + "author": "Anonymous" + }, + { + "quote": "Happiness is not having what you want. It is wanting what you have.", + "author": "Anonymous" + }, + { + "quote": "Your attention is the battlefield of the 21st century.", + "author": "Anonymous" + }, + { + "quote": "Don’t let your phone be the first thing you touch in the morning.", + "author": "Anonymous" + }, + { + "quote": "The key to productivity is to do less, better.", + "author": "Anonymous" + }, + { + "quote": "Digital detox is a vacation for your brain.", + "author": "Anonymous" + }, + { + "quote": "The secret of getting ahead is getting started.", + "author": "Mark Twain" + }, + { + "quote": "Your focus determines your reality.", + "author": "George Lucas" + }, + { + "quote": "Don’t drown in information, starve for wisdom.", + "author": "Anonymous" + }, + { + "quote": "The best productivity system is the one you actually use.", + "author": "Anonymous" + }, + { + "quote": "Digital detox is a way of reclaiming your time.", + "author": "Anonymous" + }, + { + "quote": "The man who moves a mountain begins by carrying away small stones.", + "author": "Confucius" + }, + { + "quote": "Your phone is not your life; it’s just a device.", + "author": "Anonymous" + }, + { + "quote": "Don’t confuse activity with achievement.", + "author": "John Wooden" + }, + { + "quote": "Digital detox is not about losing connection, but gaining perspective.", + "author": "Anonymous" + }, + { + "quote": "The key to productivity is focus, not frenzy.", + "author": "Anonymous" + } +] diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Info.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Info.kt index 38e3a83..c388db7 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Info.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Info.kt @@ -8,6 +8,7 @@ data object Application { data object FilePaths { const val USER = "user-data.json" const val AVATAR = "user-avatar.png" + const val QUOTES = "quotes.json" } data object Chatbot { diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/QuotesManager.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/QuotesManager.kt new file mode 100644 index 0000000..e88345c --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/QuotesManager.kt @@ -0,0 +1,30 @@ +package com.getreconnected.reconnected.core.dataManager + +import android.content.Context +import com.getreconnected.reconnected.core.FilePaths +import com.getreconnected.reconnected.core.models.entities.Quote +import com.google.gson.Gson + +object QuotesManager { + private var quotes = emptyArray() + + fun getQuotes(context: Context): Array { + if (quotes.isEmpty()) { + load(context) + } + + return quotes + } + + fun load(context: Context) { + val inputStream = context.assets.open(FilePaths.QUOTES) + val size: Int = inputStream.available() + val buffer = ByteArray(size) + inputStream.read(buffer) + inputStream.close() + + val json = String(buffer, charset = Charsets.UTF_8) + val gson = Gson() + quotes = gson.fromJson(json, Array::class.java) + } +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/Quote.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/Quote.kt new file mode 100644 index 0000000..030cdad --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/Quote.kt @@ -0,0 +1,6 @@ +package com.getreconnected.reconnected.core.models.entities + +data class Quote( + val quote: String, + val author: String, +) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Utils.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/ScreenTime.kt similarity index 79% rename from ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Utils.kt rename to ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/ScreenTime.kt index c8062a7..2b364db 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Utils.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/ScreenTime.kt @@ -1,29 +1,11 @@ -package com.getreconnected.reconnected.core +package com.getreconnected.reconnected.core.util import android.app.usage.UsageEvents import android.app.usage.UsageStatsManager import android.content.Context -import com.getreconnected.reconnected.core.util.hasUsageStatsPermission import java.util.Calendar import java.util.concurrent.TimeUnit -/** - * Formats the given time in milliseconds to a human-readable string. - * - * @param t The time in milliseconds to format. - * @return A string representing the formatted time in the format "h m s". - */ -fun formatTime(t: Long): String { - val seconds = (t / 1000) % 60 - val minutes = (t / (1000 * 60)) % 60 - val hours = (t / (1000 * 60 * 60)) % 24 - return when { - hours > 0 -> "${hours}h ${minutes}m ${seconds}s" - minutes > 0 -> "${minutes}m ${seconds}s" - else -> "${seconds}s" - } -} - /** * Calculates the number of days the application has been active since its first installation. * diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/TimeManagement.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/TimeManagement.kt new file mode 100644 index 0000000..5ad4bea --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/TimeManagement.kt @@ -0,0 +1,18 @@ +package com.getreconnected.reconnected.core.util + +/** + * Formats the given time in milliseconds to a human-readable string. + * + * @param t The time in milliseconds to format. + * @return A string representing the formatted time in the format "h m s". + */ +fun formatTime(t: Long): String { + val seconds = (t / 1000) % 60 + val minutes = (t / (1000 * 60)) % 60 + val hours = (t / (1000 * 60 * 60)) % 24 + return when { + hours > 0 -> "${hours}h ${minutes}m ${seconds}s" + minutes > 0 -> "${minutes}m ${seconds}s" + else -> "${seconds}s" + } +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/elements/WeeklyCard.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/elements/WeeklyCard.kt index b5008fc..c3e3d81 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/elements/WeeklyCard.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/elements/WeeklyCard.kt @@ -24,8 +24,8 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap import com.getreconnected.reconnected.R -import com.getreconnected.reconnected.core.formatTime import com.getreconnected.reconnected.core.models.entities.WeeklyScreenTime +import com.getreconnected.reconnected.core.util.formatTime /** * A composable that displays an icon for a given drawable. diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt index ed3e08e..4cc3e20 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt @@ -47,11 +47,12 @@ import androidx.navigation.NavController import com.getreconnected.reconnected.R import com.getreconnected.reconnected.activities.MainActivity import com.getreconnected.reconnected.core.Application -import com.getreconnected.reconnected.core.formatTime -import com.getreconnected.reconnected.core.getDaysActive -import com.getreconnected.reconnected.core.getScreenTimeInMillis +import com.getreconnected.reconnected.core.dataManager.QuotesManager import com.getreconnected.reconnected.core.models.Menus import com.getreconnected.reconnected.core.models.getMenuRoute +import com.getreconnected.reconnected.core.util.formatTime +import com.getreconnected.reconnected.core.util.getDaysActive +import com.getreconnected.reconnected.core.util.getScreenTimeInMillis import com.getreconnected.reconnected.core.util.hasUsageStatsPermission import com.getreconnected.reconnected.core.viewModels.UIRouteViewModel import com.getreconnected.reconnected.ui.composables.elements.StatCard @@ -99,6 +100,8 @@ fun Dashboard( val gradientStart = LocalReconnectEDColors.current.gradientStart val gradientEnd = LocalReconnectEDColors.current.gradientEnd + val selectedQuote = QuotesManager.getQuotes(context).random() + val screenTimeMillis by produceState(initialValue = -1L, key1 = hasPermission) { if (hasPermission) { while (true) { @@ -186,26 +189,26 @@ fun Dashboard( verticalArrangement = Arrangement.Center, ) { Text( - text = "Daily Inspiration", + text = "“${selectedQuote.quote}”", style = TextStyle( fontFamily = interDisplayFamily, - fontWeight = FontWeight.Light, + fontWeight = FontWeight.SemiBold, + fontStyle = FontStyle.Italic, + fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface, ), + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 8.dp), ) Text( - text = "“Digital detox is not about disconnecting, but reconnecting.”", + text = "- ${selectedQuote.author}", style = TextStyle( fontFamily = interDisplayFamily, - fontWeight = FontWeight.SemiBold, - fontStyle = FontStyle.Italic, - fontSize = 16.sp, + fontWeight = FontWeight.Light, color = MaterialTheme.colorScheme.onSurface, ), - textAlign = TextAlign.Center, - modifier = Modifier.padding(vertical = 8.dp), ) } } diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/ScreenTimeTracker.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/ScreenTimeTracker.kt index 42b6fce..78dc918 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/ScreenTimeTracker.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/ScreenTimeTracker.kt @@ -16,8 +16,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -28,14 +26,12 @@ 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.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.getreconnected.reconnected.R -import com.getreconnected.reconnected.core.formatTime import com.getreconnected.reconnected.core.models.entities.AppUsageInfo +import com.getreconnected.reconnected.core.util.formatTime import com.getreconnected.reconnected.core.viewModels.ScreenTimeTrackerViewModel import com.getreconnected.reconnected.ui.theme.interDisplayFamily import com.google.accompanist.drawablepainter.rememberDrawablePainter From cf5e4f60407a57b47ce10683b312a9ba7576c0b2 Mon Sep 17 00:00:00 2001 From: Chris1320 Date: Mon, 27 Oct 2025 03:42:45 +0800 Subject: [PATCH 08/42] feat(android): navigate to screen time tracker when screen time today is tapped Signed-off-by: Chris1320 --- .../java/com/getreconnected/reconnected/ui/menus/Dashboard.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt index 4cc3e20..f68458f 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt @@ -233,6 +233,8 @@ fun Dashboard( .clickable { if (!hasPermission) { context.startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) + } else { + navigateTo(Menus.ScreenTimeTracker.name) } }, ) From ef45e7f563cf17df289733ca4f6edc36d2c666b1 Mon Sep 17 00:00:00 2001 From: Chris1320 Date: Mon, 27 Oct 2025 03:45:58 +0800 Subject: [PATCH 09/42] feat(android): click the elevated card to change the quote Signed-off-by: Chris1320 --- .../com/getreconnected/reconnected/ui/menus/Dashboard.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt index f68458f..0a01674 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt @@ -29,6 +29,7 @@ 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.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -100,7 +101,7 @@ fun Dashboard( val gradientStart = LocalReconnectEDColors.current.gradientStart val gradientEnd = LocalReconnectEDColors.current.gradientEnd - val selectedQuote = QuotesManager.getQuotes(context).random() + val (selectedQuote, setSelectedQuote) = remember { mutableStateOf(QuotesManager.getQuotes(context).random()) } val screenTimeMillis by produceState(initialValue = -1L, key1 = hasPermission) { if (hasPermission) { @@ -178,7 +179,10 @@ fun Dashboard( modifier = Modifier .fillMaxWidth() - .height(125.dp), + .height(125.dp) + .clickable { + setSelectedQuote(QuotesManager.getQuotes(context).random()) + }, ) { Column( modifier = From 0be05a722911e25578b28ef43a97eb5f72f8a26f Mon Sep 17 00:00:00 2001 From: Chris1320 Date: Mon, 27 Oct 2025 04:33:23 +0800 Subject: [PATCH 10/42] feat(android): fetch and display user avatar from Google account Signed-off-by: Chris1320 --- .../core/dataManager/UserManager.kt | 43 ++++++++++++++----- .../reconnected/ui/composables/NavDrawer.kt | 14 +++++- .../ui/composables/elements/TopBar.kt | 42 +++++++++++++++++- .../reconnected/ui/screens/LoginScreen.kt | 10 +++++ .../reconnected/ui/screens/MainScreen.kt | 4 ++ 5 files changed, 100 insertions(+), 13 deletions(-) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/UserManager.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/UserManager.kt index be4e920..04bd41a 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/UserManager.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/UserManager.kt @@ -1,8 +1,13 @@ package com.getreconnected.reconnected.core.dataManager import android.graphics.Bitmap +import android.graphics.BitmapFactory import com.getreconnected.reconnected.core.models.entities.User +import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.URL /** * Manages user-related operations and data within the application. @@ -13,28 +18,46 @@ import com.google.firebase.auth.FirebaseUser object UserManager { var isLoggedIn: Boolean = false private set + var user: User? = null + private set /** * Save user data to local storage. * * @param userInfo Firebase user information to be saved. */ - fun login(userInfo: FirebaseUser) { - val avatarBitmap: Bitmap? = null - User( - displayName = userInfo.displayName ?: "", - email = userInfo.email ?: "", - avatar = avatarBitmap, - created = 0, - lastSignIn = 0, - ) + suspend fun login(userInfo: FirebaseUser) { + val avatarBitmap: Bitmap? = + withContext(Dispatchers.IO) { + try { + userInfo.photoUrl?.let { + val url = URL(it.toString()) + BitmapFactory.decodeStream(url.openStream()) + } + } catch (e: Exception) { + null + } + } + user = + User( + displayName = userInfo.displayName ?: "", + email = userInfo.email ?: "", + avatar = avatarBitmap, + created = 0, + lastSignIn = 0, + ) isLoggedIn = true } /** * Remove locally-saved information about the user. */ - fun logout() {} + fun logout() { + val firebaseAuth = FirebaseAuth.getInstance() + firebaseAuth.signOut() + user = null + isLoggedIn = false + } /** * Update user information in the Firestore database. diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/NavDrawer.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/NavDrawer.kt index 3b161ac..13afb45 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/NavDrawer.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/NavDrawer.kt @@ -13,6 +13,7 @@ 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.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material3.CardDefaults @@ -30,8 +31,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope 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.graphics.RectangleShape +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle @@ -44,6 +48,7 @@ import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.getreconnected.reconnected.R +import com.getreconnected.reconnected.core.dataManager.UserManager import com.getreconnected.reconnected.core.models.Menus import com.getreconnected.reconnected.core.models.getMenuRoute import com.getreconnected.reconnected.core.viewModels.UIRouteViewModel @@ -233,7 +238,14 @@ fun NavDrawer( modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon( + UserManager.user?.avatar?.let { avatarBitmap -> + Image( + painter = BitmapPainter(avatarBitmap.asImageBitmap()), + contentDescription = "Profile", + modifier = Modifier.size(48.dp).clip(CircleShape), + contentScale = ContentScale.Crop, + ) + } ?: Icon( imageVector = Icons.Default.AccountCircle, contentDescription = "Profile", modifier = Modifier.size(48.dp), diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/elements/TopBar.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/elements/TopBar.kt index 1fa9fb1..b057514 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/elements/TopBar.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/elements/TopBar.kt @@ -1,8 +1,12 @@ package com.getreconnected.reconnected.ui.composables.elements +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Menu @@ -15,11 +19,21 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle 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.navigation.NavHostController +import com.getreconnected.reconnected.activities.MainActivity +import com.getreconnected.reconnected.core.Application +import com.getreconnected.reconnected.core.dataManager.UserManager +import com.getreconnected.reconnected.core.models.Screens import com.getreconnected.reconnected.ui.theme.ReconnectEDTheme import com.getreconnected.reconnected.ui.theme.interDisplayFamily @@ -27,6 +41,7 @@ import com.getreconnected.reconnected.ui.theme.interDisplayFamily * The top bar of the app. * * @param title The title of the top bar. + * @param navController The navigation controller. * @param onOpenDrawer The function to open the drawer. */ @OptIn(ExperimentalMaterial3Api::class) @@ -34,6 +49,8 @@ import com.getreconnected.reconnected.ui.theme.interDisplayFamily @Suppress("ktlint:standard:function-naming") fun TopBar( title: String, + context: Context, + navController: NavHostController, onOpenDrawer: () -> Unit, ) { TopAppBar( @@ -63,8 +80,25 @@ fun TopBar( } }, actions = { - IconButton(onClick = { /* Handle profile click */ }) { - Icon( + IconButton(onClick = { + Toast + .makeText( + context, + "You are now logged out.", + Toast.LENGTH_LONG, + ).show() + UserManager.logout() + // TODO: navigate back to login screen + (context as MainActivity).finish() + }) { + UserManager.user?.avatar?.let { avatarBitmap -> + Image( + painter = BitmapPainter(avatarBitmap.asImageBitmap()), + contentDescription = "Profile", + modifier = Modifier.width(36.dp).height(36.dp).clip(CircleShape), + contentScale = ContentScale.Crop, + ) + } ?: Icon( imageVector = Icons.Default.AccountCircle, contentDescription = "User Profile", modifier = Modifier.padding(1.dp).width(36.dp).height(36.dp), @@ -79,9 +113,13 @@ fun TopBar( @Composable @Suppress("ktlint:standard:function-naming") fun TopBarPreview() { + val ctx = LocalContext.current + val navController = NavHostController(LocalContext.current) ReconnectEDTheme { TopBar( title = "Dashboard", + context = ctx, + navController = navController, onOpenDrawer = {}, ) } diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/screens/LoginScreen.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/screens/LoginScreen.kt index 7f7cc64..39b3437 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/screens/LoginScreen.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/screens/LoginScreen.kt @@ -23,6 +23,7 @@ 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -38,11 +39,13 @@ import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController import com.getreconnected.reconnected.R import com.getreconnected.reconnected.core.auth.GoogleAuth +import com.getreconnected.reconnected.core.dataManager.UserManager import com.getreconnected.reconnected.core.models.Screens import com.getreconnected.reconnected.core.viewModels.UIRouteViewModel import com.getreconnected.reconnected.ui.theme.ReconnectEDTheme import com.google.firebase.auth.FirebaseAuth import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @Composable @Suppress("ktlint:standard:function-naming") @@ -50,6 +53,7 @@ fun LoginScreen( viewModel: UIRouteViewModel, navController: NavController, ) { + val coroutineScope = rememberCoroutineScope() val firebaseAuth = FirebaseAuth.getInstance() val signInLauncher = rememberLauncherForActivityResult( @@ -68,6 +72,9 @@ fun LoginScreen( Log.d("LoginScreen", "User ID: ${firebaseAuth.currentUser?.uid}") Log.d("LoginScreen", "User email: ${firebaseAuth.currentUser?.email}") Log.d("LoginScreen", "User name: ${firebaseAuth.currentUser?.displayName}") + coroutineScope.launch { + UserManager.login(firebaseAuth.currentUser!!) + } Log.d("LoginScreen", "Navigating to Dashboard...") navController.navigate(Screens.Dashboard.name) { popUpTo(Screens.Login.name) { inclusive = true } @@ -82,6 +89,9 @@ fun LoginScreen( val googleAuth = GoogleAuth() if (firebaseAuth.currentUser != null) { + LaunchedEffect(Unit) { + UserManager.login(firebaseAuth.currentUser!!) + } viewModel.setActiveUser(firebaseAuth.currentUser?.displayName ?: "User") // User is already signed in, navigate to the main screen navController.navigate(Screens.Dashboard.name) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/screens/MainScreen.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/screens/MainScreen.kt index 13e0b43..c905555 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/screens/MainScreen.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/screens/MainScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost @@ -46,6 +47,7 @@ fun MainScreen( modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() + val context = LocalContext.current val navController = rememberNavController() val screenTimeTrackerViewModel: ScreenTimeTrackerViewModel = viewModel() @@ -67,6 +69,8 @@ fun MainScreen( topBar = { TopBar( title = currentMenu.title, + context = context, + navController = navController, onOpenDrawer = { scope.launch { drawerState.apply { if (isClosed) open() else close() } From 3fdaf09c0674e6fc716ec3a134c5c1a5fbff7b49 Mon Sep 17 00:00:00 2001 From: Chris1320 Date: Mon, 27 Oct 2025 12:21:10 +0800 Subject: [PATCH 11/42] feat(wip,android): integrate real app usage data into AI assistant Integrates the app usage data aggregation system with the AI assistant. The chatbot now receives the user's name, days since app installation, and a real-time breakdown of their daily screen time for a more personalized and context-aware conversation. - Modified `ChatManager` to accept app usage data and context to generate a dynamic initial prompt. - Updated `AIAssistant` to fetch daily app usage stats using `AppUsageRepository` upon initialization. - Added loading states (`isChatInitializing`) to the UI to handle asynchronous data fetching before the chat is ready. - Corrected `getDaysActive` calculation to no longer add an extra day. Signed-off-by: Chris1320 --- .../core/dataManager/ChatManager.kt | 52 ++++++++++----- .../reconnected/core/util/ScreenTime.kt | 2 +- .../reconnected/ui/menus/AIAssistant.kt | 63 ++++++++++++++----- 3 files changed, 87 insertions(+), 30 deletions(-) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/ChatManager.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/ChatManager.kt index f7cbc92..d412eb7 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/ChatManager.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/ChatManager.kt @@ -1,6 +1,16 @@ package com.getreconnected.reconnected.core.dataManager +import android.content.Context +import android.util.Log +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import com.getreconnected.reconnected.core.AppUsageRepository import com.getreconnected.reconnected.core.Chatbot +import com.getreconnected.reconnected.core.models.entities.AppUsageInfo +import com.getreconnected.reconnected.core.util.formatTime +import com.getreconnected.reconnected.core.util.getDaysActive +import com.getreconnected.reconnected.core.util.getScreenTimeInMillis import com.google.firebase.Firebase import com.google.firebase.ai.Chat import com.google.firebase.ai.GenerativeModel @@ -19,13 +29,18 @@ object ChatManager { * * @param name The name of the user starting the chat. This will be included in the initial message. */ - fun startChat(name: String?): Chat { + fun startChat( + name: String?, + appUsageBreakdown: List, + context: Context, + ): Chat { + val initialPrompt = generateInitialPrompt(name, appUsageBreakdown, context) model = Firebase .ai(backend = GenerativeBackend.Companion.googleAI()) .generativeModel( modelName = Chatbot.MODEL, - systemInstruction = content { text(generateInitialPrompt(name)) }, + systemInstruction = content { text(initialPrompt) }, safetySettings = listOf( SafetySetting( @@ -90,23 +105,32 @@ object ChatManager { * @param name The name of the user to be included in the generated prompt. * @return A string containing the initial chatbot prompt with user-specific details and screen time breakdown. */ - fun generateInitialPrompt(name: String?): String { + fun generateInitialPrompt( + name: String?, + appUsageBreakdown: List, + context: Context, + ): String { // TODO: Integrate the data aggregation system to get this information - val daysSinceStarted = 8 - val screenTimeTotal = 380.42 - return Chatbot.INITIAL_PROMPT + - """ + val daysSinceStarted = getDaysActive(context = context) + val screenTimeTotal = formatTime(getScreenTimeInMillis(context = context)) + + var prompt = + Chatbot.INITIAL_PROMPT + + """ | |The user has this information: |- Name: ${name ?: "Unable to retrieve name"} |- Days since started: $daysSinceStarted | - |Currently, the user's screen time is $screenTimeTotal minutes. This is a breakdown of the total screen time: - |- "Facebook": 137 minutes - |- "Instagram": 121.71 minutes - |- "Call of Duty: Mobile": 61.14 - |- "Chrome": 40.57 minutes - |- "YouTube": 20 minutes - """.trimMargin() + |The user's screen time for today is $screenTimeTotal. This is a breakdown of the total screen time: + """.trimMargin() + + Log.d("ChatManager", "There are ${appUsageBreakdown.size} apps detected.") + for (app in appUsageBreakdown) { + prompt += "\n- \"${app.appName}\": ${formatTime(app.usageTime)}" + } + + Log.d("ChatManager", "generateInitialPrompt: $prompt") + return prompt } } diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/ScreenTime.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/ScreenTime.kt index 2b364db..8ce689f 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/ScreenTime.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/ScreenTime.kt @@ -16,7 +16,7 @@ fun getDaysActive(context: Context): Long { val firstInstallTime = context.packageManager.getPackageInfo(context.packageName, 0).firstInstallTime val currentTime = System.currentTimeMillis() val diff = currentTime - firstInstallTime - return TimeUnit.MILLISECONDS.toDays(diff) + 1 + return TimeUnit.MILLISECONDS.toDays(diff) } /** diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/AIAssistant.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/AIAssistant.kt index 87dce91..9317a5d 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/AIAssistant.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/AIAssistant.kt @@ -1,5 +1,6 @@ package com.getreconnected.reconnected.ui.menus +import android.util.Log import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -36,13 +37,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.getreconnected.reconnected.R +import com.getreconnected.reconnected.core.AppUsageRepository import com.getreconnected.reconnected.core.Chatbot -import com.getreconnected.reconnected.core.auth.GoogleAuth import com.getreconnected.reconnected.core.dataManager.ChatManager import com.getreconnected.reconnected.core.models.entities.Chat import com.google.firebase.Firebase @@ -58,19 +60,42 @@ import kotlinx.coroutines.launch @Composable @Suppress("ktlint:standard:function-naming") fun AIAssistant(modifier: Modifier = Modifier) { + val context = LocalContext.current var inputText by remember { mutableStateOf("") } var chatHistory by remember { mutableStateOf(listOf(Chat(Chatbot.INITIAL_RESPONSE, null, false))) } + var chat by remember { mutableStateOf(null) } + var isChatInitializing by remember { mutableStateOf(true) } var isLoading by remember { mutableStateOf(false) } var isValidInput by remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() val listState = rememberLazyListState() - val googleAuth = GoogleAuth() - val chat = - ChatManager.startChat( - googleAuth.currentUser?.displayName, - ) + val currentUser = Firebase.auth.currentUser + + LaunchedEffect(Unit) { + isChatInitializing = true + try { + val apps = AppUsageRepository(context = context).getDailyUsageStats() + Log.d("AIAssistant", "App usage data: $apps") + chat = + ChatManager.startChat( + name = currentUser?.displayName, + appUsageBreakdown = apps, + context = context, + ) + } catch (e: Exception) { + val errorMessage = + Chat( + prompt = "Sorry, I failed to initialize: ${e.message}", + bitmap = null, + isFromUser = false, + ) + chatHistory = chatHistory + errorMessage + } finally { + isChatInitializing = false + } + } // Auto-scroll to bottom when new messages are added LaunchedEffect(chatHistory.size) { @@ -80,7 +105,7 @@ fun AIAssistant(modifier: Modifier = Modifier) { } val sendMessage = { - if (inputText.isNotBlank() && !isLoading) { + if (inputText.isNotBlank() && !isLoading && chat != null && !isChatInitializing) { val userMessage = Chat( prompt = inputText, @@ -95,7 +120,7 @@ fun AIAssistant(modifier: Modifier = Modifier) { coroutineScope.launch { try { - val aiResponse = chat.sendMessage(currentInput) + val aiResponse = chat!!.sendMessage(currentInput) // Convert the AI response to a Chat object val processedAiResponse = Chat( @@ -139,7 +164,7 @@ fun AIAssistant(modifier: Modifier = Modifier) { .weight(1f) .height(100.dp), placeholder = { Text("Type a message...") }, - enabled = !isLoading, + enabled = !isLoading && !isChatInitializing, isError = isValidInput, supportingText = { Text("${inputText.length} / ${Chatbot.CHAT_MAX_LENGTH}") }, ) @@ -147,9 +172,11 @@ fun AIAssistant(modifier: Modifier = Modifier) { Button( onClick = sendMessage, modifier = Modifier.height(50.dp), - enabled = inputText.isNotBlank() && !isLoading && inputText.length <= Chatbot.CHAT_MAX_LENGTH, + enabled = + inputText.isNotBlank() && !isLoading && !isChatInitializing && + inputText.length <= Chatbot.CHAT_MAX_LENGTH, ) { - if (isLoading) { + if (isLoading || isChatInitializing) { CircularProgressIndicator( modifier = Modifier.size(16.dp), color = Color.White, @@ -178,10 +205,16 @@ fun AIAssistant(modifier: Modifier = Modifier) { .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - ChatroomScreen( - chatHistory = chatHistory, - listState = listState, - ) + if (isChatInitializing) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + ChatroomScreen( + chatHistory = chatHistory, + listState = listState, + ) + } } } } From 9290ab73427106d923f8b25fab52ceff5e624945 Mon Sep 17 00:00:00 2001 From: Chris1320 Date: Tue, 28 Oct 2025 12:53:53 +0800 Subject: [PATCH 12/42] fix(android): fix wording on days active Signed-off-by: Chris1320 --- .../reconnected/ui/menus/Dashboard.kt | 92 ++++++------------- 1 file changed, 27 insertions(+), 65 deletions(-) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt index 0a01674..62fd8ca 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt @@ -96,6 +96,7 @@ fun Dashboard( ) { val context = LocalContext.current val daysActive = getDaysActive(context) + var daysActiveWord: String = "days" val hasPermission = hasUsageStatsPermission(context) val scrollState = rememberScrollState() val gradientStart = LocalReconnectEDColors.current.gradientStart @@ -128,6 +129,7 @@ fun Dashboard( } } + if (daysActive == 1L) daysActiveWord = "day" if (!hasPermission) { Toast .makeText( @@ -177,18 +179,12 @@ fun Dashboard( containerColor = MaterialTheme.colorScheme.surface, ), modifier = - Modifier - .fillMaxWidth() - .height(125.dp) - .clickable { - setSelectedQuote(QuotesManager.getQuotes(context).random()) - }, + Modifier.fillMaxWidth().height(125.dp).clickable { + setSelectedQuote(QuotesManager.getQuotes(context).random()) + }, ) { Column( - modifier = - Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -218,10 +214,7 @@ fun Dashboard( } Row( - modifier = - Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min), + modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { StatCard( @@ -230,28 +223,20 @@ fun Dashboard( icon = painterResource(R.drawable.daily_screen_time), color = Color(0xFF008F46), // dark green modifier = - Modifier - .weight(1f) - .fillMaxWidth() - .fillMaxHeight() - .clickable { - if (!hasPermission) { - context.startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) - } else { - navigateTo(Menus.ScreenTimeTracker.name) - } - }, + Modifier.weight(1f).fillMaxWidth().fillMaxHeight().clickable { + if (!hasPermission) { + context.startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) + } else { + navigateTo(Menus.ScreenTimeTracker.name) + } + }, ) StatCard( title = "Days Active", - value = "$daysActive days", + value = "$daysActive $daysActiveWord", icon = painterResource(R.drawable.days_active), color = Color(0xFF0453AE), // dark green - modifier = - Modifier - .weight(1f) - .fillMaxWidth() - .fillMaxHeight(), + modifier = Modifier.weight(1f).fillMaxWidth().fillMaxHeight(), ) } @@ -285,10 +270,7 @@ fun Dashboard( } } Row( - modifier = - Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min), + modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { ElevatedCard( @@ -298,18 +280,12 @@ fun Dashboard( containerColor = MaterialTheme.colorScheme.surface, ), modifier = - Modifier - .weight(1f) - .fillMaxHeight() - .clickable { - navigateTo(Menus.AIAssistant.name) - }, + Modifier.weight(1f).fillMaxHeight().clickable { + navigateTo(Menus.AIAssistant.name) + }, ) { Column( - modifier = - Modifier - .fillMaxSize() - .padding(horizontal = 16.dp, vertical = 16.dp), + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 16.dp), horizontalAlignment = Alignment.Start, ) { Text( @@ -329,11 +305,7 @@ fun Dashboard( Image( painter = painterResource(id = R.drawable.gemini_logo), contentDescription = "Gemini AI Logo", - modifier = - Modifier - .height(128.dp) - .width(128.dp) - .padding(16.dp), + modifier = Modifier.height(128.dp).width(128.dp).padding(16.dp), ) } Text( @@ -355,18 +327,12 @@ fun Dashboard( containerColor = MaterialTheme.colorScheme.surface, ), modifier = - Modifier - .weight(1f) - .fillMaxHeight() - .clickable { - navigateTo(Menus.ScreenTimeLimit.name) - }, + Modifier.weight(1f).fillMaxHeight().clickable { + navigateTo(Menus.ScreenTimeLimit.name) + }, ) { Column( - modifier = - Modifier - .fillMaxSize() - .padding(horizontal = 16.dp, vertical = 16.dp), + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 16.dp), horizontalAlignment = Alignment.Start, ) { Text( @@ -386,11 +352,7 @@ fun Dashboard( Image( painter = painterResource(id = R.drawable.screen_time_limit_green), contentDescription = "Limit App Usage", - modifier = - Modifier - .height(128.dp) - .width(128.dp) - .padding(16.dp), + modifier = Modifier.height(128.dp).width(128.dp).padding(16.dp), ) } Text( From 703c31210460c64ebeaf5128ac715a57f0d86e31 Mon Sep 17 00:00:00 2001 From: Chris1320 Date: Tue, 28 Oct 2025 21:16:13 +0800 Subject: [PATCH 13/42] feat: do not show seconds in screen time today card Signed-off-by: Chris1320 --- .../reconnected/core/util/TimeManagement.kt | 17 +++++++++++++---- .../ui/composables/elements/StatCard.kt | 2 +- .../reconnected/ui/menus/Dashboard.kt | 2 +- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/TimeManagement.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/TimeManagement.kt index 5ad4bea..3f88e3d 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/TimeManagement.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/TimeManagement.kt @@ -6,13 +6,22 @@ package com.getreconnected.reconnected.core.util * @param t The time in milliseconds to format. * @return A string representing the formatted time in the format "h m s". */ -fun formatTime(t: Long): String { +fun formatTime( + t: Long, + strip: Boolean = false, +): String { val seconds = (t / 1000) % 60 val minutes = (t / (1000 * 60)) % 60 val hours = (t / (1000 * 60 * 60)) % 24 + val s: String = + if (strip) { + "" + } else { + "${seconds}s" + } return when { - hours > 0 -> "${hours}h ${minutes}m ${seconds}s" - minutes > 0 -> "${minutes}m ${seconds}s" - else -> "${seconds}s" + hours > 0 -> "${hours}h ${minutes}m $s" + minutes > 0 -> "${minutes}m $s" + else -> s } } diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/elements/StatCard.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/elements/StatCard.kt index 52a0539..f360309 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/elements/StatCard.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/elements/StatCard.kt @@ -66,7 +66,7 @@ fun StatCard( TextStyle( fontFamily = interDisplayFamily, fontWeight = FontWeight.Bold, - fontSize = 24.sp, + fontSize = 20.sp, ), color = color, ) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt index 62fd8ca..6511ecd 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt @@ -145,7 +145,7 @@ fun Dashboard( val screenTimeValue = when { !hasPermission -> "Tap to permit" - else -> formatTime(screenTimeMillis) + else -> formatTime(screenTimeMillis, true) } fun navigateTo(route: String) { From fe14093b4c9ac7c6ff7c26941c38a7d96211b18e Mon Sep 17 00:00:00 2001 From: Chris1320 Date: Wed, 29 Oct 2025 12:07:02 +0800 Subject: [PATCH 14/42] build(android): build multiple APKs for different ABIs Signed-off-by: Chris1320 --- ReconnectED-Android/app/build.gradle.kts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ReconnectED-Android/app/build.gradle.kts b/ReconnectED-Android/app/build.gradle.kts index b16c4cc..ed58014 100644 --- a/ReconnectED-Android/app/build.gradle.kts +++ b/ReconnectED-Android/app/build.gradle.kts @@ -39,6 +39,15 @@ android { viewBinding = true buildConfig = true } + splits { + abi { + // https://developer.android.com/build/configure-apk-splits#kts + isEnable = true // enable multiple APKs per ABI + reset() // reset included ABIs + include("armeabi-v7a", "arm64-v8a", "x86_64") + isUniversalApk = true + } + } } dependencies { From 6ea1b74efd3f8ed53ee041c813291daee952f0e2 Mon Sep 17 00:00:00 2001 From: Chris1320 Date: Thu, 30 Oct 2025 05:35:16 +0800 Subject: [PATCH 15/42] feat(android): show actual data in screen time limiter and tracker Signed-off-by: Chris1320 --- .../viewModels/ScreenTimeLimitViewModel.kt | 36 +++++ .../reconnected/ui/menus/Calendar.kt | 10 +- .../reconnected/ui/menus/ScreenTimeLimit.kt | 150 +++++++++--------- .../reconnected/ui/screens/MainScreen.kt | 8 +- 4 files changed, 128 insertions(+), 76 deletions(-) create mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeLimitViewModel.kt diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeLimitViewModel.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeLimitViewModel.kt new file mode 100644 index 0000000..cad8083 --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeLimitViewModel.kt @@ -0,0 +1,36 @@ +package com.getreconnected.reconnected.core.viewModels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.getreconnected.reconnected.core.AppUsageRepository +import com.getreconnected.reconnected.core.models.entities.AppUsageInfo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +/** + * ViewModel class for tracking and managing app screen time usage. + * + * This ViewModel interacts with the AppUsageRepository to fetch and provide + * the app usage statistics in the form of a StateFlow. It is responsible + * for managing the lifecycle-conscious asynchronous data updates to the + * associated UI components. + * + * @constructor Creates an instance of ScreenTimeTrackerViewModel. + * @param application The Application context required for the AppUsageRepository. + */ +class ScreenTimeLimitViewModel( + application: Application, +) : AndroidViewModel(application) { + private val appUsageRepository = AppUsageRepository(application) + + private val _appUsageStats = MutableStateFlow>(emptyList()) + val appUsageStats: StateFlow> = _appUsageStats + + fun loadUsageStats() { + viewModelScope.launch { + _appUsageStats.value = appUsageRepository.getDailyUsageStats() + } + } +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Calendar.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Calendar.kt index ea605bc..7aa4352 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Calendar.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Calendar.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -36,6 +37,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.getreconnected.reconnected.core.viewModels.ScreenTimeTrackerViewModel import com.getreconnected.reconnected.ui.theme.interDisplayFamily import com.kizitonwose.calendar.compose.HorizontalCalendar import com.kizitonwose.calendar.compose.rememberCalendarState @@ -56,7 +58,11 @@ import java.time.format.TextStyle as JavaTextStyle */ @Composable @Suppress("ktlint:standard:function-naming") -fun Calendar(modifier: Modifier = Modifier) { +fun Calendar( + viewModel: ScreenTimeTrackerViewModel, + modifier: Modifier = Modifier, +) { + val appUsageStats by viewModel.appUsageStats.collectAsState() Column( modifier = modifier @@ -83,7 +89,7 @@ fun Calendar(modifier: Modifier = Modifier) { ) { ScreenTimeCalendar() } - AppUsageContainer() + AppUsageContainer(appList = appUsageStats) } } diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/ScreenTimeLimit.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/ScreenTimeLimit.kt index 3b780fc..a5fd52e 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/ScreenTimeLimit.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/ScreenTimeLimit.kt @@ -1,6 +1,7 @@ package com.getreconnected.reconnected.ui.menus import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider @@ -35,8 +36,10 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -54,8 +57,13 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.getreconnected.reconnected.R +import com.getreconnected.reconnected.core.models.entities.AppUsageInfo +import com.getreconnected.reconnected.core.util.formatTime import com.getreconnected.reconnected.core.viewModels.AppUsageViewModel +import com.getreconnected.reconnected.core.viewModels.ScreenTimeLimitViewModel +import com.getreconnected.reconnected.core.viewModels.ScreenTimeTrackerViewModel import com.getreconnected.reconnected.ui.theme.interDisplayFamily +import com.google.accompanist.drawablepainter.rememberDrawablePainter import kotlin.math.roundToInt /** @@ -67,9 +75,15 @@ import kotlin.math.roundToInt */ @Composable @Suppress("ktlint:standard:function-naming") -fun ScreenTimeLimit(modifier: Modifier = Modifier) { - // The Scaffold and TopAppBar have been removed. - // This Column is now the main container for your screen's content. +fun ScreenTimeLimit( + viewModel: ScreenTimeLimitViewModel, + modifier: Modifier = Modifier, +) { + val appUsageStats by viewModel.appUsageStats.collectAsState() + LaunchedEffect(Unit) { + viewModel.loadUsageStats() + } + Column( modifier = modifier @@ -86,7 +100,6 @@ fun ScreenTimeLimit(modifier: Modifier = Modifier) { ).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - // "Limit your application usage" Text Text( modifier = Modifier.padding(top = 16.dp), text = "Limit your application usage", @@ -98,7 +111,7 @@ fun ScreenTimeLimit(modifier: Modifier = Modifier) { color = Color.Black, ), ) - AppUsageContainer() + AppUsageContainer(appList = appUsageStats) } } @@ -107,23 +120,7 @@ fun ScreenTimeLimit(modifier: Modifier = Modifier) { */ @Composable @Suppress("ktlint:standard:function-naming") -fun AppUsageContainer() { - val appList = - listOf( - AppUsageViewModel( - name = "YouTube", - usageTime = "2h 39m", - icon = Icons.Filled.PlayArrow, - iconBackgroundColor = Color(0xFFFF0000), - ), - AppUsageViewModel( - name = "Facebook", - usageTime = "2h 10m", - icon = Icons.Filled.ThumbUp, - iconBackgroundColor = Color(0xFF1877F2), - ), - ) - // The Card containing the list +fun AppUsageContainer(appList: List) { Column { Card( modifier = Modifier.fillMaxWidth().weight(1f), @@ -153,7 +150,7 @@ fun AppUsageContainer() { */ @Composable @Suppress("ktlint:standard:function-naming") -fun AppUsageLimitItem(appInfo: AppUsageViewModel) { +fun AppUsageLimitItem(appInfo: AppUsageInfo) { var showPicker by remember { mutableStateOf(false) } Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), @@ -161,19 +158,18 @@ fun AppUsageLimitItem(appInfo: AppUsageViewModel) { horizontalArrangement = Arrangement.spacedBy(16.dp), ) { Box( - modifier = Modifier.size(40.dp).clip(RoundedCornerShape(8.dp)).background(appInfo.iconBackgroundColor), + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center, ) { - Icon( - imageVector = appInfo.icon, - contentDescription = "${appInfo.name} logo", - tint = Color.White, - modifier = Modifier.size(24.dp), + Image( + painter = rememberDrawablePainter(drawable = appInfo.appIcon), + contentDescription = "${appInfo.appName} icon", + modifier = Modifier.size(40.dp), ) } Column(modifier = Modifier.weight(1f)) { Text( - text = appInfo.name, + text = appInfo.appName, style = TextStyle( fontFamily = interDisplayFamily, @@ -182,7 +178,7 @@ fun AppUsageLimitItem(appInfo: AppUsageViewModel) { ), ) Text( - text = appInfo.usageTime, + text = formatTime(appInfo.usageTime), style = TextStyle( fontFamily = interDisplayFamily, @@ -205,22 +201,21 @@ fun AppUsageLimitItem(appInfo: AppUsageViewModel) { TimeLimitPickerDialog( onDismiss = { showPicker = false }, onConfirm = { hours, minutes -> - println("Limit set for ${appInfo.name}: ${hours}h ${minutes}m") + println("Limit set for ${appInfo.appName}: ${hours}h ${minutes}m") // TODO: Save to ViewModel or database - } + }, ) } - } } @Composable fun TimeLimitPickerDialog( onDismiss: () -> Unit, - onConfirm: (hours: Int, minutes: Int) -> Unit + onConfirm: (hours: Int, minutes: Int) -> Unit, ) { - var selectedHours by remember { mutableStateOf(0) } - var selectedMinutes by remember { mutableStateOf(0) } // TODO: Minutes should start by zero + var selectedHours by remember { mutableIntStateOf(0) } + var selectedMinutes by remember { mutableIntStateOf(0) } // TODO: Minutes should start by zero AlertDialog( onDismissRequest = onDismiss, @@ -229,20 +224,20 @@ fun TimeLimitPickerDialog( Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { LoopingNumberPicker( range = 0..99, value = selectedHours, onValueChange = { selectedHours = it }, - label = "hrs" + label = "hrs", ) Spacer(modifier = Modifier.width(32.dp)) LoopingNumberPicker( range = 0..59, value = selectedMinutes, onValueChange = { selectedMinutes = it }, - label = "min" + label = "min", ) } }, @@ -251,16 +246,22 @@ fun TimeLimitPickerDialog( onConfirm(selectedHours, selectedMinutes) onDismiss() }) { - Text(style = TextStyle( - fontFamily = interDisplayFamily), text = "Set Limit") + Text( + style = + TextStyle(fontFamily = interDisplayFamily), + text = "Set Limit", + ) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(style = TextStyle( - fontFamily = interDisplayFamily), text = "Cancel") + Text( + style = + TextStyle(fontFamily = interDisplayFamily), + text = "Cancel", + ) } - } + }, ) } @@ -270,7 +271,7 @@ fun LoopingNumberPicker( range: IntRange, value: Int, onValueChange: (Int) -> Unit, - label: String + label: String, ) { val visibleCount = 5 val totalCount = range.count() @@ -300,52 +301,57 @@ fun LoopingNumberPicker( } Box( - modifier = Modifier - .height(180.dp) // slightly taller to fit the label - .width(70.dp), - contentAlignment = Alignment.Center + modifier = + Modifier + .height(180.dp) // slightly taller to fit the label + .width(70.dp), + contentAlignment = Alignment.Center, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, ) { // Scroll picker area Box( - modifier = Modifier - .height(140.dp) // height for the scroll zone - .fillMaxWidth(), - contentAlignment = Alignment.Center + modifier = + Modifier + .height(140.dp) // height for the scroll zone + .fillMaxWidth(), + contentAlignment = Alignment.Center, ) { // Highlight box Box( - modifier = Modifier - .align(Alignment.Center) - .height(itemHeight) - .fillMaxWidth() - .background(Color(0x22000000), RoundedCornerShape(8.dp)) + modifier = + Modifier + .align(Alignment.Center) + .height(itemHeight) + .fillMaxWidth() + .background(Color(0x22000000), RoundedCornerShape(8.dp)), ) LazyColumn( state = listState, flingBehavior = flingBehavior, horizontalAlignment = Alignment.CenterHorizontally, - contentPadding = PaddingValues(vertical = 50.dp) + contentPadding = PaddingValues(vertical = 50.dp), ) { items(extendedList.size) { index -> val number = extendedList[index] val isSelected = index == centerItemIndex Text( text = number.toString().padStart(2, '0'), - style = TextStyle( - fontSize = if (isSelected) 24.sp else 18.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - color = if (isSelected) Color.Black else Color.Gray, - textAlign = TextAlign.Center - ), - modifier = Modifier - .height(itemHeight) - .fillMaxWidth() - .padding(vertical = 2.dp), + style = + TextStyle( + fontSize = if (isSelected) 24.sp else 18.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) Color.Black else Color.Gray, + textAlign = TextAlign.Center, + ), + modifier = + Modifier + .height(itemHeight) + .fillMaxWidth() + .padding(vertical = 2.dp), ) } } @@ -356,7 +362,7 @@ fun LoopingNumberPicker( text = label, color = Color.Gray, fontSize = 14.sp, - modifier = Modifier.padding(top = 6.dp) + modifier = Modifier.padding(top = 6.dp), ) } } diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/screens/MainScreen.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/screens/MainScreen.kt index c905555..0574fc5 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/screens/MainScreen.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/screens/MainScreen.kt @@ -21,6 +21,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.getreconnected.reconnected.core.models.Menus import com.getreconnected.reconnected.core.models.getMenuRoute +import com.getreconnected.reconnected.core.viewModels.ScreenTimeLimitViewModel import com.getreconnected.reconnected.core.viewModels.ScreenTimeTrackerViewModel import com.getreconnected.reconnected.core.viewModels.UIRouteViewModel import com.getreconnected.reconnected.ui.composables.NavDrawer @@ -50,6 +51,7 @@ fun MainScreen( val context = LocalContext.current val navController = rememberNavController() val screenTimeTrackerViewModel: ScreenTimeTrackerViewModel = viewModel() + val screenTimeLimitViewModel: ScreenTimeLimitViewModel = viewModel() val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) // Initialize the drawer state val backStackEntry by navController.currentBackStackEntryAsState() // Get the current back stack entry @@ -85,9 +87,11 @@ fun MainScreen( ScreenTimeTracker(modifier = Modifier.padding(padding), viewModel = screenTimeTrackerViewModel) } composable(Menus.ScreenTimeLimit.name) { - ScreenTimeLimit(Modifier.padding(padding)) + ScreenTimeLimit(viewModel = screenTimeLimitViewModel, Modifier.padding(padding)) } - composable(Menus.Calendar.name) { Calendar(Modifier.padding(padding)) } + composable( + Menus.Calendar.name, + ) { Calendar(viewModel = screenTimeTrackerViewModel, Modifier.padding(padding)) } composable(Menus.AIAssistant.name) { AIAssistant(Modifier.padding(padding)) } } } From 3bea9fd41938149cf30d8d4fd21b92842a043d0d Mon Sep 17 00:00:00 2001 From: Chris1320 Date: Thu, 30 Oct 2025 08:58:37 +0800 Subject: [PATCH 16/42] feat(android): debug logs Signed-off-by: Chris1320 --- .../com/getreconnected/reconnected/ui/menus/Dashboard.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt index 6511ecd..392fc99 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt @@ -2,6 +2,7 @@ package com.getreconnected.reconnected.ui.menus import android.content.Intent import android.provider.Settings +import android.util.Log import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -148,6 +149,11 @@ fun Dashboard( else -> formatTime(screenTimeMillis, true) } + Log.d("Dashboard", "Screen time: $screenTimeMillis") + Log.d("Dashboard", "Days active: $daysActive") + Log.d("Dashboard", "Has permission: $hasPermission") + Log.d("Dashboard", "Screen time value: $screenTimeValue") + fun navigateTo(route: String) { navController.navigate(route) { popUpTo(Menus.Dashboard.name) { inclusive = false } From a469f3640860af6cdbde8a239adf82604bcb64d1 Mon Sep 17 00:00:00 2001 From: Chris1320 Date: Thu, 30 Oct 2025 09:02:34 +0800 Subject: [PATCH 17/42] fix(android): still show seconds when less than a minute Signed-off-by: Chris1320 --- .../reconnected/core/util/TimeManagement.kt | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/TimeManagement.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/TimeManagement.kt index 3f88e3d..cca45e3 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/TimeManagement.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/TimeManagement.kt @@ -13,15 +13,20 @@ fun formatTime( val seconds = (t / 1000) % 60 val minutes = (t / (1000 * 60)) % 60 val hours = (t / (1000 * 60 * 60)) % 24 - val s: String = - if (strip) { - "" - } else { - "${seconds}s" - } + val s = "${seconds}s" return when { - hours > 0 -> "${hours}h ${minutes}m $s" - minutes > 0 -> "${minutes}m $s" + hours > 0 -> + if (strip) { + "${hours}h ${minutes}m" + } else { + "${hours}h ${minutes}m $s" + } + minutes > 0 -> + if (strip) { + "${minutes}m" + } else { + "${minutes}m $s" + } else -> s } } From 8fbaed58a964ad2a7226d20cc4c0b00bb30376d4 Mon Sep 17 00:00:00 2001 From: Chris1320 Date: Thu, 30 Oct 2025 09:46:09 +0800 Subject: [PATCH 18/42] feat(android): use actual app usage for dashboard graph Signed-off-by: Chris1320 --- .../reconnected/core/AppUsageRepository.kt | 62 +++++++++++++------ .../viewModels/ScreenTimeTrackerViewModel.kt | 10 +++ .../reconnected/ui/menus/Dashboard.kt | 27 ++++++-- 3 files changed, 75 insertions(+), 24 deletions(-) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/AppUsageRepository.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/AppUsageRepository.kt index d592a6f..f325232 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/AppUsageRepository.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/AppUsageRepository.kt @@ -1,14 +1,15 @@ package com.getreconnected.reconnected.core -import android.app.usage.UsageStats import android.app.usage.UsageStatsManager import android.content.Context import android.content.pm.PackageManager +import android.util.Log import com.getreconnected.reconnected.core.models.entities.AppUsageInfo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat import java.util.Calendar -import java.util.concurrent.TimeUnit +import java.util.Locale /** * Repository for fetching application usage statistics from the device. @@ -67,24 +68,49 @@ class AppUsageRepository( /** * Retrieves the weekly application usage statistics. Uses the `UsageStatsManager` to - * collect usage data over the last 7 days and returns a list of `UsageStats` objects - * containing information about app usage. + * collect usage data over the last 7 days and returns a map of day to usage time in minutes. * - * @param context The context used to access system services such as `UsageStatsManager`. - * @return A list of `UsageStats`, where each object represents the usage statistics - * for an app during the past week. Returns an empty list if no data is available. + * @return A map where the key is the day of the week (e.g., "Mon") and the value is the + * total screen time in minutes. */ - fun getWeeklyUsageStats(context: Context): List { - val usageStatsManager = - context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager + suspend fun getWeeklyUsageStats(): Map = + withContext(Dispatchers.IO) { + val usageStatsManager = context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager + val weeklyUsage = linkedMapOf() + val cal = Calendar.getInstance() + val dayFormat = SimpleDateFormat("EEE", Locale.getDefault()) + + for (i in 6 downTo 0) { + cal.time = java.util.Date() + cal.add(Calendar.DAY_OF_YEAR, -i) + val dayKey = dayFormat.format(cal.time) + + cal.set(Calendar.HOUR_OF_DAY, 0) + cal.set(Calendar.MINUTE, 0) + cal.set(Calendar.SECOND, 0) + cal.set(Calendar.MILLISECOND, 0) + val startTime = cal.timeInMillis - val endTime = System.currentTimeMillis() - val startTime = endTime - TimeUnit.DAYS.toMillis(7) // last 7 days + cal.add(Calendar.DAY_OF_YEAR, 1) + cal.add(Calendar.MILLISECOND, -1) + val endTime = cal.timeInMillis - return usageStatsManager.queryUsageStats( - UsageStatsManager.INTERVAL_DAILY, - startTime, - endTime, - ) ?: emptyList() - } + val usageStats = + usageStatsManager.queryUsageStats( + UsageStatsManager.INTERVAL_DAILY, + startTime, + endTime, + ) + + val totalTime = usageStats.sumOf { it.totalTimeInForeground } + + Log.d("WeeklyUsageStats", "Start Time: ${java.util.Date(startTime)}") + Log.d("WeeklyUsageStats", "End Time: ${java.util.Date(endTime)}") + Log.d("WeeklyUsageStats", "Day: $dayKey") + Log.d("WeeklyUsageStats", "Total Usage: $totalTime") + + weeklyUsage[dayKey] = totalTime / (1000 * 60) // Convert to minutes + } + weeklyUsage + } } diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeTrackerViewModel.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeTrackerViewModel.kt index 91ebb95..3aea77b 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeTrackerViewModel.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeTrackerViewModel.kt @@ -7,6 +7,7 @@ import com.getreconnected.reconnected.core.AppUsageRepository import com.getreconnected.reconnected.core.models.entities.AppUsageInfo import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch /** @@ -28,9 +29,18 @@ class ScreenTimeTrackerViewModel( private val _appUsageStats = MutableStateFlow>(emptyList()) val appUsageStats: StateFlow> = _appUsageStats + private val _weeklyUsageStats = MutableStateFlow>(emptyMap()) + val weeklyUsageStats: StateFlow> = _weeklyUsageStats.asStateFlow() + fun loadUsageStats() { viewModelScope.launch { _appUsageStats.value = appUsageRepository.getDailyUsageStats() } } + + fun loadWeeklyUsageStats() { + viewModelScope.launch { + _weeklyUsageStats.value = appUsageRepository.getWeeklyUsageStats() + } + } } diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt index 392fc99..4052f8d 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Dashboard.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState @@ -45,6 +46,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import com.getreconnected.reconnected.R import com.getreconnected.reconnected.activities.MainActivity @@ -56,6 +58,7 @@ import com.getreconnected.reconnected.core.util.formatTime import com.getreconnected.reconnected.core.util.getDaysActive import com.getreconnected.reconnected.core.util.getScreenTimeInMillis import com.getreconnected.reconnected.core.util.hasUsageStatsPermission +import com.getreconnected.reconnected.core.viewModels.ScreenTimeTrackerViewModel import com.getreconnected.reconnected.core.viewModels.UIRouteViewModel import com.getreconnected.reconnected.ui.composables.elements.StatCard import com.getreconnected.reconnected.ui.theme.LocalReconnectEDColors @@ -97,7 +100,7 @@ fun Dashboard( ) { val context = LocalContext.current val daysActive = getDaysActive(context) - var daysActiveWord: String = "days" + var daysActiveWord = "days" val hasPermission = hasUsageStatsPermission(context) val scrollState = rememberScrollState() val gradientStart = LocalReconnectEDColors.current.gradientStart @@ -414,8 +417,6 @@ private val BottomAxisValueFormatter = context.model.extraStore[BottomAxisLabelKey][x.toInt()] } -private val data = mapOf("Sun" to 1, "Mon" to 2, "Tue" to 6, "Wed" to 4, "Thu" to 9, "Fri" to 5, "Sat" to 3) - /** * A composable that renders a bar chart representing the average screen time for a week. * @@ -473,11 +474,25 @@ private fun WeeklyAvgScreenTimeChart( @Composable @Suppress("ktlint:standard:function-naming") fun WeeklyAvgScreenTimeChart(modifier: Modifier = Modifier) { + val screenTimeViewModel: ScreenTimeTrackerViewModel = viewModel() + val weeklyUsage by screenTimeViewModel.weeklyUsageStats.collectAsState() val modelProducer = remember { CartesianChartModelProducer() } + LaunchedEffect(Unit) { - modelProducer.runTransaction { - columnSeries { series(data.values) } - extras { it[BottomAxisLabelKey] = data.keys.toList() } + screenTimeViewModel.loadWeeklyUsageStats() + } + + LaunchedEffect(key1 = weeklyUsage) { + if (weeklyUsage.isNotEmpty()) { + Log.d("WeeklyAvgScreenTimeChart", "Weekly usage stats: $weeklyUsage") + for ((day, time) in weeklyUsage) { + Log.d("WeeklyAvgScreenTimeChart", "Day: $day, Time: $time") + } + + modelProducer.runTransaction { + columnSeries { series(weeklyUsage.values) } + extras { it[BottomAxisLabelKey] = weeklyUsage.keys.toList() } + } } } WeeklyAvgScreenTimeChart(modelProducer, modifier) From 9b231f98e4f3766f72518436e37d1ee8d8ea491d Mon Sep 17 00:00:00 2001 From: Chris1320 Date: Thu, 30 Oct 2025 11:06:44 +0800 Subject: [PATCH 19/42] feat(android): add functionality to retrieve app usage stats for a specific date and update UI components accordingly Signed-off-by: Chris1320 --- .../reconnected/core/AppUsageRepository.kt | 61 +++++++++++++- .../viewModels/ScreenTimeTrackerViewModel.kt | 30 +++++++ .../reconnected/ui/menus/Calendar.kt | 84 ++++++++++++++----- .../reconnected/ui/menus/ScreenTimeLimit.kt | 23 ++++- 4 files changed, 173 insertions(+), 25 deletions(-) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/AppUsageRepository.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/AppUsageRepository.kt index f325232..9143227 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/AppUsageRepository.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/AppUsageRepository.kt @@ -109,8 +109,61 @@ class AppUsageRepository( Log.d("WeeklyUsageStats", "Day: $dayKey") Log.d("WeeklyUsageStats", "Total Usage: $totalTime") - weeklyUsage[dayKey] = totalTime / (1000 * 60) // Convert to minutes - } - weeklyUsage + weeklyUsage[dayKey] = totalTime / (1000 * 60) // Convert to minutes } -} + weeklyUsage + } + + /** + * Retrieves usage statistics for a specific date. + * + * @param date The date to retrieve usage statistics for (as Calendar instance). + * @return A list of [AppUsageInfo] objects representing the usage statistics for that date. + */ + suspend fun getUsageStatsForDate(date: Calendar): List = + withContext(Dispatchers.IO) { + val usageStatsManager = context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager + val packageManager = context.packageManager + + // Set to start of day + val startCal = date.clone() as Calendar + startCal.set(Calendar.HOUR_OF_DAY, 0) + startCal.set(Calendar.MINUTE, 0) + startCal.set(Calendar.SECOND, 0) + startCal.set(Calendar.MILLISECOND, 0) + val startTime = startCal.timeInMillis + + // Set to end of day + val endCal = date.clone() as Calendar + endCal.set(Calendar.HOUR_OF_DAY, 23) + endCal.set(Calendar.MINUTE, 59) + endCal.set(Calendar.SECOND, 59) + endCal.set(Calendar.MILLISECOND, 999) + val endTime = endCal.timeInMillis + + val usageStatsList = + usageStatsManager.queryUsageStats( + UsageStatsManager.INTERVAL_DAILY, + startTime, + endTime, + ) + + usageStatsList + .filter { it.totalTimeInForeground > 0 } + .mapNotNull { usageStats -> + try { + val applicationInfo = packageManager.getApplicationInfo(usageStats.packageName, 0) + val appName = packageManager.getApplicationLabel(applicationInfo).toString() + val appIcon = packageManager.getApplicationIcon(applicationInfo) + AppUsageInfo( + appName = appName, + packageName = usageStats.packageName, + usageTime = usageStats.totalTimeInForeground, + appIcon = appIcon, + ) + } catch (e: PackageManager.NameNotFoundException) { + null + } + }.sortedByDescending { it.usageTime } + } +} \ No newline at end of file diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeTrackerViewModel.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeTrackerViewModel.kt index 3aea77b..cfa8383 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeTrackerViewModel.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeTrackerViewModel.kt @@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import java.time.LocalDate +import java.util.Calendar /** * ViewModel class for tracking and managing app screen time usage. @@ -32,6 +34,9 @@ class ScreenTimeTrackerViewModel( private val _weeklyUsageStats = MutableStateFlow>(emptyMap()) val weeklyUsageStats: StateFlow> = _weeklyUsageStats.asStateFlow() + private val _selectedDate = MutableStateFlow(null) + val selectedDate: StateFlow = _selectedDate.asStateFlow() + fun loadUsageStats() { viewModelScope.launch { _appUsageStats.value = appUsageRepository.getDailyUsageStats() @@ -43,4 +48,29 @@ class ScreenTimeTrackerViewModel( _weeklyUsageStats.value = appUsageRepository.getWeeklyUsageStats() } } + + /** + * Loads usage statistics for a specific date. + * @param date The date to load usage statistics for. + */ + fun loadUsageStatsForDate(date: LocalDate) { + viewModelScope.launch { + _selectedDate.value = date + val calendar = + Calendar.getInstance().apply { + set(date.year, date.monthValue - 1, date.dayOfMonth) + } + _appUsageStats.value = appUsageRepository.getUsageStatsForDate(calendar) + } + } + + /** + * Clears the selected date and loads today's usage statistics. + */ + fun clearSelectedDate() { + viewModelScope.launch { + _selectedDate.value = null + loadUsageStats() + } + } } diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Calendar.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Calendar.kt index 7aa4352..ae9b3a8 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Calendar.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/Calendar.kt @@ -1,6 +1,7 @@ package com.getreconnected.reconnected.ui.menus 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 @@ -63,6 +64,8 @@ fun Calendar( modifier: Modifier = Modifier, ) { val appUsageStats by viewModel.appUsageStats.collectAsState() + val selectedDate by viewModel.selectedDate.collectAsState() + Column( modifier = modifier @@ -87,19 +90,37 @@ fun Calendar( ), modifier = Modifier.fillMaxWidth(), ) { - ScreenTimeCalendar() + ScreenTimeCalendar( + selectedDate = selectedDate, + onDateSelected = { date -> + if (selectedDate == date) { + viewModel.clearSelectedDate() + } else { + viewModel.loadUsageStatsForDate(date) + } + }, + ) } - AppUsageContainer(appList = appUsageStats) + AppUsageContainer( + appList = appUsageStats, + selectedDate = selectedDate, + ) } } /** * A composable that displays a screen time calendar with the ability to navigate between months * and view the days of the selected month. It also highlights today's date for better visibility. + * + * @param selectedDate The currently selected date, if any. + * @param onDateSelected Callback invoked when a date is clicked. */ @Composable @Suppress("ktlint:standard:function-naming") -fun ScreenTimeCalendar() { +fun ScreenTimeCalendar( + selectedDate: LocalDate? = null, + onDateSelected: (LocalDate) -> Unit = {}, +) { val currentMonth = remember { YearMonth.now() } var displayedMonth by remember { mutableStateOf(currentMonth) } @@ -189,7 +210,12 @@ fun ScreenTimeCalendar() { HorizontalCalendar( state = state, dayContent = { day -> - DayCell(day.date, today) + DayCell( + date = day.date, + today = today, + isSelected = selectedDate == day.date, + onClick = { onDateSelected(day.date) }, + ) }, ) } @@ -200,36 +226,54 @@ fun ScreenTimeCalendar() { * * @param date The specific date to be displayed in the cell. * @param today The current date, used to determine if the cell represents today. + * @param isSelected Whether this date is currently selected. + * @param onClick Callback invoked when the cell is clicked. */ @Composable @Suppress("ktlint:standard:function-naming") fun DayCell( date: LocalDate, today: LocalDate, + isSelected: Boolean = false, + onClick: () -> Unit = {}, ) { val isToday = date == today + Box( - modifier = Modifier.aspectRatio(1f).padding(2.dp), + modifier = + Modifier + .aspectRatio(1f) + .padding(2.dp) + .background( + color = + when { + isSelected -> Color(0xFF10B981) // Green background for selected date + isToday -> Color(0xFFE0F2FE) // Light blue background for today + else -> Color.Transparent + }, + shape = androidx.compose.foundation.shape.CircleShape, + ).clickable { onClick() }, contentAlignment = Alignment.Center, ) { Text( text = date.dayOfMonth.toString(), style = - if (isToday) { - TextStyle( - fontFamily = interDisplayFamily, - fontSize = 16.sp, - fontWeight = FontWeight.Companion.Normal, - color = Color(0xFF020202), - ) - } else { - TextStyle( - fontFamily = interDisplayFamily, - fontSize = 16.sp, - fontWeight = FontWeight.Companion.Normal, - color = Color(0xAA595959), - ) - }, + TextStyle( + fontFamily = interDisplayFamily, + fontSize = 16.sp, + fontWeight = + if (isSelected) { + FontWeight.Companion.Bold + } else { + FontWeight.Companion.Normal + }, + color = + when { + isSelected -> Color.White // White text for selected date + isToday -> Color(0xFF020202) // Black text for today + else -> Color(0xAA595959) // Gray text for other dates + }, + ), ) } } diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/ScreenTimeLimit.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/ScreenTimeLimit.kt index a5fd52e..3c1ebe6 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/ScreenTimeLimit.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/menus/ScreenTimeLimit.kt @@ -117,11 +117,32 @@ fun ScreenTimeLimit( /** * A composable function that represents a container displaying app usage information. + * @param appList List of AppUsageInfo objects to display. + * @param selectedDate The currently selected date, if any, to display in the header. */ @Composable @Suppress("ktlint:standard:function-naming") -fun AppUsageContainer(appList: List) { +fun AppUsageContainer( + appList: List, + selectedDate: java.time.LocalDate? = null, +) { Column { + // Display selected date header if a date is selected + if (selectedDate != null) { + val dateFormatter = java.time.format.DateTimeFormatter.ofPattern("MMMM dd, yyyy") + Text( + text = "Usage for ${selectedDate.format(dateFormatter)}", + style = + androidx.compose.ui.text.TextStyle( + fontFamily = com.getreconnected.reconnected.ui.theme.interDisplayFamily, + fontSize = 16.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, + color = androidx.compose.ui.graphics.Color(0xFF020202), + ), + modifier = Modifier.padding(bottom = 8.dp), + ) + } + Card( modifier = Modifier.fillMaxWidth().weight(1f), // Allow card to take remaining space From d6ce0bac8254f1e3abdc888b34bdb73fdac97f7b Mon Sep 17 00:00:00 2001 From: Chris1320 Date: Thu, 30 Oct 2025 11:22:57 +0800 Subject: [PATCH 20/42] feat(android): add logout confirmation dialog and update user status message in TopBar Signed-off-by: Chris1320 --- .../reconnected/ui/composables/NavDrawer.kt | 74 ++++++++++++++++++- .../ui/composables/elements/TopBar.kt | 24 +++--- 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/NavDrawer.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/NavDrawer.kt index 13afb45..14acecb 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/NavDrawer.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/NavDrawer.kt @@ -1,5 +1,6 @@ package com.getreconnected.reconnected.ui.composables +import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -15,7 +16,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ExitToApp import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.CardDefaults import androidx.compose.material3.DrawerState import androidx.compose.material3.ElevatedCard @@ -25,10 +29,14 @@ import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -37,6 +45,7 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -48,6 +57,7 @@ import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.getreconnected.reconnected.R +import com.getreconnected.reconnected.activities.MainActivity import com.getreconnected.reconnected.core.dataManager.UserManager import com.getreconnected.reconnected.core.models.Menus import com.getreconnected.reconnected.core.models.getMenuRoute @@ -76,6 +86,7 @@ fun NavDrawer( scope: CoroutineScope, modifier: Modifier = Modifier, ) { + val context = LocalContext.current val drawerItemShape = RectangleShape val currentBackStack by navController.currentBackStackEntryAsState() val currentDestination = currentBackStack?.destination?.route @@ -87,6 +98,7 @@ fun NavDrawer( color = MaterialTheme.colorScheme.onPrimary, ) val selectedGreen = LocalReconnectEDColors.current.selectedGreen + var showLogoutDialog by remember { mutableStateOf(false) } fun navigateAndClose(route: String) { navController.navigate(route) { @@ -264,10 +276,70 @@ fun NavDrawer( ) } } - Spacer(modifier = Modifier.height(24.dp)) } + + NavigationDrawerItem( + icon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.ExitToApp, + contentDescription = "Logout", + tint = MaterialTheme.colorScheme.onPrimary, + ) + }, + label = { Text("Logout", style = sidebarButtonText) }, + selected = false, + onClick = { showLogoutDialog = true }, + shape = drawerItemShape, + colors = + NavigationDrawerItemDefaults.colors( + selectedContainerColor = selectedGreen, + selectedIconColor = MaterialTheme.colorScheme.onPrimary, + selectedTextColor = MaterialTheme.colorScheme.onPrimary, + unselectedContainerColor = Color.Transparent, + unselectedIconColor = MaterialTheme.colorScheme.onPrimary, + unselectedTextColor = MaterialTheme.colorScheme.onPrimary, + ), + ) + Spacer(modifier = Modifier.height(16.dp)) } } + + // Logout confirmation dialog + if (showLogoutDialog) { + AlertDialog( + onDismissRequest = { showLogoutDialog = false }, + title = { Text("Logout") }, + text = { Text("Are you sure you want to logout?") }, + confirmButton = { + Button(onClick = { + showLogoutDialog = false + scope.launch { drawerState.close() } + Toast + .makeText( + context, + "You are now logged out.", + Toast.LENGTH_LONG, + ).show() + UserManager.logout() + // TODO: Navigate to login screen + (context as MainActivity).finish() + }) { + Text( + style = TextStyle(fontFamily = interDisplayFamily), + text = "Logout", + ) + } + }, + dismissButton = { + TextButton(onClick = { showLogoutDialog = false }) { + Text( + style = TextStyle(fontFamily = interDisplayFamily), + text = "Cancel", + ) + } + }, + ) + } } @Preview(showBackground = true) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/elements/TopBar.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/elements/TopBar.kt index b057514..8fa9034 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/elements/TopBar.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/ui/composables/elements/TopBar.kt @@ -30,10 +30,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavHostController -import com.getreconnected.reconnected.activities.MainActivity -import com.getreconnected.reconnected.core.Application import com.getreconnected.reconnected.core.dataManager.UserManager -import com.getreconnected.reconnected.core.models.Screens import com.getreconnected.reconnected.ui.theme.ReconnectEDTheme import com.getreconnected.reconnected.ui.theme.interDisplayFamily @@ -84,24 +81,33 @@ fun TopBar( Toast .makeText( context, - "You are now logged out.", + if (UserManager.user != null) { + "You are logged in as ${UserManager.user?.displayName} (${UserManager.user?.email})." + } else { + "You are not logged in." + }, Toast.LENGTH_LONG, ).show() - UserManager.logout() - // TODO: navigate back to login screen - (context as MainActivity).finish() }) { UserManager.user?.avatar?.let { avatarBitmap -> Image( painter = BitmapPainter(avatarBitmap.asImageBitmap()), contentDescription = "Profile", - modifier = Modifier.width(36.dp).height(36.dp).clip(CircleShape), + modifier = + Modifier + .width(36.dp) + .height(36.dp) + .clip(CircleShape), contentScale = ContentScale.Crop, ) } ?: Icon( imageVector = Icons.Default.AccountCircle, contentDescription = "User Profile", - modifier = Modifier.padding(1.dp).width(36.dp).height(36.dp), + modifier = + Modifier + .padding(1.dp) + .width(36.dp) + .height(36.dp), tint = MaterialTheme.colorScheme.onPrimary, ) } From 7718955614a92e8e7a470a03d336548ef95a24cf Mon Sep 17 00:00:00 2001 From: Chris1320 Date: Thu, 30 Oct 2025 13:05:37 +0800 Subject: [PATCH 21/42] feat(android): Implement app usage monitoring and blocking functionality - Added AppBlockOverlayService to display an overlay when app usage limits are exceeded. - Introduced AppLimitMonitorService to monitor app usage and enforce time limits. - Updated Dashboard to check for necessary permissions and guide users to grant them. - Enhanced ScreenTimeLimit screen to display active limits and allow setting limits for apps. - Created UI components for permission requests and app limit management. - Added drawable resources for overlay and icons, along with necessary string resources. Signed-off-by: Chris1320 --- .../app/src/main/AndroidManifest.xml | 31 ++ .../activities/AppBlockedActivity.kt | 196 ++++++++++ .../reconnected/activities/MainActivity.kt | 55 +++ .../core/database/AppLimitDatabase.kt | 53 +++ .../core/database/dao/AppLimitDao.kt | 103 ++++++ .../core/database/dao/InstalledAppDao.kt | 85 +++++ .../core/database/entities/AppLimit.kt | 25 ++ .../core/database/entities/InstalledApp.kt | 24 ++ .../core/repositories/AppLimitRepository.kt | 160 ++++++++ .../core/util/AppLimitPermissionHelper.kt | 89 +++++ .../core/util/AppLimitPreferences.kt | 33 ++ .../viewModels/ScreenTimeLimitViewModel.kt | 118 +++++- .../services/AppBlockOverlayService.kt | 132 +++++++ .../services/AppLimitMonitorService.kt | 345 ++++++++++++++++++ .../reconnected/ui/menus/Dashboard.kt | 281 ++++++++++++-- .../reconnected/ui/menus/ScreenTimeLimit.kt | 197 ++++++++-- .../res/drawable/block_overlay_gradient.xml | 8 + .../src/main/res/drawable/ic_lock_large.xml | 9 + .../src/main/res/layout/app_block_overlay.xml | 69 ++++ .../app/src/main/res/values/strings.xml | 5 + 20 files changed, 1941 insertions(+), 77 deletions(-) create mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/activities/AppBlockedActivity.kt create mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/AppLimitDatabase.kt create mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/dao/AppLimitDao.kt create mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/dao/InstalledAppDao.kt create mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/entities/AppLimit.kt create mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/entities/InstalledApp.kt create mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/repositories/AppLimitRepository.kt create mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/AppLimitPermissionHelper.kt create mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/AppLimitPreferences.kt create mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/services/AppBlockOverlayService.kt create mode 100644 ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/services/AppLimitMonitorService.kt create mode 100644 ReconnectED-Android/app/src/main/res/drawable/block_overlay_gradient.xml create mode 100644 ReconnectED-Android/app/src/main/res/drawable/ic_lock_large.xml create mode 100644 ReconnectED-Android/app/src/main/res/layout/app_block_overlay.xml diff --git a/ReconnectED-Android/app/src/main/AndroidManifest.xml b/ReconnectED-Android/app/src/main/AndroidManifest.xml index a7d0b6e..fbeb58c 100644 --- a/ReconnectED-Android/app/src/main/AndroidManifest.xml +++ b/ReconnectED-Android/app/src/main/AndroidManifest.xml @@ -6,6 +6,11 @@ + + + + + @@ -34,5 +39,31 @@ + + + + + + + + + + + diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/activities/AppBlockedActivity.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/activities/AppBlockedActivity.kt new file mode 100644 index 0000000..66a52b9 --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/activities/AppBlockedActivity.kt @@ -0,0 +1,196 @@ +package com.getreconnected.reconnected.activities + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +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.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.getreconnected.reconnected.core.util.formatTime +import com.getreconnected.reconnected.ui.theme.ReconnectEDTheme +import com.getreconnected.reconnected.ui.theme.interDisplayFamily + +/** + * Activity that is displayed when an app's usage limit is reached. + * Shows a blocking screen and sends the user back to the home screen. + */ +class AppBlockedActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val packageName = intent.getStringExtra("package_name") ?: "" + val appName = intent.getStringExtra("app_name") ?: "This app" + val limitMillis = intent.getLongExtra("limit_millis", 0L) + + setContent { + ReconnectEDTheme { + AppBlockedScreen( + appName = appName, + limitMillis = limitMillis, + onGoHome = { + goToHomeScreen() + finish() + }, + ) + } + } + } + + private fun goToHomeScreen() { + val homeIntent = + Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_HOME) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + startActivity(homeIntent) + } +} + +/** + * Composable screen that displays the app blocked message. + */ +@Composable +@Suppress("ktlint:standard:function-naming") +fun AppBlockedScreen( + appName: String, + limitMillis: Long, + onGoHome: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background( + brush = + Brush.verticalGradient( + colors = + listOf( + Color(0xFFEF4444), // Red + Color(0xFFF97316), // Orange + ), + ), + ).padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "Locked", + modifier = Modifier.size(120.dp), + tint = Color.White, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = "Time's Up!", + style = + TextStyle( + fontFamily = interDisplayFamily, + fontSize = 48.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + ), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "You've reached your limit for", + style = + TextStyle( + fontFamily = interDisplayFamily, + fontSize = 20.sp, + fontWeight = FontWeight.Normal, + color = Color.White.copy(alpha = 0.9f), + ), + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = appName, + style = + TextStyle( + fontFamily = interDisplayFamily, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + ), + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Daily limit: ${formatTime(limitMillis)}", + style = + TextStyle( + fontFamily = interDisplayFamily, + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + color = Color.White.copy(alpha = 0.8f), + ), + ) + + Spacer(modifier = Modifier.height(48.dp)) + + Text( + text = "Take a break and reconnect with what matters!", + style = + TextStyle( + fontFamily = interDisplayFamily, + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = Color.White.copy(alpha = 0.9f), + ), + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = onGoHome, + colors = + ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color(0xFFEF4444), + ), + modifier = Modifier.height(56.dp), + ) { + Text( + text = "Go to Home Screen", + style = + TextStyle( + fontFamily = interDisplayFamily, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ), + ) + } + } +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/activities/MainActivity.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/activities/MainActivity.kt index f142728..ea22c9b 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/activities/MainActivity.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/activities/MainActivity.kt @@ -1,10 +1,16 @@ package com.getreconnected.reconnected.activities +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import com.getreconnected.reconnected.services.AppLimitMonitorService import com.getreconnected.reconnected.ui.AppNavigation import com.getreconnected.reconnected.ui.theme.ReconnectEDTheme @@ -12,6 +18,19 @@ import com.getreconnected.reconnected.ui.theme.ReconnectEDTheme * The main activity for the app. */ class MainActivity : ComponentActivity() { + // Notification permission launcher for Android 13+ + private val notificationPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) { + Log.d("MainActivity", "Notification permission granted") + startMonitoringService() + } else { + Log.w("MainActivity", "Notification permission denied") + // Service will still start, but notifications won't show + startMonitoringService() + } + } + override fun onCreate(savedInstanceState: Bundle?) { Log.d("MainActivity", "Starting main activity") installSplashScreen() // This must be called BEFORE super.onCreate() @@ -20,5 +39,41 @@ class MainActivity : ComponentActivity() { Log.d("MainActivity", "Setting content") setContent { ReconnectEDTheme { AppNavigation() } } + + // Request notification permission on Android 13+ and start monitoring service + requestNotificationPermissionAndStartService() + } + + override fun onDestroy() { + super.onDestroy() + // Note: We don't stop the service here as it should run in the background + // The service will be stopped when the user disables monitoring or logs out + } + + private fun requestNotificationPermissionAndStartService() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + when { + ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED -> { + startMonitoringService() + } + else -> { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } else { + startMonitoringService() + } + } + + private fun startMonitoringService() { + try { + AppLimitMonitorService.start(this) + Log.d("MainActivity", "App limit monitoring service started") + } catch (e: Exception) { + Log.e("MainActivity", "Failed to start monitoring service", e) + } } } diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/AppLimitDatabase.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/AppLimitDatabase.kt new file mode 100644 index 0000000..f635262 --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/AppLimitDatabase.kt @@ -0,0 +1,53 @@ +package com.getreconnected.reconnected.core.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import com.getreconnected.reconnected.core.database.dao.AppLimitDao +import com.getreconnected.reconnected.core.database.dao.InstalledAppDao +import com.getreconnected.reconnected.core.database.entities.AppLimit +import com.getreconnected.reconnected.core.database.entities.InstalledApp + +/** + * Room database for storing app limits and installed apps. + * + * This database contains two tables: + * 1. installed_apps - Stores information about all installed applications + * 2. app_limits - Stores user-defined time limits for specific applications + */ +@Database( + entities = [InstalledApp::class, AppLimit::class], + version = 1, + exportSchema = false, +) +abstract class AppLimitDatabase : RoomDatabase() { + abstract fun installedAppDao(): InstalledAppDao + + abstract fun appLimitDao(): AppLimitDao + + companion object { + @Volatile + private var INSTANCE: AppLimitDatabase? = null + + /** + * Get the singleton instance of the database. + * + * @param context Application context + * @return The database instance + */ + fun getDatabase(context: Context): AppLimitDatabase { + return INSTANCE ?: synchronized(this) { + val instance = + Room + .databaseBuilder( + context.applicationContext, + AppLimitDatabase::class.java, + "app_limit_database", + ).build() + INSTANCE = instance + instance + } + } + } +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/dao/AppLimitDao.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/dao/AppLimitDao.kt new file mode 100644 index 0000000..679b98c --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/dao/AppLimitDao.kt @@ -0,0 +1,103 @@ +package com.getreconnected.reconnected.core.database.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.getreconnected.reconnected.core.database.entities.AppLimit +import kotlinx.coroutines.flow.Flow + +/** + * Data Access Object for AppLimit entities. + * Provides methods to interact with the app_limits table. + */ +@Dao +interface AppLimitDao { + /** + * Get all app limits as a Flow for reactive updates. + */ + @Query("SELECT * FROM app_limits ORDER BY appName ASC") + fun getAllLimits(): Flow> + + /** + * Get all enabled app limits. + */ + @Query("SELECT * FROM app_limits WHERE isEnabled = 1 ORDER BY appName ASC") + fun getEnabledLimits(): Flow> + + /** + * Get a specific app limit by package name. + */ + @Query("SELECT * FROM app_limits WHERE packageName = :packageName") + suspend fun getLimitByPackage(packageName: String): AppLimit? + + /** + * Get a specific app limit by package name as Flow. + */ + @Query("SELECT * FROM app_limits WHERE packageName = :packageName") + fun getLimitByPackageFlow(packageName: String): Flow + + /** + * Insert or update an app limit. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertLimit(appLimit: AppLimit) + + /** + * Insert multiple app limits. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertLimits(appLimits: List) + + /** + * Update an existing app limit. + */ + @Update + suspend fun updateLimit(appLimit: AppLimit) + + /** + * Delete an app limit. + */ + @Delete + suspend fun deleteLimit(appLimit: AppLimit) + + /** + * Delete app limit by package name. + */ + @Query("DELETE FROM app_limits WHERE packageName = :packageName") + suspend fun deleteLimitByPackage(packageName: String) + + /** + * Enable or disable a limit. + */ + @Query("UPDATE app_limits SET isEnabled = :isEnabled, updatedAt = :timestamp WHERE packageName = :packageName") + suspend fun setLimitEnabled( + packageName: String, + isEnabled: Boolean, + timestamp: Long = System.currentTimeMillis(), + ) + + /** + * Update the limit time for an app. + */ + @Query("UPDATE app_limits SET limitMillis = :limitMillis, updatedAt = :timestamp WHERE packageName = :packageName") + suspend fun updateLimitTime( + packageName: String, + limitMillis: Long, + timestamp: Long = System.currentTimeMillis(), + ) + + /** + * Check if a limit exists for a package. + */ + @Query("SELECT EXISTS(SELECT 1 FROM app_limits WHERE packageName = :packageName)") + suspend fun hasLimit(packageName: String): Boolean + + /** + * Delete all app limits. + */ + @Query("DELETE FROM app_limits") + suspend fun deleteAllLimits() +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/dao/InstalledAppDao.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/dao/InstalledAppDao.kt new file mode 100644 index 0000000..8a8b493 --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/dao/InstalledAppDao.kt @@ -0,0 +1,85 @@ +package com.getreconnected.reconnected.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.getreconnected.reconnected.core.database.entities.InstalledApp +import kotlinx.coroutines.flow.Flow + +/** + * Data Access Object for InstalledApp entities. + * Provides methods to interact with the installed_apps table. + */ +@Dao +interface InstalledAppDao { + /** + * Get all installed apps as a Flow. + */ + @Query("SELECT * FROM installed_apps WHERE isInstalled = 1 ORDER BY appName ASC") + fun getAllInstalledApps(): Flow> + + /** + * Get all user apps (non-system apps). + */ + @Query("SELECT * FROM installed_apps WHERE isInstalled = 1 AND isSystemApp = 0 ORDER BY appName ASC") + fun getUserApps(): Flow> + + /** + * Get a specific app by package name. + */ + @Query("SELECT * FROM installed_apps WHERE packageName = :packageName") + suspend fun getAppByPackage(packageName: String): InstalledApp? + + /** + * Insert or update an installed app. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertApp(app: InstalledApp) + + /** + * Insert multiple apps. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertApps(apps: List) + + /** + * Update an app. + */ + @Update + suspend fun updateApp(app: InstalledApp) + + /** + * Mark an app as uninstalled. + */ + @Query("UPDATE installed_apps SET isInstalled = 0, lastUpdated = :timestamp WHERE packageName = :packageName") + suspend fun markAsUninstalled( + packageName: String, + timestamp: Long = System.currentTimeMillis(), + ) + + /** + * Delete all apps marked as uninstalled. + */ + @Query("DELETE FROM installed_apps WHERE isInstalled = 0") + suspend fun deleteUninstalledApps() + + /** + * Search apps by name. + */ + @Query("SELECT * FROM installed_apps WHERE appName LIKE '%' || :query || '%' AND isInstalled = 1 ORDER BY appName ASC") + fun searchApps(query: String): Flow> + + /** + * Get count of installed apps. + */ + @Query("SELECT COUNT(*) FROM installed_apps WHERE isInstalled = 1") + suspend fun getInstalledAppCount(): Int + + /** + * Delete all apps. + */ + @Query("DELETE FROM installed_apps") + suspend fun deleteAllApps() +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/entities/AppLimit.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/entities/AppLimit.kt new file mode 100644 index 0000000..de7ce5a --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/entities/AppLimit.kt @@ -0,0 +1,25 @@ +package com.getreconnected.reconnected.core.database.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Entity representing a time limit set by the user for a specific application. + * + * @property packageName The unique package name of the application (e.g., "com.example.app") + * @property appName The display name of the application + * @property limitMillis The time limit in milliseconds + * @property isEnabled Whether the limit is currently active + * @property createdAt Timestamp when the limit was created + * @property updatedAt Timestamp when the limit was last updated + */ +@Entity(tableName = "app_limits") +data class AppLimit( + @PrimaryKey + val packageName: String, + val appName: String, + val limitMillis: Long, + val isEnabled: Boolean = true, + val createdAt: Long = System.currentTimeMillis(), + val updatedAt: Long = System.currentTimeMillis(), +) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/entities/InstalledApp.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/entities/InstalledApp.kt new file mode 100644 index 0000000..71edccc --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/entities/InstalledApp.kt @@ -0,0 +1,24 @@ +package com.getreconnected.reconnected.core.database.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Entity representing an installed application on the device. + * This table stores information about all apps that can potentially have limits set. + * + * @property packageName The unique package name of the application + * @property appName The display name of the application + * @property isSystemApp Whether this is a system application + * @property isInstalled Whether the app is currently installed + * @property lastUpdated Timestamp when this record was last updated + */ +@Entity(tableName = "installed_apps") +data class InstalledApp( + @PrimaryKey + val packageName: String, + val appName: String, + val isSystemApp: Boolean = false, + val isInstalled: Boolean = true, + val lastUpdated: Long = System.currentTimeMillis(), +) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/repositories/AppLimitRepository.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/repositories/AppLimitRepository.kt new file mode 100644 index 0000000..3498b4e --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/repositories/AppLimitRepository.kt @@ -0,0 +1,160 @@ +package com.getreconnected.reconnected.core.repositories + +import android.app.Application +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import com.getreconnected.reconnected.core.database.AppLimitDatabase +import com.getreconnected.reconnected.core.database.entities.AppLimit +import com.getreconnected.reconnected.core.database.entities.InstalledApp +import kotlinx.coroutines.flow.Flow + +/** + * Repository for managing app limits. + * Handles business logic for setting, retrieving, and enforcing app usage limits. + */ +class AppLimitRepository( + private val application: Application, +) { + private val database = AppLimitDatabase.getDatabase(application) + private val appLimitDao = database.appLimitDao() + private val installedAppDao = database.installedAppDao() + private val packageManager = application.packageManager + + // Flow for observing all limits + val allLimits: Flow> = appLimitDao.getAllLimits() + + // Flow for observing enabled limits + val enabledLimits: Flow> = appLimitDao.getEnabledLimits() + + // Flow for observing installed apps + val installedApps: Flow> = installedAppDao.getAllInstalledApps() + + // Flow for observing user apps only + val userApps: Flow> = installedAppDao.getUserApps() + + /** + * Sync installed apps from the device to the database. + * This should be called periodically to keep the database up to date. + */ + suspend fun syncInstalledApps() { + val intent = + android.content.Intent( + android.content.Intent.ACTION_MAIN, + null, + ) + intent.addCategory(android.content.Intent.CATEGORY_LAUNCHER) + + val packages = packageManager.queryIntentActivities(intent, 0) + val apps = + packages.mapNotNull { resolveInfo -> + try { + val packageName = resolveInfo.activityInfo.packageName + // Skip our own app + if (packageName == application.packageName) return@mapNotNull null + + val appInfo = packageManager.getApplicationInfo(packageName, 0) + val appName = packageManager.getApplicationLabel(appInfo).toString() + val isSystemApp = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0 + + InstalledApp( + packageName = packageName, + appName = appName, + isSystemApp = isSystemApp, + isInstalled = true, + lastUpdated = System.currentTimeMillis(), + ) + } catch (e: PackageManager.NameNotFoundException) { + null + } + } + + installedAppDao.insertApps(apps) + } + + /** + * Set or update a limit for an app. + * + * @param packageName The package name of the app + * @param appName The display name of the app + * @param limitMillis The limit in milliseconds + * @param isEnabled Whether the limit should be enabled + */ + suspend fun setLimit( + packageName: String, + appName: String, + limitMillis: Long, + isEnabled: Boolean = true, + ) { + val limit = + AppLimit( + packageName = packageName, + appName = appName, + limitMillis = limitMillis, + isEnabled = isEnabled, + updatedAt = System.currentTimeMillis(), + ) + appLimitDao.insertLimit(limit) + } + + /** + * Get a limit for a specific app. + */ + suspend fun getLimit(packageName: String): AppLimit? { + return appLimitDao.getLimitByPackage(packageName) + } + + /** + * Get a limit as Flow for reactive updates. + */ + fun getLimitFlow(packageName: String): Flow { + return appLimitDao.getLimitByPackageFlow(packageName) + } + + /** + * Update the limit time for an app. + */ + suspend fun updateLimitTime( + packageName: String, + limitMillis: Long, + ) { + appLimitDao.updateLimitTime(packageName, limitMillis) + } + + /** + * Enable or disable a limit. + */ + suspend fun setLimitEnabled( + packageName: String, + isEnabled: Boolean, + ) { + appLimitDao.setLimitEnabled(packageName, isEnabled) + } + + /** + * Delete a limit. + */ + suspend fun deleteLimit(packageName: String) { + appLimitDao.deleteLimitByPackage(packageName) + } + + /** + * Check if an app has a limit set. + */ + suspend fun hasLimit(packageName: String): Boolean { + return appLimitDao.hasLimit(packageName) + } + + /** + * Get an installed app by package name. + */ + suspend fun getInstalledApp(packageName: String): InstalledApp? { + return installedAppDao.getAppByPackage(packageName) + } + + /** + * Search installed apps by name. + */ + fun searchApps(query: String): Flow> { + return installedAppDao.searchApps(query) + } +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/AppLimitPermissionHelper.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/AppLimitPermissionHelper.kt new file mode 100644 index 0000000..7a9a6a2 --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/AppLimitPermissionHelper.kt @@ -0,0 +1,89 @@ +package com.getreconnected.reconnected.core.util + +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings + +/** + * Utility functions for managing app limit permissions. + */ +object AppLimitPermissionHelper { + /** + * Check if the app can draw overlays (SYSTEM_ALERT_WINDOW permission). + * This is required to show blocking overlay over apps. + */ + fun canDrawOverlays(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Settings.canDrawOverlays(context) + } else { + true // Older Android versions don't need explicit permission + } + } + + /** + * Open settings to allow drawing overlays. + */ + fun openOverlaySettings(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val intent = + Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:${context.packageName}"), + ) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) + } + } + + /** + * Check if the app can show full-screen intents (needed for blocking popup). + * This is required on Android 14+ for automatic popup display. + */ + fun canShowFullScreenIntent(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.canUseFullScreenIntent() + } else { + true // Older Android versions don't need this permission + } + } + + /** + * Open settings to allow full-screen intents. + */ + fun openFullScreenIntentSettings(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val intent = + Intent( + Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, + Uri.parse("package:${context.packageName}"), + ) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) + } + } + + /** + * Check if all required permissions for app limiting are granted. + */ + fun hasAllRequiredPermissions(context: Context): Boolean { + val hasUsageAccess = hasUsageStatsPermission(context) + val canDrawOverlay = canDrawOverlays(context) + return hasUsageAccess && canDrawOverlay + } + + private fun hasUsageStatsPermission(context: Context): Boolean { + val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as android.app.AppOpsManager + val mode = + appOps.unsafeCheckOpNoThrow( + android.app.AppOpsManager.OPSTR_GET_USAGE_STATS, + android.os.Process.myUid(), + context.packageName, + ) + return mode == android.app.AppOpsManager.MODE_ALLOWED + } +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/AppLimitPreferences.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/AppLimitPreferences.kt new file mode 100644 index 0000000..726c885 --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/AppLimitPreferences.kt @@ -0,0 +1,33 @@ +package com.getreconnected.reconnected.core.util + +import android.content.Context +import android.content.SharedPreferences + +/** + * Utility object for managing app limit monitoring preferences. + */ +object AppLimitPreferences { + private const val PREFS_NAME = "app_limit_prefs" + private const val KEY_MONITORING_ENABLED = "monitoring_enabled" + + private fun getPreferences(context: Context): SharedPreferences { + return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + /** + * Check if app limit monitoring is enabled. + */ + fun isMonitoringEnabled(context: Context): Boolean { + return getPreferences(context).getBoolean(KEY_MONITORING_ENABLED, true) + } + + /** + * Set monitoring enabled state. + */ + fun setMonitoringEnabled( + context: Context, + enabled: Boolean, + ) { + getPreferences(context).edit().putBoolean(KEY_MONITORING_ENABLED, enabled).apply() + } +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeLimitViewModel.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeLimitViewModel.kt index cad8083..9af6b15 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeLimitViewModel.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeLimitViewModel.kt @@ -4,33 +4,137 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.getreconnected.reconnected.core.AppUsageRepository +import com.getreconnected.reconnected.core.database.entities.AppLimit import com.getreconnected.reconnected.core.models.entities.AppUsageInfo +import com.getreconnected.reconnected.core.repositories.AppLimitRepository import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch /** - * ViewModel class for tracking and managing app screen time usage. + * Data class combining app usage info with limit information. + */ +data class AppUsageWithLimit( + val usageInfo: AppUsageInfo, + val limit: AppLimit?, +) + +/** + * ViewModel class for tracking and managing app screen time usage and limits. * - * This ViewModel interacts with the AppUsageRepository to fetch and provide - * the app usage statistics in the form of a StateFlow. It is responsible - * for managing the lifecycle-conscious asynchronous data updates to the - * associated UI components. + * This ViewModel interacts with the AppUsageRepository and AppLimitRepository + * to fetch and provide the app usage statistics and limits in the form of StateFlows. + * It is responsible for managing the lifecycle-conscious asynchronous data updates + * to the associated UI components. * - * @constructor Creates an instance of ScreenTimeTrackerViewModel. - * @param application The Application context required for the AppUsageRepository. + * @constructor Creates an instance of ScreenTimeLimitViewModel. + * @param application The Application context required for the repositories. */ class ScreenTimeLimitViewModel( application: Application, ) : AndroidViewModel(application) { private val appUsageRepository = AppUsageRepository(application) + private val appLimitRepository = AppLimitRepository(application) private val _appUsageStats = MutableStateFlow>(emptyList()) val appUsageStats: StateFlow> = _appUsageStats + // Observe all app limits + val appLimits: StateFlow> = + appLimitRepository.allLimits.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList(), + ) + + // Combine usage stats with limits + val appUsageWithLimits: StateFlow> = + combine(_appUsageStats, appLimits) { usageList, limitList -> + usageList.map { usage -> + val limit = limitList.find { it.packageName == usage.packageName } + AppUsageWithLimit(usage, limit) + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList(), + ) + + init { + // Sync installed apps on initialization + viewModelScope.launch { + appLimitRepository.syncInstalledApps() + } + } + fun loadUsageStats() { viewModelScope.launch { _appUsageStats.value = appUsageRepository.getDailyUsageStats() } } + + /** + * Set a time limit for an app. + * + * @param packageName The package name of the app + * @param appName The display name of the app + * @param hours Number of hours + * @param minutes Number of minutes + */ + fun setLimit( + packageName: String, + appName: String, + hours: Int, + minutes: Int, + ) { + viewModelScope.launch { + val limitMillis = (hours * 60 * 60 * 1000L) + (minutes * 60 * 1000L) + appLimitRepository.setLimit(packageName, appName, limitMillis, isEnabled = true) + } + } + + /** + * Update an existing limit. + */ + fun updateLimit( + packageName: String, + hours: Int, + minutes: Int, + ) { + viewModelScope.launch { + val limitMillis = (hours * 60 * 60 * 1000L) + (minutes * 60 * 1000L) + appLimitRepository.updateLimitTime(packageName, limitMillis) + } + } + + /** + * Enable or disable a limit. + */ + fun toggleLimit( + packageName: String, + isEnabled: Boolean, + ) { + viewModelScope.launch { + appLimitRepository.setLimitEnabled(packageName, isEnabled) + } + } + + /** + * Delete a limit. + */ + fun deleteLimit(packageName: String) { + viewModelScope.launch { + appLimitRepository.deleteLimit(packageName) + } + } + + /** + * Get a specific limit. + */ + suspend fun getLimit(packageName: String): AppLimit? { + return appLimitRepository.getLimit(packageName) + } } diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/services/AppBlockOverlayService.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/services/AppBlockOverlayService.kt new file mode 100644 index 0000000..b0e9570 --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/services/AppBlockOverlayService.kt @@ -0,0 +1,132 @@ +package com.getreconnected.reconnected.services + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.graphics.PixelFormat +import android.os.Build +import android.os.IBinder +import android.util.Log +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.WindowManager +import android.widget.Button +import android.widget.TextView +import com.getreconnected.reconnected.R + +/** + * Service that displays a blocking overlay window over apps that have exceeded their limit. + * This uses SYSTEM_ALERT_WINDOW permission to show an overlay that can't be easily dismissed. + */ +class AppBlockOverlayService : Service() { + private var windowManager: WindowManager? = null + private var overlayView: View? = null + + companion object { + private const val TAG = "AppBlockOverlay" + const val EXTRA_APP_NAME = "app_name" + const val EXTRA_LIMIT_MILLIS = "limit_millis" + + fun show(context: Context, appName: String, limitMillis: Long) { + val intent = Intent(context, AppBlockOverlayService::class.java).apply { + putExtra(EXTRA_APP_NAME, appName) + putExtra(EXTRA_LIMIT_MILLIS, limitMillis) + } + context.startService(intent) + } + + fun hide(context: Context) { + val intent = Intent(context, AppBlockOverlayService::class.java) + context.stopService(intent) + } + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val appName = intent?.getStringExtra(EXTRA_APP_NAME) ?: "This app" + val limitMillis = intent?.getLongExtra(EXTRA_LIMIT_MILLIS, 0) ?: 0 + + showOverlay(appName, limitMillis) + + return START_NOT_STICKY + } + + private fun showOverlay(appName: String, limitMillis: Long) { + // Remove any existing overlay first + removeOverlay() + + windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager + + // Inflate the overlay layout + overlayView = LayoutInflater.from(this).inflate(R.layout.app_block_overlay, null) + + // Set up the overlay view + overlayView?.apply { + findViewById(R.id.blockedAppName)?.text = appName + + val limitMinutes = limitMillis / (1000 * 60) + findViewById(R.id.limitText)?.text = + "You've reached your ${limitMinutes}min limit for today" + + findViewById