From 6074e5b7c3c4dc8632b9d2da71ee1121fc643d8b Mon Sep 17 00:00:00 2001 From: Muhammed Afnan kP Date: Mon, 30 Mar 2026 16:46:01 +0000 Subject: [PATCH 01/13] feat: update app changes and increase Gradle JVM memory --- app/build.gradle.kts | 3 + .../focusblocker/app/ui/FocusBlockerApp.kt | 162 +++++++++++++++ .../ui/screens/applimits/AppLimitsScreen.kt | 23 +++ .../screens/applimits/AppLimitsViewModel.kt | 50 +++++ .../ui/screens/dashboard/DashboardScreen.kt | 118 +++++++++++ .../screens/dashboard/DashboardViewModel.kt | 90 ++++++++ .../app/ui/screens/tasks/TaskScreen.kt | 193 ++++++++++++++++++ .../app/ui/screens/tasks/TaskViewModel.kt | 63 ++++++ .../com/focusblocker/app/ui/theme/Color.kt | 42 ++++ app/src/main/res/values/strings.xml | 2 +- gradle.properties | 2 +- gradle/libs.versions.toml | 4 + 12 files changed, 750 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt create mode 100644 app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsScreen.kt create mode 100644 app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsViewModel.kt create mode 100644 app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt create mode 100644 app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardViewModel.kt create mode 100644 app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt create mode 100644 app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7e1f3a4..8b00789 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -92,6 +92,7 @@ dependencies { implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) // Tooling debugImplementation(libs.androidx.compose.ui.tooling) // Instrumented tests @@ -112,6 +113,8 @@ dependencies { implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.lifecycle.viewmodel.navigation3) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.hilt.navigation.compose) // ════════════════════════════════════════ // ROOM DATABASE diff --git a/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt b/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt new file mode 100644 index 0000000..2d77650 --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt @@ -0,0 +1,162 @@ +package com.focusblocker.app.ui + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.List +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.focusblocker.app.ui.screens.applimits.AppLimitsScreen +import com.focusblocker.app.ui.screens.dashboard.DashboardScreen +import com.focusblocker.app.ui.screens.tasks.TaskScreen +import com.focusblocker.app.ui.theme.Surface800 + +// ── Route constants ─────────────────────────────────────────────────────────── + +object Routes { + const val DASHBOARD = "dashboard" + const val TASKS = "tasks" + const val APP_LIMITS = "app_limits" +} + +// ── Bottom nav descriptor ───────────────────────────────────────────────────── + +private data class TopLevelDest( + val route: String, + val label: String, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector, +) + +private val topLevelDestinations = listOf( + TopLevelDest(Routes.DASHBOARD, "Focus", Icons.Filled.Home, Icons.Outlined.Home), + TopLevelDest(Routes.TASKS, "Planner", Icons.Filled.List, Icons.Outlined.List), + TopLevelDest(Routes.APP_LIMITS, "Enforcer",Icons.Filled.Lock, Icons.Outlined.Lock), +) + +// ── Root composable ─────────────────────────────────────────────────────────── + +/** + * Application root. Owns the NavController and the shared Scaffold that + * hosts the BottomNavigationBar. + * + * Each top-level screen receives the full remaining inner padding from + * Scaffold so their own Scaffolds / LazyColumns can correctly inset for + * the system bars without double-counting. + * + * TRANSITION DESIGN: + * Sibling top-level screens fade — no directional slides between peers. + * Directional slides are reserved for sub-screens (detail views) that + * will be added in future iterations. + */ +@Composable +fun FocusBlockerApp() { + val navController = rememberNavController() + val backStackEntry by navController.currentBackStackEntryAsState() + val currentDest = backStackEntry?.destination + + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = MaterialTheme.colorScheme.background, + bottomBar = { + FocusNavBar( + destinations = topLevelDestinations, + currentDest = currentDest, + onDestSelected = { dest -> + navController.navigate(dest.route) { + // Pop up to start dest to avoid a back stack of all screens. + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + ) + }, + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = Routes.DASHBOARD, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + enterTransition = { fadeIn(tween(220)) }, + exitTransition = { fadeOut(tween(180)) }, + popEnterTransition = { fadeIn(tween(220)) }, + popExitTransition = { fadeOut(tween(180)) }, + ) { + composable(Routes.DASHBOARD) { + DashboardScreen() + } + composable(Routes.TASKS) { + TaskScreen() + } + composable(Routes.APP_LIMITS) { + AppLimitsScreen() + } + } + } +} + +// ── Bottom navigation bar ───────────────────────────────────────────────────── + +@Composable +private fun FocusNavBar( + destinations: List, + currentDest: androidx.navigation.NavDestination?, + onDestSelected: (TopLevelDest) -> Unit, +) { + NavigationBar( + containerColor = Surface800, + tonalElevation = 0.dp, // flat — no tonal overlay on OLED + ) { + destinations.forEach { dest -> + val selected = currentDest?.hierarchy?.any { it.route == dest.route } == true + NavigationBarItem( + selected = selected, + onClick = { onDestSelected(dest) }, + icon = { + Icon( + imageVector = if (selected) dest.selectedIcon else dest.unselectedIcon, + contentDescription = dest.label, + ) + }, + label = { Text(dest.label, style = MaterialTheme.typography.labelMedium) }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.primary, + selectedTextColor = MaterialTheme.colorScheme.primary, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + indicatorColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsScreen.kt new file mode 100644 index 0000000..7bfc6e3 --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsScreen.kt @@ -0,0 +1,23 @@ +// ── AppLimitsScreen.kt stub ─────────────────────────────────────────────────── +// Full implementation delivered in Step 3. +package com.focusblocker.app.ui.screens.applimits + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +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.hilt.navigation.compose.hiltViewModel + +@Composable +fun AppLimitsScreen(viewModel: AppLimitsViewModel = hiltViewModel()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = "Enforcer — Step 3", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsViewModel.kt b/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsViewModel.kt new file mode 100644 index 0000000..d2625f1 --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsViewModel.kt @@ -0,0 +1,50 @@ +package com.focusblocker.app.ui.screens.applimits + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.focusblocker.app.data.local.dao.AppPolicyDao +import com.focusblocker.app.data.local.entity.AppPolicy +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class AppLimitsUiState( + val isLoading: Boolean = true, + val policies: List = emptyList(), + val error: String? = null, +) + +@HiltViewModel +class AppLimitsViewModel @Inject constructor( + private val appPolicyDao: AppPolicyDao, +) : ViewModel() { + + val uiState: StateFlow = + appPolicyDao.observeAllPolicies() + .map { policies -> AppLimitsUiState(isLoading = false, policies = policies) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = AppLimitsUiState(isLoading = true), + ) + + fun toggleBlocking(policy: AppPolicy, enabled: Boolean) { + viewModelScope.launch { + appPolicyDao.setBlockingEnabled(policy.packageName, enabled) + } + } + + fun updateQuota(policy: AppPolicy, newQuotaMs: Long) { + viewModelScope.launch { + appPolicyDao.updatePolicy(policy.copy(allowedQuotaMs = newQuotaMs)) + } + } + + fun removePolicy(policy: AppPolicy) { + viewModelScope.launch { appPolicyDao.deletePolicy(policy) } + } +} diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt new file mode 100644 index 0000000..fa02907 --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt @@ -0,0 +1,118 @@ +package com.focusblocker.app.ui.screens.dashboard + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel + +@Composable +fun DashboardScreen( + viewModel: DashboardViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsState() + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "Deep Work Mode Active", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + + val progress = state.quotaFraction + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth(), + ) + + Text( + text = "${formatDuration(state.totalUsedMs)} of ${formatDuration(state.totalAllowedMs)} used", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Text( + text = "Pending Tasks for Today", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + + if (state.incompleteTasks.isEmpty()) { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Text( + text = "No pending tasks. You are clear for deep work.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } + } else { + state.incompleteTasks.forEach { task -> + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(14.dp)) { + Text( + text = task.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (task.hasTimeBoundary && task.startTimeMs != null) { + Spacer(Modifier.height(4.dp)) + Text( + text = "Starts at ${formatTime(task.startTimeMs)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } +} + +private fun formatDuration(ms: Long): String { + if (ms <= 0L) return "0m" + val hours = ms / 3_600_000L + val minutes = (ms % 3_600_000L) / 60_000L + return when { + hours > 0 && minutes > 0 -> "${hours}h ${minutes}m" + hours > 0 -> "${hours}h" + else -> "${minutes}m" + } +} + +private fun formatTime(epochMs: Long): String { + val dateTime = java.time.Instant.ofEpochMilli(epochMs) + .atZone(java.time.ZoneId.systemDefault()) + return "%02d:%02d".format(dateTime.hour, dateTime.minute) +} diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardViewModel.kt b/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardViewModel.kt new file mode 100644 index 0000000..370cdee --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardViewModel.kt @@ -0,0 +1,90 @@ +// ═════════════════════════════════════════════════════════════════════════════ +// DashboardViewModel.kt +// package com.focusblocker.app.ui.screens.dashboard +// ═════════════════════════════════════════════════════════════════════════════ +package com.focusblocker.app.ui.screens.dashboard + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.focusblocker.app.data.local.dao.AppPolicyDao +import com.focusblocker.app.data.local.dao.TaskDao +import com.focusblocker.app.data.local.entity.AppPolicy +import com.focusblocker.app.data.local.entity.Task +import com.focusblocker.app.security.SecurityManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +// ── UI state ────────────────────────────────────────────────────────────────── + +data class DashboardUiState( + val isLoading: Boolean = true, + val incompleteTasks: List = emptyList(), + val activePolicies: List = emptyList(), + + /** Total allowed quota in ms across all active policies. */ + val totalAllowedMs: Long = 0L, + + /** Total consumed quota in ms across all active policies today. */ + val totalUsedMs: Long = 0L, + + /** True when any active policy has exhausted its quota. */ + val anyQuotaExhausted: Boolean = false, + + val logicalDateLabel: String = "", + val error: String? = null, +) { + /** Fraction [0f, 1f] of total quota consumed — drives the progress ring. */ + val quotaFraction: Float + get() = if (totalAllowedMs <= 0L) 0f + else (totalUsedMs.toFloat() / totalAllowedMs).coerceIn(0f, 1f) + + /** Remaining quota in ms, clamped to zero. */ + val remainingMs: Long + get() = (totalAllowedMs - totalUsedMs).coerceAtLeast(0L) +} + +// ── ViewModel ───────────────────────────────────────────────────────────────── + +@HiltViewModel +class DashboardViewModel @Inject constructor( + private val taskDao: TaskDao, + private val appPolicyDao: AppPolicyDao, + private val securityManager: SecurityManager, +) : ViewModel() { + + private val logicalEpochDay: Long + get() = securityManager.resolveLogicalDay() + + val uiState: StateFlow = combine( + taskDao.observeTasksForDay(logicalEpochDay), + appPolicyDao.observeActivePolicies(), + appPolicyDao.observeTotalUsedTodayMs(), + ) { tasks, policies, totalUsedMs -> + + val incompleteTasks = tasks.filter { !it.isCompleted } + val totalAllowedMs = policies.sumOf { it.allowedQuotaMs } + val anyExhausted = policies.any { it.isQuotaExhausted } + + val logicalDate = java.time.LocalDate.ofEpochDay(logicalEpochDay) + val dateLabel = logicalDate.toString() // "YYYY-MM-DD"; format in UI layer + + DashboardUiState( + isLoading = false, + incompleteTasks = incompleteTasks, + activePolicies = policies, + totalAllowedMs = totalAllowedMs, + totalUsedMs = totalUsedMs, + anyQuotaExhausted = anyExhausted, + logicalDateLabel = dateLabel, + ) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = DashboardUiState(isLoading = true), + ) +} diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt new file mode 100644 index 0000000..947a436 --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt @@ -0,0 +1,193 @@ +package com.focusblocker.app.ui.screens.tasks + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.focusblocker.app.data.local.entity.Task +import java.time.LocalDate + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TaskScreen( + viewModel: TaskViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsState() + + var showAddSheet by remember { mutableStateOf(false) } + var titleInput by remember { mutableStateOf("") } + + Scaffold( + floatingActionButton = { + FloatingActionButton(onClick = { showAddSheet = true }) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "Add task", + ) + } + }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier.padding(innerPadding), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { + Text( + text = "Tasks", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + + if (state.tasks.isEmpty()) { + item { + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Text( + text = "No tasks yet. Tap + to add one.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } + } + } else { + items(items = state.tasks, key = { it.id }) { task -> + TaskItem( + task = task, + onCheckedChange = { viewModel.toggleTaskCompletion(task) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + } + } + + if (showAddSheet) { + ModalBottomSheet( + onDismissRequest = { + showAddSheet = false + titleInput = "" + }, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Add Task", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + + OutlinedTextField( + value = titleInput, + onValueChange = { titleInput = it }, + label = { Text("Title") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Button( + onClick = { + val title = titleInput.trim() + if (title.isNotEmpty()) { + viewModel.addTask( + Task( + title = title, + dateEpochDay = LocalDate.now().toEpochDay(), + ), + ) + titleInput = "" + showAddSheet = false + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Save") + } + } + } + } +} + +@Composable +private fun TaskItem( + task: Task, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + ElevatedCard(modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = task.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (task.hasTimeBoundary && task.startTimeMs != null) { + Text( + text = formatTime(task.startTimeMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Checkbox( + checked = task.isCompleted, + onCheckedChange = onCheckedChange, + ) + } + } +} + +private fun formatTime(epochMs: Long): String { + val dateTime = java.time.Instant.ofEpochMilli(epochMs) + .atZone(java.time.ZoneId.systemDefault()) + return "%02d:%02d".format(dateTime.hour, dateTime.minute) +} diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskViewModel.kt b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskViewModel.kt new file mode 100644 index 0000000..3e3da44 --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskViewModel.kt @@ -0,0 +1,63 @@ +package com.focusblocker.app.ui.screens.tasks + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.focusblocker.app.data.local.dao.TaskDao +import com.focusblocker.app.data.local.entity.Task +import com.focusblocker.app.security.SecurityManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class TaskUiState( + val isLoading: Boolean = true, + val tasks: List = emptyList(), + val allComplete: Boolean = false, + val error: String? = null, +) + +@HiltViewModel +class TaskViewModel @Inject constructor( + private val taskDao: TaskDao, + private val securityManager: SecurityManager, +) : ViewModel() { + + private val logicalEpochDay: Long + get() = securityManager.resolveLogicalDay() + + val uiState: StateFlow = + taskDao.observeTasksForDay(logicalEpochDay) + .map { tasks -> + TaskUiState( + isLoading = false, + tasks = tasks, + allComplete = tasks.isNotEmpty() && tasks.all { it.isCompleted }, + ) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = TaskUiState(isLoading = true), + ) + + fun toggleTaskCompletion(task: Task) { + viewModelScope.launch { + taskDao.setTaskCompletion( + taskId = task.id, + isCompleted = !task.isCompleted, + ) + } + } + + fun deleteTask(task: Task) { + viewModelScope.launch { taskDao.deleteTask(task) } + } + + fun addTask(task: Task) { + viewModelScope.launch { taskDao.insertTask(task) } + } +} diff --git a/app/src/main/java/com/focusblocker/app/ui/theme/Color.kt b/app/src/main/java/com/focusblocker/app/ui/theme/Color.kt index 224ffac..de968c2 100644 --- a/app/src/main/java/com/focusblocker/app/ui/theme/Color.kt +++ b/app/src/main/java/com/focusblocker/app/ui/theme/Color.kt @@ -9,3 +9,45 @@ val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650A4) val PurpleGrey40 = Color(0xFF625B71) val Pink40 = Color(0xFF7D5260) + +// ── Brand palette ───────────────────────────────────────────────────────────── +// Deep Work aesthetic: OLED-safe pitch-black backgrounds, sharp indigo primary, +// electric pink/teal accents that cut through the dark without being garish. + +// Indigo / Focus primary +val Indigo200 = Color(0xFFB0ABFF) +val Indigo400 = Color(0xFF7B6FFF) +val Indigo500 = Color(0xFF6157F5) +val Indigo600 = Color(0xFF4E44D6) +val Indigo900 = Color(0xFF1A1640) + +// Electric pink / action accent +val Pink300 = Color(0xFFFF6BB5) +val Pink400 = Color(0xFFFF4FA0) +val Pink500 = Color(0xFFE83585) + +// Teal / secondary accent +val Teal300 = Color(0xFF4DDEC8) +val Teal400 = Color(0xFF1EC8B0) +val Teal500 = Color(0xFF00A896) + +// OLED dark surfaces +val Black = Color(0xFF000000) +val Surface900 = Color(0xFF0D0D0F) +val Surface800 = Color(0xFF121212) +val Surface700 = Color(0xFF1C1C22) +val Surface600 = Color(0xFF242430) +val Surface500 = Color(0xFF2E2E3C) + +// Semantic +val WarningAmber = Color(0xFFFFB830) +val WarningAmber2 = Color(0xFFFFF0C2) +val SuccessGreen = Color(0xFF34D399) +val SuccessGreen2 = Color(0xFFD1FAE5) +val ErrorRed = Color(0xFFFF5370) +val ErrorRed2 = Color(0xFFFFE4E8) + +// Text +val TextPrimary = Color(0xFFF2F2F8) +val TextSecondary = Color(0xFF9898B0) +val TextDisabled = Color(0xFF4A4A60) \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2ef53c7..54cc0c5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,5 +15,5 @@ --> - MyApplication + FocusBlocker diff --git a/gradle.properties b/gradle.properties index e52b54e..93efb4d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -48,4 +48,4 @@ android.nonTransitiveRClass=true org.gradle.caching=true org.gradle.parallel=true org.gradle.unsafe.configuration-cache=true -org.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=512m -Dkotlin.daemon.jvm.options="-Xmx512m" +org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -Dkotlin.daemon.jvm.options="-Xmx2g" \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e0f6f86..54bc8e3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ androidxCore = "1.17.0" androidxLifecycle = "2.10.0" androidxActivity = "1.12.4" androidxComposeBom = "2026.02.00" +androidxNavigationCompose = "2.8.9" androidxHilt = "1.3.0" androidxRoom = "2.8.4" androidxTest = "1.7.0" @@ -23,12 +24,14 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3"} +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui"} androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview"} androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4"} androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling"} androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest"} androidx-hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "androidxHilt" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHilt" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidxLifecycle" } @@ -46,6 +49,7 @@ junit = { module = "junit:junit", version.ref = "junit" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigationCompose" } androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } androidx-security-crypto = { module = "androidx.security:security-crypto", version = "1.1.0-alpha06" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version = "1.8.0" } From bcb213bbf5750aa7a1602722197f230c2fcc592d Mon Sep 17 00:00:00 2001 From: Muhammed Afnan kP Date: Tue, 31 Mar 2026 01:31:31 +0000 Subject: [PATCH 02/13] Implement bottom-nav UI and remove intro scaffold flow --- app/build.gradle.kts | 1 + .../focusblocker/app/ui/FocusBlockerApp.kt | 7 +- .../com/focusblocker/app/ui/MainActivity.kt | 32 ++-- .../com/focusblocker/app/ui/Navigation.kt | 17 -- .../app/ui/home/FocusBlockerHomeScreen.kt | 32 ---- .../ui/screens/applimits/AppLimitsScreen.kt | 2 +- .../ui/screens/dashboard/DashboardScreen.kt | 93 +++++++-- .../app/ui/screens/tasks/TaskScreen.kt | 28 ++- .../com/focusblocker/app/ui/theme/Theme.kt | 180 +++++++++++++++--- gradle.properties | 11 +- 10 files changed, 283 insertions(+), 120 deletions(-) delete mode 100644 app/src/main/java/com/focusblocker/app/ui/Navigation.kt delete mode 100644 app/src/main/java/com/focusblocker/app/ui/home/FocusBlockerHomeScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8b00789..79eae28 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -59,6 +59,7 @@ android { buildFeatures { compose = true + buildConfig = true } } diff --git a/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt b/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt index 2d77650..374de01 100644 --- a/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt +++ b/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt @@ -1,6 +1,5 @@ package com.focusblocker.app.ui -import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -55,9 +54,9 @@ private data class TopLevelDest( ) private val topLevelDestinations = listOf( - TopLevelDest(Routes.DASHBOARD, "Focus", Icons.Filled.Home, Icons.Outlined.Home), - TopLevelDest(Routes.TASKS, "Planner", Icons.Filled.List, Icons.Outlined.List), - TopLevelDest(Routes.APP_LIMITS, "Enforcer",Icons.Filled.Lock, Icons.Outlined.Lock), + TopLevelDest(Routes.DASHBOARD, "Dashboard", Icons.Filled.Home, Icons.Outlined.Home), + TopLevelDest(Routes.TASKS, "Tasks", Icons.Filled.List, Icons.Outlined.List), + TopLevelDest(Routes.APP_LIMITS, "Limits", Icons.Filled.Lock, Icons.Outlined.Lock), ) // ── Root composable ─────────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/focusblocker/app/ui/MainActivity.kt b/app/src/main/java/com/focusblocker/app/ui/MainActivity.kt index fcf15f1..a454133 100644 --- a/app/src/main/java/com/focusblocker/app/ui/MainActivity.kt +++ b/app/src/main/java/com/focusblocker/app/ui/MainActivity.kt @@ -2,37 +2,33 @@ package com.focusblocker.app.ui import android.os.Bundle import androidx.activity.ComponentActivity -import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.ui.Modifier import com.focusblocker.app.ui.theme.MyApplicationTheme import dagger.hilt.android.AndroidEntryPoint +/** + * Single-activity host. Edge-to-edge is enabled here so the app draws + * behind the status bar and gesture-nav bar — all child composables must + * consume WindowInsets via Scaffold or Modifier.windowInsetsPadding. + * + * Hilt injects the Application-scoped graph; ViewModel injection flows + * down automatically through HiltViewModel / hiltViewModel(). + */ @AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge( - statusBarStyle = SystemBarStyle.auto( - lightScrim = android.graphics.Color.TRANSPARENT, - darkScrim = android.graphics.Color.TRANSPARENT, - ), - ) super.onCreate(savedInstanceState) + // Draws content behind status bar + nav bar. + // Must be called BEFORE setContent. + enableEdgeToEdge() + setContent { MyApplicationTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - MainNavigation() - } + FocusBlockerApp() } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/focusblocker/app/ui/Navigation.kt b/app/src/main/java/com/focusblocker/app/ui/Navigation.kt deleted file mode 100644 index c51c757..0000000 --- a/app/src/main/java/com/focusblocker/app/ui/Navigation.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.focusblocker.app.ui - -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.focusblocker.app.ui.home.FocusBlockerHomeScreen - -@Composable -fun MainNavigation() { - FocusBlockerHomeScreen( - modifier = Modifier - .safeDrawingPadding() - .padding(16.dp), - ) -} diff --git a/app/src/main/java/com/focusblocker/app/ui/home/FocusBlockerHomeScreen.kt b/app/src/main/java/com/focusblocker/app/ui/home/FocusBlockerHomeScreen.kt deleted file mode 100644 index ca9b2a9..0000000 --- a/app/src/main/java/com/focusblocker/app/ui/home/FocusBlockerHomeScreen.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.focusblocker.app.ui.home - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -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.text.style.TextAlign - -@Composable -fun FocusBlockerHomeScreen( - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = "FocusBlocker", - style = MaterialTheme.typography.headlineMedium, - ) - Text( - text = "Core app scaffold ready. Start building your blocker flows here.", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - ) - } -} diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsScreen.kt index 7bfc6e3..6aee538 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsScreen.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsScreen.kt @@ -9,7 +9,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @Composable fun AppLimitsScreen(viewModel: AppLimitsViewModel = hiltViewModel()) { diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt index fa02907..1802723 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt @@ -1,34 +1,49 @@ package com.focusblocker.app.ui.screens.dashboard +import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.focusblocker.app.BuildConfig +import kotlin.math.roundToInt @Composable fun DashboardScreen( viewModel: DashboardViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsState() + val progress = state.quotaFraction + val percentUsed = (progress * 100f).roundToInt() + val ringTrackColor = MaterialTheme.colorScheme.surfaceVariant + val buildLabel = "v${BuildConfig.VERSION_NAME} (${if (BuildConfig.DEBUG) "debug" else "release"})" Column( modifier = Modifier + .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), @@ -38,22 +53,76 @@ fun DashboardScreen( ) { Column( modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Text( - text = "Deep Work Mode Active", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - ) - - val progress = state.quotaFraction - LinearProgressIndicator( - progress = { progress }, + Row( modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Daily Summary", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = buildLabel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier.size(150.dp), + contentAlignment = Alignment.Center, + ) { + Canvas(modifier = Modifier.matchParentSize()) { + drawArc( + color = ringTrackColor, + startAngle = -90f, + sweepAngle = 360f, + useCenter = false, + style = Stroke(width = 14.dp.toPx(), cap = StrokeCap.Round), + ) + } + + CircularProgressIndicator( + progress = { progress }, + modifier = Modifier.matchParentSize(), + strokeWidth = 14.dp, + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0f), + ) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "$percentUsed%", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Used", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Text( + text = "${formatDuration(state.totalUsedMs)} of ${formatDuration(state.totalAllowedMs)} consumed", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = "${formatDuration(state.totalUsedMs)} of ${formatDuration(state.totalAllowedMs)} used", + text = "${formatDuration(state.remainingMs)} remaining today", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt index 947a436..fac32af 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt @@ -3,6 +3,7 @@ package com.focusblocker.app.ui.screens.tasks import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -56,16 +57,31 @@ fun TaskScreen( }, ) { innerPadding -> LazyColumn( - modifier = Modifier.padding(innerPadding), + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), verticalArrangement = Arrangement.spacedBy(10.dp), ) { item { - Text( - text = "Tasks", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, + Column( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Tasks", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = if (state.allComplete && state.tasks.isNotEmpty()) { + "All tasks complete. Keep your focus momentum." + } else { + "${state.tasks.count { !it.isCompleted }} pending for today" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } if (state.tasks.isEmpty()) { diff --git a/app/src/main/java/com/focusblocker/app/ui/theme/Theme.kt b/app/src/main/java/com/focusblocker/app/ui/theme/Theme.kt index 1b48a1b..e92dc30 100644 --- a/app/src/main/java/com/focusblocker/app/ui/theme/Theme.kt +++ b/app/src/main/java/com/focusblocker/app/ui/theme/Theme.kt @@ -1,46 +1,178 @@ package com.focusblocker.app.ui.theme -import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +// ── Dark scheme (primary target — OLED optimised) ───────────────────────────── private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80, + primary = Indigo400, + onPrimary = Color.White, + primaryContainer = Indigo900, + onPrimaryContainer = Indigo200, + + secondary = Pink400, + onSecondary = Color.White, + secondaryContainer = Color(0xFF3D1A2E), + onSecondaryContainer= Pink300, + + tertiary = Teal400, + onTertiary = Color.Black, + tertiaryContainer = Color(0xFF003C35), + onTertiaryContainer = Teal300, + + background = Black, + onBackground = TextPrimary, + + surface = Surface900, + onSurface = TextPrimary, + surfaceVariant = Surface700, + onSurfaceVariant = TextSecondary, + + surfaceTint = Indigo400, + + outline = Surface500, + outlineVariant = Surface600, + + error = ErrorRed, + onError = Color.White, + errorContainer = Color(0xFF3D0A14), + onErrorContainer = ErrorRed2, ) +// ── Light scheme (secondary — ships for completeness) ──────────────────────── private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40, + primary = Indigo600, + onPrimary = Color.White, + primaryContainer = Color(0xFFECEBFF), + onPrimaryContainer = Indigo900, + + secondary = Pink500, + onSecondary = Color.White, + + tertiary = Teal500, + onTertiary = Color.White, + + background = Color(0xFFF6F6FF), + onBackground = Color(0xFF1A1A2E), + + surface = Color.White, + onSurface = Color(0xFF1A1A2E), + surfaceVariant = Color(0xFFEEEEF8), + onSurfaceVariant = Color(0xFF4A4A6A), + + error = ErrorRed, + onError = Color.White, +) + +// ── Typography ──────────────────────────────────────────────────────────────── +// System sans-serif only — no custom font downloads (offline-only app). +// Tightly controlled weight/size scale for the "mission control" aesthetic. +private val FocusTypography = Typography( + // Screen section headers + headlineLarge = TextStyle( + fontWeight = FontWeight.W700, + fontSize = 28.sp, + lineHeight = 34.sp, + letterSpacing = (-0.5).sp, + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.W600, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = (-0.3).sp, + ), + headlineSmall = TextStyle( + fontWeight = FontWeight.W600, + fontSize = 18.sp, + lineHeight = 24.sp, + letterSpacing = (-0.2).sp, + ), + // Card titles, section labels + titleLarge = TextStyle( + fontWeight = FontWeight.W600, + fontSize = 16.sp, + lineHeight = 22.sp, + letterSpacing = 0.sp, + ), + titleMedium = TextStyle( + fontWeight = FontWeight.W500, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + titleSmall = TextStyle( + fontWeight = FontWeight.W500, + fontSize = 13.sp, + lineHeight = 18.sp, + letterSpacing = 0.1.sp, + ), + // Body text + bodyLarge = TextStyle( + fontWeight = FontWeight.W400, + fontSize = 15.sp, + lineHeight = 22.sp, + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.W400, + fontSize = 13.sp, + lineHeight = 19.sp, + ), + bodySmall = TextStyle( + fontWeight = FontWeight.W400, + fontSize = 12.sp, + lineHeight = 17.sp, + letterSpacing = 0.2.sp, + ), + // Labels, chips, badges + labelLarge = TextStyle( + fontWeight = FontWeight.W600, + fontSize = 13.sp, + lineHeight = 18.sp, + letterSpacing = 0.4.sp, + ), + labelMedium = TextStyle( + fontWeight = FontWeight.W500, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = TextStyle( + fontWeight = FontWeight.W500, + fontSize = 10.sp, + lineHeight = 14.sp, + letterSpacing = 0.6.sp, + ), ) +// ── Theme entry point ───────────────────────────────────────────────────────── @Composable fun MyApplicationTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - dynamicColor: Boolean = true, + darkTheme: Boolean = true, // Default dark — OLED-first content: @Composable () -> Unit, ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - - darkTheme -> DarkColorScheme - else -> LightColorScheme - } + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme MaterialTheme( colorScheme = colorScheme, - typography = Typography, - content = content, + typography = FocusTypography, + content = content, ) } + +@Composable +fun FocusBlockerTheme( + darkTheme: Boolean = true, + content: @Composable () -> Unit, +) { + MyApplicationTheme( + darkTheme = darkTheme, + content = content, + ) +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 93efb4d..3e261fe 100644 --- a/gradle.properties +++ b/gradle.properties @@ -24,8 +24,8 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8 +# Keep memory conservative for reliable local and CI builds. +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit @@ -44,8 +44,7 @@ kotlin.code.style=official # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -# Speed up the build +# Speed up the build while staying stable in constrained environments. org.gradle.caching=true -org.gradle.parallel=true -org.gradle.unsafe.configuration-cache=true -org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -Dkotlin.daemon.jvm.options="-Xmx2g" \ No newline at end of file +org.gradle.parallel=false +org.gradle.unsafe.configuration-cache=false \ No newline at end of file From a48f7a658bb7cdbee7705fc3670d69954175a172 Mon Sep 17 00:00:00 2001 From: Muhammed Afnan kP Date: Tue, 31 Mar 2026 02:15:43 +0000 Subject: [PATCH 03/13] Add splash screen and advanced task editing interactions --- app/build.gradle.kts | 1 + .../com/focusblocker/app/ui/MainActivity.kt | 2 + .../app/ui/screens/tasks/TaskScreen.kt | 194 ++++++++++++++++-- .../app/ui/screens/tasks/TaskViewModel.kt | 4 + 4 files changed, 180 insertions(+), 21 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 79eae28..b5c7255 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -71,6 +71,7 @@ dependencies { // Core Android dependencies implementation(libs.androidx.core.ktx) + implementation("androidx.core:core-splashscreen:1.0.1") implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) diff --git a/app/src/main/java/com/focusblocker/app/ui/MainActivity.kt b/app/src/main/java/com/focusblocker/app/ui/MainActivity.kt index a454133..1fd955b 100644 --- a/app/src/main/java/com/focusblocker/app/ui/MainActivity.kt +++ b/app/src/main/java/com/focusblocker/app/ui/MainActivity.kt @@ -4,6 +4,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.focusblocker.app.ui.theme.MyApplicationTheme import dagger.hilt.android.AndroidEntryPoint @@ -19,6 +20,7 @@ import dagger.hilt.android.AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() super.onCreate(savedInstanceState) // Draws content behind status bar + nav bar. diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt index fac32af..41a1120 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt @@ -1,6 +1,8 @@ package com.focusblocker.app.ui.screens.tasks +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -10,6 +12,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.ElevatedCard @@ -20,7 +23,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -35,6 +41,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.focusblocker.app.data.local.entity.Task import java.time.LocalDate +import java.time.ZoneId @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -42,13 +49,27 @@ fun TaskScreen( viewModel: TaskViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsState() + val todayEpochDay = LocalDate.now().toEpochDay() - var showAddSheet by remember { mutableStateOf(false) } + var showTaskSheet by remember { mutableStateOf(false) } + var editingTask by remember { mutableStateOf(null) } var titleInput by remember { mutableStateOf("") } + var startTimeInput by remember { mutableStateOf("") } + var endTimeInput by remember { mutableStateOf("") } + var inputError by remember { mutableStateOf(null) } Scaffold( floatingActionButton = { - FloatingActionButton(onClick = { showAddSheet = true }) { + FloatingActionButton( + onClick = { + editingTask = null + titleInput = "" + startTimeInput = "" + endTimeInput = "" + inputError = null + showTaskSheet = true + }, + ) { Icon( imageVector = Icons.Filled.Add, contentDescription = "Add task", @@ -101,23 +122,64 @@ fun TaskScreen( } } else { items(items = state.tasks, key = { it.id }) { task -> - TaskItem( - task = task, - onCheckedChange = { viewModel.toggleTaskCompletion(task) }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { value -> + if (value == SwipeToDismissBoxValue.StartToEnd || value == SwipeToDismissBoxValue.EndToStart) { + viewModel.deleteTask(task) + true + } else { + false + } + }, ) + + SwipeToDismissBox( + state = dismissState, + backgroundContent = { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + contentAlignment = Alignment.CenterEnd, + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = "Delete task", + tint = MaterialTheme.colorScheme.error, + ) + } + }, + ) { + TaskItem( + task = task, + onClick = { + editingTask = task + titleInput = task.title + startTimeInput = task.startTimeMs?.let(::formatTime) ?: "" + endTimeInput = task.endTimeMs?.let(::formatTime) ?: "" + inputError = null + showTaskSheet = true + }, + onCheckedChange = { viewModel.toggleTaskCompletion(task) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } } } } } - if (showAddSheet) { + if (showTaskSheet) { ModalBottomSheet( onDismissRequest = { - showAddSheet = false + showTaskSheet = false + editingTask = null titleInput = "" + startTimeInput = "" + endTimeInput = "" + inputError = null }, ) { Column( @@ -127,7 +189,7 @@ fun TaskScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Add Task", + text = if (editingTask == null) "Add Task" else "Edit Task", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, ) @@ -140,19 +202,88 @@ fun TaskScreen( singleLine = true, ) + OutlinedTextField( + value = startTimeInput, + onValueChange = { startTimeInput = it }, + label = { Text("Start Time (HH:mm)") }, + placeholder = { Text("09:00") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + OutlinedTextField( + value = endTimeInput, + onValueChange = { endTimeInput = it }, + label = { Text("End Time (HH:mm)") }, + placeholder = { Text("10:30") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + inputError?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + Button( onClick = { val title = titleInput.trim() - if (title.isNotEmpty()) { - viewModel.addTask( - Task( - title = title, - dateEpochDay = LocalDate.now().toEpochDay(), - ), - ) - titleInput = "" - showAddSheet = false + if (title.isEmpty()) { + inputError = "Title is required." + return@Button } + + val hasAnyTimeInput = startTimeInput.isNotBlank() || endTimeInput.isNotBlank() + val startMs = if (startTimeInput.isBlank()) null else parseTimeInputToEpochMs( + dateEpochDay = editingTask?.dateEpochDay ?: todayEpochDay, + value = startTimeInput, + ) + val endMs = if (endTimeInput.isBlank()) null else parseTimeInputToEpochMs( + dateEpochDay = editingTask?.dateEpochDay ?: todayEpochDay, + value = endTimeInput, + ) + + if (hasAnyTimeInput && (startMs == null || endMs == null)) { + inputError = "Use HH:mm format for both start and end time." + return@Button + } + + if (startMs != null && endMs != null && startMs >= endMs) { + inputError = "End time must be after start time." + return@Button + } + + inputError = null + + val taskToPersist = editingTask?.copy( + title = title, + hasTimeBoundary = startMs != null && endMs != null, + startTimeMs = startMs, + endTimeMs = endMs, + extendedEndTimeMs = null, + alarmFired = false, + ) ?: Task( + title = title, + dateEpochDay = todayEpochDay, + hasTimeBoundary = startMs != null && endMs != null, + startTimeMs = startMs, + endTimeMs = endMs, + ) + + if (editingTask == null) { + viewModel.addTask(taskToPersist) + } else { + viewModel.updateTask(taskToPersist) + } + + editingTask = null + titleInput = "" + startTimeInput = "" + endTimeInput = "" + showTaskSheet = false }, modifier = Modifier.fillMaxWidth(), ) { @@ -166,10 +297,13 @@ fun TaskScreen( @Composable private fun TaskItem( task: Task, + onClick: () -> Unit, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { - ElevatedCard(modifier = modifier) { + ElevatedCard( + modifier = modifier.clickable(onClick = onClick), + ) { Row( modifier = Modifier .fillMaxWidth() @@ -207,3 +341,21 @@ private fun formatTime(epochMs: Long): String { .atZone(java.time.ZoneId.systemDefault()) return "%02d:%02d".format(dateTime.hour, dateTime.minute) } + +private fun parseTimeInputToEpochMs( + dateEpochDay: Long, + value: String, +): Long? { + val parts = value.trim().split(":") + if (parts.size != 2) return null + + val hour = parts[0].toIntOrNull() ?: return null + val minute = parts[1].toIntOrNull() ?: return null + if (hour !in 0..23 || minute !in 0..59) return null + + return LocalDate.ofEpochDay(dateEpochDay) + .atTime(hour, minute) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() +} diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskViewModel.kt b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskViewModel.kt index 3e3da44..bb486a8 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskViewModel.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskViewModel.kt @@ -60,4 +60,8 @@ class TaskViewModel @Inject constructor( fun addTask(task: Task) { viewModelScope.launch { taskDao.insertTask(task) } } + + fun updateTask(task: Task) { + viewModelScope.launch { taskDao.updateTask(task) } + } } From c82872fe1f800dc2048f1a2e30bbab01a1bd5529 Mon Sep 17 00:00:00 2001 From: Muhammed Afnan kP Date: Tue, 31 Mar 2026 03:27:30 +0000 Subject: [PATCH 04/13] Add time engine: settings day-start and task calendar filtering --- .../focusblocker/app/ui/FocusBlockerApp.kt | 42 ++++-- .../ui/screens/dashboard/DashboardScreen.kt | 23 ++- .../app/ui/screens/settings/SettingsScreen.kt | 137 ++++++++++++++++++ .../ui/screens/settings/SettingsViewModel.kt | 33 +++++ .../app/ui/screens/tasks/TaskScreen.kt | 124 +++++++++++++--- .../app/ui/screens/tasks/TaskViewModel.kt | 63 ++++++-- 6 files changed, 371 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/com/focusblocker/app/ui/screens/settings/SettingsScreen.kt create mode 100644 app/src/main/java/com/focusblocker/app/ui/screens/settings/SettingsViewModel.kt diff --git a/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt b/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt index 374de01..ec3915d 100644 --- a/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt +++ b/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt @@ -33,6 +33,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.focusblocker.app.ui.screens.applimits.AppLimitsScreen import com.focusblocker.app.ui.screens.dashboard.DashboardScreen +import com.focusblocker.app.ui.screens.settings.SettingsScreen import com.focusblocker.app.ui.screens.tasks.TaskScreen import com.focusblocker.app.ui.theme.Surface800 @@ -42,6 +43,7 @@ object Routes { const val DASHBOARD = "dashboard" const val TASKS = "tasks" const val APP_LIMITS = "app_limits" + const val SETTINGS = "settings" } // ── Bottom nav descriptor ───────────────────────────────────────────────────── @@ -84,20 +86,23 @@ fun FocusBlockerApp() { modifier = Modifier.fillMaxSize(), containerColor = MaterialTheme.colorScheme.background, bottomBar = { - FocusNavBar( - destinations = topLevelDestinations, - currentDest = currentDest, - onDestSelected = { dest -> - navController.navigate(dest.route) { - // Pop up to start dest to avoid a back stack of all screens. - popUpTo(navController.graph.findStartDestination().id) { - saveState = true + val onTopLevel = topLevelDestinations.any { it.route == currentDest?.route } + if (onTopLevel) { + FocusNavBar( + destinations = topLevelDestinations, + currentDest = currentDest, + onDestSelected = { dest -> + navController.navigate(dest.route) { + // Pop up to start dest to avoid a back stack of all screens. + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true } - launchSingleTop = true - restoreState = true - } - }, - ) + }, + ) + } }, ) { innerPadding -> NavHost( @@ -112,7 +117,11 @@ fun FocusBlockerApp() { popExitTransition = { fadeOut(tween(180)) }, ) { composable(Routes.DASHBOARD) { - DashboardScreen() + DashboardScreen( + onOpenSettings = { + navController.navigate(Routes.SETTINGS) + }, + ) } composable(Routes.TASKS) { TaskScreen() @@ -120,6 +129,11 @@ fun FocusBlockerApp() { composable(Routes.APP_LIMITS) { AppLimitsScreen() } + composable(Routes.SETTINGS) { + SettingsScreen( + onNavigateBack = { navController.navigateUp() }, + ) + } } } } diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt index 1802723..2d5d098 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt @@ -13,8 +13,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -33,6 +37,7 @@ import kotlin.math.roundToInt @Composable fun DashboardScreen( + onOpenSettings: () -> Unit = {}, viewModel: DashboardViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsState() @@ -65,13 +70,21 @@ fun DashboardScreen( style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, ) - Text( - text = buildLabel, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + IconButton(onClick = onOpenSettings) { + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = "Open settings", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } + Text( + text = buildLabel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Box( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/settings/SettingsScreen.kt new file mode 100644 index 0000000..d4244ab --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/screens/settings/SettingsScreen.kt @@ -0,0 +1,137 @@ +package com.focusblocker.app.ui.screens.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AssistChip +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onNavigateBack: () -> Unit, + viewModel: SettingsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "Custom Day Start", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Tasks and quotas reset when your logical day starts.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Current: ${formatHourLabel(state.dayStartHour)}", + style = MaterialTheme.typography.bodyLarge, + ) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items((0..23).toList()) { hour -> + val selected = hour == state.dayStartHour + AssistChip( + onClick = { viewModel.setDayStartHour(hour) }, + label = { Text(formatHourLabel(hour)) }, + leadingIcon = if (selected) { + { + Text( + text = "•", + style = MaterialTheme.typography.bodyLarge, + ) + } + } else { + null + }, + ) + } + } + } + } + + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Example", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "4:00 AM means 2:00 AM belongs to previous day", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +private fun formatHourLabel(hour: Int): String { + val normalized = ((hour % 24) + 24) % 24 + val suffix = if (normalized < 12) "AM" else "PM" + val twelveHour = when (val h = normalized % 12) { + 0 -> 12 + else -> h + } + return "$twelveHour:00 $suffix" +} diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/com/focusblocker/app/ui/screens/settings/SettingsViewModel.kt new file mode 100644 index 0000000..1e1dc9a --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/screens/settings/SettingsViewModel.kt @@ -0,0 +1,33 @@ +package com.focusblocker.app.ui.screens.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.focusblocker.app.security.SecurityManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +data class SettingsUiState( + val dayStartHour: Int = SecurityManager.DEFAULT_DAY_START_HOUR, +) + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val securityManager: SecurityManager, +) : ViewModel() { + + val uiState: StateFlow = securityManager.dayStartHourFlow + .map { hour -> SettingsUiState(dayStartHour = hour) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = SettingsUiState(dayStartHour = securityManager.readDayStartHour()), + ) + + fun setDayStartHour(hour: Int) { + securityManager.writeDayStartHour(hour) + } +} diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt index 41a1120..a55d483 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt @@ -4,11 +4,13 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -26,6 +28,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -35,12 +38,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.focusblocker.app.data.local.entity.Task import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.temporal.TemporalAdjusters import java.time.ZoneId @OptIn(ExperimentalMaterial3Api::class) @@ -49,7 +55,7 @@ fun TaskScreen( viewModel: TaskViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsState() - val todayEpochDay = LocalDate.now().toEpochDay() + val selectedDate = LocalDate.ofEpochDay(state.selectedEpochDay) var showTaskSheet by remember { mutableStateOf(false) } var editingTask by remember { mutableStateOf(null) } @@ -83,6 +89,17 @@ fun TaskScreen( .padding(innerPadding), verticalArrangement = Arrangement.spacedBy(10.dp), ) { + item { + WeeklyCalendarStrip( + selectedDate = selectedDate, + onDaySelected = { date -> viewModel.selectDay(date.toEpochDay()) }, + onTodayClick = { viewModel.resetToLogicalToday() }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) + } + item { Column( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), @@ -238,11 +255,11 @@ fun TaskScreen( val hasAnyTimeInput = startTimeInput.isNotBlank() || endTimeInput.isNotBlank() val startMs = if (startTimeInput.isBlank()) null else parseTimeInputToEpochMs( - dateEpochDay = editingTask?.dateEpochDay ?: todayEpochDay, + dateEpochDay = editingTask?.dateEpochDay ?: state.selectedEpochDay, value = startTimeInput, ) val endMs = if (endTimeInput.isBlank()) null else parseTimeInputToEpochMs( - dateEpochDay = editingTask?.dateEpochDay ?: todayEpochDay, + dateEpochDay = editingTask?.dateEpochDay ?: state.selectedEpochDay, value = endTimeInput, ) @@ -258,25 +275,24 @@ fun TaskScreen( inputError = null - val taskToPersist = editingTask?.copy( - title = title, - hasTimeBoundary = startMs != null && endMs != null, - startTimeMs = startMs, - endTimeMs = endMs, - extendedEndTimeMs = null, - alarmFired = false, - ) ?: Task( - title = title, - dateEpochDay = todayEpochDay, - hasTimeBoundary = startMs != null && endMs != null, - startTimeMs = startMs, - endTimeMs = endMs, - ) - if (editingTask == null) { - viewModel.addTask(taskToPersist) + viewModel.addTask( + title = title, + startTimeMs = startMs, + endTimeMs = endMs, + ) } else { - viewModel.updateTask(taskToPersist) + val existingTask = editingTask ?: return@Button + viewModel.updateTask( + existingTask.copy( + title = title, + hasTimeBoundary = startMs != null && endMs != null, + startTimeMs = startMs, + endTimeMs = endMs, + extendedEndTimeMs = null, + alarmFired = false, + ), + ) } editingTask = null @@ -294,6 +310,74 @@ fun TaskScreen( } } +@Composable +private fun WeeklyCalendarStrip( + selectedDate: LocalDate, + onDaySelected: (LocalDate) -> Unit, + onTodayClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val weekStart = selectedDate.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)) + val weekDays = (0..6).map { weekStart.plusDays(it.toLong()) } + val dayNameFormatter = DateTimeFormatter.ofPattern("EEE") + val monthFormatter = DateTimeFormatter.ofPattern("MMM d") + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Week of ${weekStart.format(monthFormatter)}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButton(onClick = onTodayClick) { + Text(text = "Today") + } + } + + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(weekDays) { day -> + val selected = day == selectedDate + ElevatedCard( + modifier = Modifier + .clickable { onDaySelected(day) }, + ) { + Column( + modifier = Modifier + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = day.format(dayNameFormatter), + style = MaterialTheme.typography.labelSmall, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Text( + text = day.dayOfMonth.toString(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + } + } + } + } +} + @Composable private fun TaskItem( task: Task, diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskViewModel.kt b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskViewModel.kt index bb486a8..d08322a 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskViewModel.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskViewModel.kt @@ -6,44 +6,65 @@ import com.focusblocker.app.data.local.dao.TaskDao import com.focusblocker.app.data.local.entity.Task import com.focusblocker.app.security.SecurityManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlin.math.max import javax.inject.Inject data class TaskUiState( val isLoading: Boolean = true, val tasks: List = emptyList(), val allComplete: Boolean = false, + val selectedEpochDay: Long = 0L, val error: String? = null, ) @HiltViewModel +@OptIn(ExperimentalCoroutinesApi::class) class TaskViewModel @Inject constructor( private val taskDao: TaskDao, private val securityManager: SecurityManager, ) : ViewModel() { - private val logicalEpochDay: Long - get() = securityManager.resolveLogicalDay() + private val selectedEpochDay = MutableStateFlow(securityManager.resolveLogicalDay()) val uiState: StateFlow = - taskDao.observeTasksForDay(logicalEpochDay) - .map { tasks -> - TaskUiState( - isLoading = false, - tasks = tasks, - allComplete = tasks.isNotEmpty() && tasks.all { it.isCompleted }, - ) + selectedEpochDay + .flatMapLatest { epochDay -> + taskDao.observeTasksForDay(epochDay) + .map { tasks -> + TaskUiState( + isLoading = false, + tasks = tasks, + allComplete = tasks.isNotEmpty() && tasks.all { it.isCompleted }, + selectedEpochDay = epochDay, + ) + } } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = TaskUiState(isLoading = true), + initialValue = TaskUiState( + isLoading = true, + selectedEpochDay = securityManager.resolveLogicalDay(), + ), ) + fun selectDay(epochDay: Long) { + selectedEpochDay.update { epochDay } + } + + fun resetToLogicalToday() { + selectedEpochDay.update { securityManager.resolveLogicalDay() } + } + fun toggleTaskCompletion(task: Task) { viewModelScope.launch { taskDao.setTaskCompletion( @@ -57,8 +78,26 @@ class TaskViewModel @Inject constructor( viewModelScope.launch { taskDao.deleteTask(task) } } - fun addTask(task: Task) { - viewModelScope.launch { taskDao.insertTask(task) } + fun addTask( + title: String, + startTimeMs: Long?, + endTimeMs: Long?, + ) { + viewModelScope.launch { + val hasBoundary = startTimeMs != null && endTimeMs != null + val maxSortOrder = uiState.value.tasks.maxOfOrNull { it.sortOrder } ?: -1 + + taskDao.insertTask( + Task( + title = title, + dateEpochDay = selectedEpochDay.value, + hasTimeBoundary = hasBoundary, + startTimeMs = startTimeMs, + endTimeMs = endTimeMs, + sortOrder = max(0, maxSortOrder + 1), + ), + ) + } } fun updateTask(task: Task) { From d1fe3115e0cfb7ffebef769b73907ba5d3a586c6 Mon Sep 17 00:00:00 2001 From: Muhammed Afnan kP Date: Tue, 31 Mar 2026 03:47:06 +0000 Subject: [PATCH 05/13] Upgrade to premium UI: app studio, focus hub, and task animations --- app/src/main/AndroidManifest.xml | 2 + .../com/focusblocker/app/di/DatabaseModule.kt | 7 + .../ui/screens/applimits/AppLimitsScreen.kt | 185 +++++++++++++++++- .../screens/applimits/AppLimitsViewModel.kt | 143 +++++++++++++- .../ui/screens/dashboard/DashboardScreen.kt | 62 ++++-- .../screens/dashboard/DashboardViewModel.kt | 10 + .../app/ui/screens/tasks/TaskScreen.kt | 47 ++++- 7 files changed, 424 insertions(+), 32 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ede66d1..8f5bbc0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,6 +18,8 @@ + + + var quotaMinutes by remember(app.packageName, app.quotaMs) { + mutableFloatStateOf((app.quotaMs / 60_000L).coerceIn(10L, 240L).toFloat()) + } + + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors( + containerColor = if (app.isBlockingEnabled) { + MaterialTheme.colorScheme.surface + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + }, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + AppIcon(packageName = app.packageName) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = app.appLabel, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = if (app.isConfigured) "Saved policy" else "Not configured yet", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Switch( + checked = app.isBlockingEnabled, + onCheckedChange = { enabled -> + viewModel.toggleBlocking(app, enabled) + }, + ) + } + + Text( + text = "Daily quota: ${quotaMinutes.toInt()} min", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Slider( + value = quotaMinutes, + onValueChange = { quotaMinutes = it }, + valueRange = 10f..240f, + steps = 45, + onValueChangeFinished = { + viewModel.updateQuota( + app = app, + newQuotaMs = quotaMinutes.toLong() * 60_000L, + ) + }, + ) + } + } + } + + item { + Text( + text = "Tip: Lower quotas for highest-dopamine apps first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + } +} + +@Composable +private fun AppIcon(packageName: String) { + val context = LocalContext.current + val iconBitmap = remember(packageName) { + runCatching { + context.packageManager + .getApplicationIcon(packageName) + .toBitmap(width = 96, height = 96) + .asImageBitmap() + }.getOrNull() + } + + if (iconBitmap != null) { + Image( + bitmap = iconBitmap, + contentDescription = null, + modifier = Modifier.size(42.dp), + ) + } else { Text( - text = "Enforcer — Step 3", - style = MaterialTheme.typography.titleMedium, + text = "◻", + style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsViewModel.kt b/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsViewModel.kt index d2625f1..6f5c8fe 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsViewModel.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsViewModel.kt @@ -1,50 +1,173 @@ package com.focusblocker.app.ui.screens.applimits +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.os.Build import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.focusblocker.app.data.local.dao.AppPolicyDao import com.focusblocker.app.data.local.entity.AppPolicy import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject +private const val DEFAULT_QUOTA_MS = 30L * 60L * 1_000L + +data class InstalledAppUi( + val packageName: String, + val appLabel: String, + val quotaMs: Long, + val isBlockingEnabled: Boolean, + val usedQuotaTodayMs: Long, + val isConfigured: Boolean, +) + data class AppLimitsUiState( val isLoading: Boolean = true, - val policies: List = emptyList(), + val apps: List = emptyList(), val error: String? = null, ) @HiltViewModel class AppLimitsViewModel @Inject constructor( private val appPolicyDao: AppPolicyDao, + private val packageManager: PackageManager, ) : ViewModel() { - val uiState: StateFlow = + private data class LaunchableApp( + val packageName: String, + val appLabel: String, + ) + + private val installedApps = MutableStateFlow>(emptyList()) + + private val policies: StateFlow> = appPolicyDao.observeAllPolicies() - .map { policies -> AppLimitsUiState(isLoading = false, policies = policies) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) + + val uiState: StateFlow = + combine(installedApps, policies) { apps, policyList -> + val policiesByPackage = policyList.associateBy { it.packageName } + val rows = apps.map { app -> + val policy = policiesByPackage[app.packageName] + InstalledAppUi( + packageName = app.packageName, + appLabel = app.appLabel, + quotaMs = policy?.allowedQuotaMs ?: DEFAULT_QUOTA_MS, + isBlockingEnabled = policy?.isBlockingEnabled ?: false, + usedQuotaTodayMs = policy?.usedQuotaTodayMs ?: 0L, + isConfigured = policy != null, + ) + } + AppLimitsUiState( + isLoading = false, + apps = rows, + ) + } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = AppLimitsUiState(isLoading = true), ) - fun toggleBlocking(policy: AppPolicy, enabled: Boolean) { + init { + refreshInstalledApps() + } + + fun refreshInstalledApps() { viewModelScope.launch { - appPolicyDao.setBlockingEnabled(policy.packageName, enabled) + val apps = withContext(Dispatchers.IO) { + val launcherIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + val resolved = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.queryIntentActivities( + launcherIntent, + PackageManager.ResolveInfoFlags.of(0), + ) + } else { + @Suppress("DEPRECATION") + packageManager.queryIntentActivities(launcherIntent, 0) + } + + resolved + .mapNotNull { resolveInfo -> + val activityInfo = resolveInfo.activityInfo ?: return@mapNotNull null + val packageName = activityInfo.packageName + val appInfo = activityInfo.applicationInfo ?: return@mapNotNull null + + if (isCoreSystemApp(appInfo)) return@mapNotNull null + + val label = runCatching { + resolveInfo.loadLabel(packageManager).toString() + }.getOrDefault(packageName) + + LaunchableApp( + packageName = packageName, + appLabel = label, + ) + } + .distinctBy { it.packageName } + .sortedBy { it.appLabel.lowercase() } + } + + installedApps.value = apps } } - fun updateQuota(policy: AppPolicy, newQuotaMs: Long) { + fun toggleBlocking(app: InstalledAppUi, enabled: Boolean) { viewModelScope.launch { - appPolicyDao.updatePolicy(policy.copy(allowedQuotaMs = newQuotaMs)) + val existing = policies.value.firstOrNull { it.packageName == app.packageName } + if (existing != null) { + appPolicyDao.setBlockingEnabled(existing.packageName, enabled) + } else { + appPolicyDao.insertPolicy( + AppPolicy( + packageName = app.packageName, + appLabel = app.appLabel, + isBlockingEnabled = enabled, + allowedQuotaMs = app.quotaMs, + ), + ) + } } } - fun removePolicy(policy: AppPolicy) { - viewModelScope.launch { appPolicyDao.deletePolicy(policy) } + fun updateQuota(app: InstalledAppUi, newQuotaMs: Long) { + viewModelScope.launch { + val existing = policies.value.firstOrNull { it.packageName == app.packageName } + if (existing != null) { + appPolicyDao.updatePolicy(existing.copy(allowedQuotaMs = newQuotaMs)) + } else { + appPolicyDao.insertPolicy( + AppPolicy( + packageName = app.packageName, + appLabel = app.appLabel, + isBlockingEnabled = true, + allowedQuotaMs = newQuotaMs, + ), + ) + } + } + } + + fun removePolicy(app: InstalledAppUi) { + viewModelScope.launch { appPolicyDao.deletePolicyByPackage(app.packageName) } + } + + private fun isCoreSystemApp(appInfo: ApplicationInfo): Boolean { + val isSystem = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0 + val isUpdatedSystem = (appInfo.flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) != 0 + return isSystem || isUpdatedSystem } } diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt index 2d5d098..e575365 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt @@ -41,10 +41,11 @@ fun DashboardScreen( viewModel: DashboardViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsState() - val progress = state.quotaFraction + val progress = state.focusScoreFraction val percentUsed = (progress * 100f).roundToInt() val ringTrackColor = MaterialTheme.colorScheme.surfaceVariant val buildLabel = "v${BuildConfig.VERSION_NAME} (${if (BuildConfig.DEBUG) "debug" else "release"})" + val appTimeRemaining = if (state.totalAllowedMs > 0L) formatDuration(state.remainingMs) else "2h 00m" Column( modifier = Modifier @@ -66,7 +67,7 @@ fun DashboardScreen( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Daily Summary", + text = "Focus Hub", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, ) @@ -92,7 +93,7 @@ fun DashboardScreen( contentAlignment = Alignment.Center, ) { Box( - modifier = Modifier.size(150.dp), + modifier = Modifier.size(180.dp), contentAlignment = Alignment.Center, ) { Canvas(modifier = Modifier.matchParentSize()) { @@ -101,14 +102,14 @@ fun DashboardScreen( startAngle = -90f, sweepAngle = 360f, useCenter = false, - style = Stroke(width = 14.dp.toPx(), cap = StrokeCap.Round), + style = Stroke(width = 16.dp.toPx(), cap = StrokeCap.Round), ) } CircularProgressIndicator( progress = { progress }, modifier = Modifier.matchParentSize(), - strokeWidth = 14.dp, + strokeWidth = 16.dp, color = MaterialTheme.colorScheme.primary, trackColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0f), ) @@ -120,7 +121,7 @@ fun DashboardScreen( fontWeight = FontWeight.Bold, ) Text( - text = "Used", + text = "Daily Focus Score", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -129,16 +130,26 @@ fun DashboardScreen( } Text( - text = "${formatDuration(state.totalUsedMs)} of ${formatDuration(state.totalAllowedMs)} consumed", + text = "${state.completedTasks} of ${state.totalTasks} tasks completed", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Text( - text = "${formatDuration(state.remainingMs)} remaining today", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + StatCard( + title = "Completed Tasks", + value = "${state.completedTasks}/${state.totalTasks}", + modifier = Modifier.weight(1f), + ) + StatCard( + title = "App Time Remaining", + value = appTimeRemaining, + modifier = Modifier.weight(1f), + ) + } } } @@ -182,6 +193,33 @@ fun DashboardScreen( } } +@Composable +private fun StatCard( + title: String, + value: String, + modifier: Modifier = Modifier, +) { + ElevatedCard(modifier = modifier) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + } + } +} + private fun formatDuration(ms: Long): String { if (ms <= 0L) return "0m" val hours = ms / 3_600_000L diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardViewModel.kt b/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardViewModel.kt index 370cdee..1736ed7 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardViewModel.kt @@ -23,6 +23,8 @@ import javax.inject.Inject data class DashboardUiState( val isLoading: Boolean = true, val incompleteTasks: List = emptyList(), + val totalTasks: Int = 0, + val completedTasks: Int = 0, val activePolicies: List = emptyList(), /** Total allowed quota in ms across all active policies. */ @@ -37,6 +39,10 @@ data class DashboardUiState( val logicalDateLabel: String = "", val error: String? = null, ) { + /** Fraction [0f, 1f] of tasks completed for today's logical day. */ + val focusScoreFraction: Float + get() = if (totalTasks <= 0) 0f else (completedTasks.toFloat() / totalTasks.toFloat()).coerceIn(0f, 1f) + /** Fraction [0f, 1f] of total quota consumed — drives the progress ring. */ val quotaFraction: Float get() = if (totalAllowedMs <= 0L) 0f @@ -66,6 +72,8 @@ class DashboardViewModel @Inject constructor( ) { tasks, policies, totalUsedMs -> val incompleteTasks = tasks.filter { !it.isCompleted } + val totalTasks = tasks.size + val completedTasks = tasks.count { it.isCompleted } val totalAllowedMs = policies.sumOf { it.allowedQuotaMs } val anyExhausted = policies.any { it.isQuotaExhausted } @@ -75,6 +83,8 @@ class DashboardViewModel @Inject constructor( DashboardUiState( isLoading = false, incompleteTasks = incompleteTasks, + totalTasks = totalTasks, + completedTasks = completedTasks, activePolicies = policies, totalAllowedMs = totalAllowedMs, totalUsedMs = totalUsedMs, diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt index a55d483..1251379 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt @@ -1,5 +1,8 @@ package com.focusblocker.app.ui.screens.tasks +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -16,6 +19,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.Button +import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api @@ -38,8 +42,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -385,8 +392,42 @@ private fun TaskItem( onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { + val completionProgress by animateFloatAsState( + targetValue = if (task.isCompleted) 1f else 0f, + animationSpec = tween(durationMillis = 320), + label = "taskCompletionProgress", + ) + val titleColor by animateColorAsState( + targetValue = lerp( + MaterialTheme.colorScheme.onSurface, + MaterialTheme.colorScheme.onSurfaceVariant, + completionProgress, + ), + animationSpec = tween(durationMillis = 280), + label = "taskTitleColor", + ) + val subTitleColor by animateColorAsState( + targetValue = lerp( + MaterialTheme.colorScheme.onSurfaceVariant, + MaterialTheme.colorScheme.outline, + completionProgress, + ), + animationSpec = tween(durationMillis = 280), + label = "taskSubTitleColor", + ) + val cardColor by animateColorAsState( + targetValue = lerp( + MaterialTheme.colorScheme.surface, + MaterialTheme.colorScheme.surfaceVariant, + completionProgress * 0.65f, + ), + animationSpec = tween(durationMillis = 280), + label = "taskCardColor", + ) + ElevatedCard( modifier = modifier.clickable(onClick = onClick), + colors = CardDefaults.elevatedCardColors(containerColor = cardColor), ) { Row( modifier = Modifier @@ -399,15 +440,19 @@ private fun TaskItem( Text( text = task.title, style = MaterialTheme.typography.bodyLarge, + color = titleColor, + textDecoration = if (task.isCompleted) TextDecoration.LineThrough else TextDecoration.None, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.alpha(1f - (completionProgress * 0.35f)), ) if (task.hasTimeBoundary && task.startTimeMs != null) { Text( text = formatTime(task.startTimeMs), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = subTitleColor, + modifier = Modifier.alpha(1f - (completionProgress * 0.2f)), ) } } From b00c953ea0351f81e295a416dafce8a088fef0f5 Mon Sep 17 00:00:00 2001 From: Muhammed Afnan kP Date: Tue, 31 Mar 2026 04:20:08 +0000 Subject: [PATCH 06/13] Refine premium UI and default app quotas to unlimited --- .../ui/screens/applimits/AppLimitsScreen.kt | 124 +++++++++++++++++- .../screens/applimits/AppLimitsViewModel.kt | 26 +++- .../ui/screens/dashboard/DashboardScreen.kt | 55 ++++++++ .../app/ui/screens/tasks/TaskScreen.kt | 36 ++++- 4 files changed, 231 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsScreen.kt index cbf61e3..bd65d39 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsScreen.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsScreen.kt @@ -12,10 +12,13 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -34,6 +37,8 @@ import androidx.core.graphics.drawable.toBitmap @Composable fun AppLimitsScreen(viewModel: AppLimitsViewModel = hiltViewModel()) { val state by viewModel.uiState.collectAsState() + val totalConfigured = state.apps.count { it.isConfigured } + val totalEnabled = state.apps.count { it.isBlockingEnabled } LazyColumn( modifier = Modifier.fillMaxSize(), @@ -54,6 +59,65 @@ fun AppLimitsScreen(viewModel: AppLimitsViewModel = hiltViewModel()) { ) } + item { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text( + text = "Configured", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = totalConfigured.toString(), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + } + Column { + Text( + text = "Enabled", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = totalEnabled.toString(), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + } + Column { + Text( + text = "Visible Apps", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = state.apps.size.toString(), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } + + item { + OutlinedTextField( + value = state.searchQuery, + onValueChange = viewModel::setSearchQuery, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Search apps") }, + placeholder = { Text("Instagram, YouTube, game...") }, + ) + } + if (state.isLoading) { item { ElevatedCard(modifier = Modifier.fillMaxWidth()) { @@ -81,8 +145,12 @@ fun AppLimitsScreen(viewModel: AppLimitsViewModel = hiltViewModel()) { } items(state.apps, key = { it.packageName }) { app -> + val isUnlimited = app.quotaMs >= UNLIMITED_QUOTA_MS var quotaMinutes by remember(app.packageName, app.quotaMs) { - mutableFloatStateOf((app.quotaMs / 60_000L).coerceIn(10L, 240L).toFloat()) + mutableFloatStateOf( + if (isUnlimited) 240f + else (app.quotaMs / 60_000L).coerceIn(10L, 240L).toFloat(), + ) } ElevatedCard( @@ -130,11 +198,57 @@ fun AppLimitsScreen(viewModel: AppLimitsViewModel = hiltViewModel()) { } Text( - text = "Daily quota: ${quotaMinutes.toInt()} min", + text = if (isUnlimited) "Daily quota: Unlimited" + else "Daily quota: ${quotaMinutes.toInt()} min", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + val usedFraction = if (app.quotaMs <= 0L) 0f + else (app.usedQuotaTodayMs.toFloat() / app.quotaMs.toFloat()).coerceIn(0f, 1f) + LinearProgressIndicator( + progress = { usedFraction }, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = "Used ${formatMinutes(app.usedQuotaTodayMs)} / ${formatMinutes(app.quotaMs)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = { + viewModel.updateQuota( + app = app, + newQuotaMs = UNLIMITED_QUOTA_MS, + ) + }, + ) { + Text("Unlimited") + } + + listOf(30L, 60L, 90L, 120L).forEach { minutes -> + TextButton( + onClick = { + quotaMinutes = minutes.toFloat() + viewModel.updateQuota( + app = app, + newQuotaMs = minutes * 60_000L, + ) + }, + ) { + Text("${minutes}m") + } + } + + if (app.isConfigured) { + TextButton(onClick = { viewModel.removePolicy(app) }) { + Text("Remove") + } + } + } + Slider( value = quotaMinutes, onValueChange = { quotaMinutes = it }, @@ -187,4 +301,10 @@ private fun AppIcon(packageName: String) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) } +} + +private fun formatMinutes(ms: Long): String { + if (ms >= UNLIMITED_QUOTA_MS) return "Unlimited" + val minutes = (ms / 60_000L).coerceAtLeast(0L) + return "${minutes}m" } \ No newline at end of file diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsViewModel.kt b/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsViewModel.kt index 6f5c8fe..d95d4ce 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsViewModel.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsViewModel.kt @@ -19,7 +19,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject -private const val DEFAULT_QUOTA_MS = 30L * 60L * 1_000L +const val UNLIMITED_QUOTA_MS = 31_536_000_000L data class InstalledAppUi( val packageName: String, @@ -33,6 +33,7 @@ data class InstalledAppUi( data class AppLimitsUiState( val isLoading: Boolean = true, val apps: List = emptyList(), + val searchQuery: String = "", val error: String? = null, ) @@ -48,6 +49,7 @@ class AppLimitsViewModel @Inject constructor( ) private val installedApps = MutableStateFlow>(emptyList()) + private val searchQuery = MutableStateFlow("") private val policies: StateFlow> = appPolicyDao.observeAllPolicies() @@ -58,22 +60,34 @@ class AppLimitsViewModel @Inject constructor( ) val uiState: StateFlow = - combine(installedApps, policies) { apps, policyList -> + combine(installedApps, policies, searchQuery) { apps, policyList, query -> val policiesByPackage = policyList.associateBy { it.packageName } val rows = apps.map { app -> val policy = policiesByPackage[app.packageName] InstalledAppUi( packageName = app.packageName, appLabel = app.appLabel, - quotaMs = policy?.allowedQuotaMs ?: DEFAULT_QUOTA_MS, + quotaMs = policy?.allowedQuotaMs ?: UNLIMITED_QUOTA_MS, isBlockingEnabled = policy?.isBlockingEnabled ?: false, usedQuotaTodayMs = policy?.usedQuotaTodayMs ?: 0L, isConfigured = policy != null, ) } + + val normalizedQuery = query.trim().lowercase() + val filteredRows = if (normalizedQuery.isBlank()) { + rows + } else { + rows.filter { row -> + row.appLabel.lowercase().contains(normalizedQuery) || + row.packageName.lowercase().contains(normalizedQuery) + } + } + AppLimitsUiState( isLoading = false, - apps = rows, + apps = filteredRows, + searchQuery = query, ) } .stateIn( @@ -82,6 +96,10 @@ class AppLimitsViewModel @Inject constructor( initialValue = AppLimitsUiState(isLoading = true), ) + fun setSearchQuery(value: String) { + searchQuery.value = value + } + init { refreshInstalledApps() } diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt index e575365..b92d101 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt @@ -150,6 +150,51 @@ fun DashboardScreen( modifier = Modifier.weight(1f), ) } + + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = "Focus Insight", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = focusInsight(state.completedTasks, state.totalTasks), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + } + } + } + } + + if (state.activePolicies.isNotEmpty()) { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = "Active Limits Snapshot", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + state.activePolicies.take(3).forEach { policy -> + val remaining = (policy.allowedQuotaMs - policy.usedQuotaTodayMs).coerceAtLeast(0L) + Text( + text = "${policy.appLabel}: ${formatDuration(remaining)} left", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } } @@ -236,3 +281,13 @@ private fun formatTime(epochMs: Long): String { .atZone(java.time.ZoneId.systemDefault()) return "%02d:%02d".format(dateTime.hour, dateTime.minute) } + +private fun focusInsight(completed: Int, total: Int): String { + if (total <= 0) return "No tasks scheduled yet. Set three focused tasks to define your day." + val ratio = completed.toFloat() / total.toFloat() + return when { + ratio >= 0.85f -> "Elite momentum. Protect your flow and avoid low-value app hops." + ratio >= 0.5f -> "Strong progress. One more deep-work push will lock in a high-focus day." + else -> "Early phase. Start with the hardest task first and keep distraction quotas tight." + } +} diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt index 1251379..bc0eca6 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -56,6 +57,12 @@ import java.time.format.DateTimeFormatter import java.time.temporal.TemporalAdjusters import java.time.ZoneId +private enum class TaskFilter(val label: String) { + ALL("All"), + ACTIVE("Active"), + COMPLETED("Completed"), +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun TaskScreen( @@ -70,6 +77,13 @@ fun TaskScreen( var startTimeInput by remember { mutableStateOf("") } var endTimeInput by remember { mutableStateOf("") } var inputError by remember { mutableStateOf(null) } + var selectedFilter by remember { mutableStateOf(TaskFilter.ALL) } + + val visibleTasks = when (selectedFilter) { + TaskFilter.ALL -> state.tasks + TaskFilter.ACTIVE -> state.tasks.filter { !it.isCompleted } + TaskFilter.COMPLETED -> state.tasks.filter { it.isCompleted } + } Scaffold( floatingActionButton = { @@ -110,7 +124,7 @@ fun TaskScreen( item { Column( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = "Tasks", @@ -126,10 +140,20 @@ fun TaskScreen( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TaskFilter.entries.forEach { filter -> + FilterChip( + selected = selectedFilter == filter, + onClick = { selectedFilter = filter }, + label = { Text(filter.label) }, + ) + } + } } } - if (state.tasks.isEmpty()) { + if (visibleTasks.isEmpty()) { item { ElevatedCard( modifier = Modifier @@ -137,7 +161,11 @@ fun TaskScreen( .padding(horizontal = 16.dp), ) { Text( - text = "No tasks yet. Tap + to add one.", + text = when (selectedFilter) { + TaskFilter.ALL -> "No tasks yet. Tap + to add one." + TaskFilter.ACTIVE -> "No active tasks. You're caught up." + TaskFilter.COMPLETED -> "No completed tasks yet." + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(16.dp), @@ -145,7 +173,7 @@ fun TaskScreen( } } } else { - items(items = state.tasks, key = { it.id }) { task -> + items(items = visibleTasks, key = { it.id }) { task -> val dismissState = rememberSwipeToDismissBoxState( confirmValueChange = { value -> if (value == SwipeToDismissBoxValue.StartToEnd || value == SwipeToDismissBoxValue.EndToStart) { From f5bc7c04f0b8c9f01bf1a0a6edbfde8a2dae01ad Mon Sep 17 00:00:00 2001 From: Muhammed Afnan kP Date: Tue, 31 Mar 2026 15:42:09 +0000 Subject: [PATCH 07/13] Premium UI overhaul, splash/icon branding, motion pass, and blocker engine integration --- app/src/main/AndroidManifest.xml | 20 +- .../com/focusblocker/app/di/DatabaseModule.kt | 8 + .../app/service/AppMonitorService.kt | 191 +++++++++++++++ .../focusblocker/app/ui/FocusBlockerApp.kt | 103 ++++++--- .../focusblocker/app/ui/block/BlockScreen.kt | 60 +++++ .../app/ui/block/BlockScreenActivity.kt | 46 ++++ .../ui/screens/applimits/AppLimitsScreen.kt | 125 +++++++--- .../ui/screens/dashboard/DashboardScreen.kt | 174 +++++++++++--- .../screens/permissions/PermissionsScreen.kt | 218 ++++++++++++++++++ .../app/ui/screens/settings/SettingsScreen.kt | 16 +- .../app/ui/screens/tasks/TaskScreen.kt | 121 +++++++--- .../com/focusblocker/app/ui/theme/Color.kt | 46 ++-- .../com/focusblocker/app/ui/theme/Theme.kt | 87 +++---- .../drawable-v24/ic_launcher_foreground.xml | 52 ++--- .../res/drawable/ic_launcher_background.xml | 196 ++-------------- .../res/drawable/ic_launcher_foreground.xml | 23 ++ .../res/drawable/ic_launcher_monochrome.xml | 23 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 1 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 1 + app/src/main/res/values/colors.xml | 4 + app/src/main/res/values/themes.xml | 13 +- 21 files changed, 1126 insertions(+), 402 deletions(-) create mode 100644 app/src/main/java/com/focusblocker/app/service/AppMonitorService.kt create mode 100644 app/src/main/java/com/focusblocker/app/ui/block/BlockScreen.kt create mode 100644 app/src/main/java/com/focusblocker/app/ui/block/BlockScreenActivity.kt create mode 100644 app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_launcher_monochrome.xml create mode 100644 app/src/main/res/values/colors.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8f5bbc0..a65fc5c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,10 @@ xmlns:tools="http://schemas.android.com/tools"> + + + + + android:theme="@style/Theme.MyApplication.Starting"> + + + + diff --git a/app/src/main/java/com/focusblocker/app/di/DatabaseModule.kt b/app/src/main/java/com/focusblocker/app/di/DatabaseModule.kt index 6cf4844..a50ef7f 100644 --- a/app/src/main/java/com/focusblocker/app/di/DatabaseModule.kt +++ b/app/src/main/java/com/focusblocker/app/di/DatabaseModule.kt @@ -2,6 +2,7 @@ package com.focusblocker.app.di import android.content.Context import android.content.pm.PackageManager +import android.app.usage.UsageStatsManager import androidx.room.Room import com.focusblocker.app.data.local.AppDatabase import com.focusblocker.app.data.local.dao.AppPolicyDao @@ -70,6 +71,13 @@ object DatabaseModule { @ApplicationContext context: Context, ): PackageManager = context.packageManager + @Provides + @Singleton + fun provideUsageStatsManager( + @ApplicationContext context: Context, + ): UsageStatsManager = + context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager + @Provides @Singleton fun provideTaskDao(db: AppDatabase): TaskDao = db.taskDao() diff --git a/app/src/main/java/com/focusblocker/app/service/AppMonitorService.kt b/app/src/main/java/com/focusblocker/app/service/AppMonitorService.kt new file mode 100644 index 0000000..c981943 --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/service/AppMonitorService.kt @@ -0,0 +1,191 @@ +package com.focusblocker.app.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.app.usage.UsageEvents +import android.app.usage.UsageStatsManager +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.focusblocker.app.R +import com.focusblocker.app.data.local.dao.AppPolicyDao +import com.focusblocker.app.security.SecurityManager +import com.focusblocker.app.ui.MainActivity +import com.focusblocker.app.ui.block.BlockScreenActivity +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class AppMonitorService : Service() { + + @Inject + lateinit var appPolicyDao: AppPolicyDao + + @Inject + lateinit var usageStatsManager: UsageStatsManager + + @Inject + lateinit var securityManager: SecurityManager + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var monitorJob: Job? = null + private var lastTickMs: Long = 0L + private var lastBlockLaunchAtMs: Long = 0L + private var lastBlockedPackage: String? = null + + override fun onCreate() { + super.onCreate() + createNotificationChannelIfNeeded() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startForeground(NOTIFICATION_ID, buildForegroundNotification()) + if (monitorJob == null) { + startMonitoringLoop() + } + return START_STICKY + } + + override fun onDestroy() { + monitorJob?.cancel() + monitorJob = null + serviceScope.cancel() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun startMonitoringLoop() { + lastTickMs = System.currentTimeMillis() + monitorJob = serviceScope.launch { + while (isActive) { + val nowMs = System.currentTimeMillis() + val elapsedMs = (nowMs - lastTickMs).coerceIn(0L, 10_000L) + lastTickMs = nowMs + + runCatching { + tick(elapsedMs) + } + + delay(TICK_MS) + } + } + } + + private suspend fun tick(elapsedMs: Long) { + val currentPackage = getCurrentForegroundPackage() ?: return + if (currentPackage == packageName) return + + val policy = appPolicyDao.getPolicyForPackage(currentPackage) ?: return + if (!policy.isBlockingEnabled) return + + val currentDay = securityManager.resolveLogicalDay() + if (policy.quotaDateEpochDay != currentDay) { + appPolicyDao.resetQuotaForPackage(currentPackage, currentDay) + } + + if (elapsedMs > 0L) { + appPolicyDao.accumulateUsage( + packageName = currentPackage, + additionalMs = elapsedMs, + currentEpochDay = currentDay, + ) + } + + if (appPolicyDao.isQuotaExhausted(currentPackage)) { + showBlockScreen(packageName = currentPackage, appLabel = policy.appLabel) + } + } + + private fun getCurrentForegroundPackage(): String? { + val end = System.currentTimeMillis() + val start = end - FOREGROUND_LOOKBACK_MS + val events = usageStatsManager.queryEvents(start, end) + val event = UsageEvents.Event() + + var foregroundPackage: String? = null + while (events.hasNextEvent()) { + events.getNextEvent(event) + val isResumeEvent = event.eventType == UsageEvents.Event.ACTIVITY_RESUMED || + event.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND + if (isResumeEvent && !event.packageName.isNullOrBlank()) { + foregroundPackage = event.packageName + } + } + return foregroundPackage + } + + private fun showBlockScreen(packageName: String, appLabel: String) { + val now = System.currentTimeMillis() + val recentlyBlockedSamePackage = + lastBlockedPackage == packageName && (now - lastBlockLaunchAtMs) < BLOCK_LAUNCH_COOLDOWN_MS + if (recentlyBlockedSamePackage) return + + lastBlockedPackage = packageName + lastBlockLaunchAtMs = now + + val intent = Intent(this, BlockScreenActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(BlockScreenActivity.EXTRA_BLOCKED_PACKAGE, packageName) + putExtra(BlockScreenActivity.EXTRA_BLOCKED_APP_LABEL, appLabel) + } + startActivity(intent) + } + + private fun createNotificationChannelIfNeeded() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val manager = getSystemService(NotificationManager::class.java) + val existing = manager.getNotificationChannel(CHANNEL_ID) + if (existing != null) return + + val channel = NotificationChannel( + CHANNEL_ID, + "Focus Blocker", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Monitors foreground app usage for Focus Blocker policies." + } + manager.createNotificationChannel(channel) + } + + private fun buildForegroundNotification(): Notification { + val contentIntent = PendingIntent.getActivity( + this, + 1001, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle("Focus Blocker") + .setContentText("Focus Blocker is actively running") + .setOngoing(true) + .setOnlyAlertOnce(true) + .setContentIntent(contentIntent) + .build() + } + + companion object { + private const val CHANNEL_ID = "focus_blocker_monitor_channel" + private const val NOTIFICATION_ID = 42021 + private const val TICK_MS = 2_000L + private const val FOREGROUND_LOOKBACK_MS = 12_000L + private const val BLOCK_LAUNCH_COOLDOWN_MS = 4_000L + } +} diff --git a/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt b/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt index ec3915d..5dc1062 100644 --- a/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt +++ b/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt @@ -3,8 +3,11 @@ package com.focusblocker.app.ui import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.List @@ -18,6 +21,7 @@ import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -33,6 +37,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.focusblocker.app.ui.screens.applimits.AppLimitsScreen import com.focusblocker.app.ui.screens.dashboard.DashboardScreen +import com.focusblocker.app.ui.screens.permissions.PermissionsScreen import com.focusblocker.app.ui.screens.settings.SettingsScreen import com.focusblocker.app.ui.screens.tasks.TaskScreen import com.focusblocker.app.ui.theme.Surface800 @@ -40,10 +45,11 @@ import com.focusblocker.app.ui.theme.Surface800 // ── Route constants ─────────────────────────────────────────────────────────── object Routes { - const val DASHBOARD = "dashboard" - const val TASKS = "tasks" - const val APP_LIMITS = "app_limits" - const val SETTINGS = "settings" + const val DASHBOARD = "dashboard" + const val TASKS = "tasks" + const val APP_LIMITS = "app_limits" + const val SETTINGS = "settings" + const val PERMISSIONS = "permissions" } // ── Bottom nav descriptor ───────────────────────────────────────────────────── @@ -111,16 +117,39 @@ fun FocusBlockerApp() { modifier = Modifier .fillMaxSize() .padding(innerPadding), - enterTransition = { fadeIn(tween(220)) }, - exitTransition = { fadeOut(tween(180)) }, - popEnterTransition = { fadeIn(tween(220)) }, - popExitTransition = { fadeOut(tween(180)) }, + enterTransition = { + slideInHorizontally( + animationSpec = tween(240), + initialOffsetX = { fullWidth -> fullWidth / 18 }, + ) + fadeIn(tween(220)) + }, + exitTransition = { + slideOutHorizontally( + animationSpec = tween(200), + targetOffsetX = { fullWidth -> -fullWidth / 24 }, + ) + fadeOut(tween(180)) + }, + popEnterTransition = { + slideInHorizontally( + animationSpec = tween(220), + initialOffsetX = { fullWidth -> -fullWidth / 24 }, + ) + fadeIn(tween(200)) + }, + popExitTransition = { + slideOutHorizontally( + animationSpec = tween(180), + targetOffsetX = { fullWidth -> fullWidth / 18 }, + ) + fadeOut(tween(160)) + }, ) { composable(Routes.DASHBOARD) { DashboardScreen( onOpenSettings = { navController.navigate(Routes.SETTINGS) }, + onOpenPermissions = { + navController.navigate(Routes.PERMISSIONS) + }, ) } composable(Routes.TASKS) { @@ -134,6 +163,11 @@ fun FocusBlockerApp() { onNavigateBack = { navController.navigateUp() }, ) } + composable(Routes.PERMISSIONS) { + PermissionsScreen( + onNavigateBack = { navController.navigateUp() }, + ) + } } } } @@ -146,30 +180,37 @@ private fun FocusNavBar( currentDest: androidx.navigation.NavDestination?, onDestSelected: (TopLevelDest) -> Unit, ) { - NavigationBar( - containerColor = Surface800, - tonalElevation = 0.dp, // flat — no tonal overlay on OLED + Surface( + color = Surface800, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp), ) { - destinations.forEach { dest -> - val selected = currentDest?.hierarchy?.any { it.route == dest.route } == true - NavigationBarItem( - selected = selected, - onClick = { onDestSelected(dest) }, - icon = { - Icon( - imageVector = if (selected) dest.selectedIcon else dest.unselectedIcon, - contentDescription = dest.label, - ) - }, - label = { Text(dest.label, style = MaterialTheme.typography.labelMedium) }, - colors = NavigationBarItemDefaults.colors( - selectedIconColor = MaterialTheme.colorScheme.primary, - selectedTextColor = MaterialTheme.colorScheme.primary, - unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, - indicatorColor = MaterialTheme.colorScheme.primaryContainer, - ), - ) + NavigationBar( + containerColor = Color.Transparent, + tonalElevation = 0.dp, + ) { + destinations.forEach { dest -> + val selected = currentDest?.hierarchy?.any { it.route == dest.route } == true + NavigationBarItem( + selected = selected, + onClick = { onDestSelected(dest) }, + icon = { + Icon( + imageVector = if (selected) dest.selectedIcon else dest.unselectedIcon, + contentDescription = dest.label, + ) + }, + label = { Text(dest.label, style = MaterialTheme.typography.labelMedium) }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.primary, + selectedTextColor = MaterialTheme.colorScheme.primary, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + indicatorColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/focusblocker/app/ui/block/BlockScreen.kt b/app/src/main/java/com/focusblocker/app/ui/block/BlockScreen.kt new file mode 100644 index 0000000..b8e278d --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/block/BlockScreen.kt @@ -0,0 +1,60 @@ +package com.focusblocker.app.ui.block + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun BlockScreen( + appLabel: String, + onGoHome: () -> Unit, +) { + val title = if (appLabel.isBlank()) { + "Deep Work Active. This app is blocked." + } else { + "Deep Work Active. $appLabel is blocked." + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + + Text( + text = "Complete your planned tasks first to unlock intentional app access.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 12.dp, bottom = 20.dp), + ) + + Button( + onClick = onGoHome, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Go To Home Screen") + } + } +} diff --git a/app/src/main/java/com/focusblocker/app/ui/block/BlockScreenActivity.kt b/app/src/main/java/com/focusblocker/app/ui/block/BlockScreenActivity.kt new file mode 100644 index 0000000..0de0ab8 --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/block/BlockScreenActivity.kt @@ -0,0 +1,46 @@ +package com.focusblocker.app.ui.block + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.focusblocker.app.ui.theme.MyApplicationTheme + +class BlockScreenActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + } + + val appLabel = intent.getStringExtra(EXTRA_BLOCKED_APP_LABEL).orEmpty() + + setContent { + MyApplicationTheme { + BlockScreen( + appLabel = appLabel, + onGoHome = { + startActivity( + Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_HOME) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }, + ) + finish() + }, + ) + } + } + } + + companion object { + const val EXTRA_BLOCKED_PACKAGE = "extra_blocked_package" + const val EXTRA_BLOCKED_APP_LABEL = "extra_blocked_app_label" + } +} diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsScreen.kt index bd65d39..42da868 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsScreen.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsScreen.kt @@ -1,6 +1,12 @@ package com.focusblocker.app.ui.screens.applimits +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -10,6 +16,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.LinearProgressIndicator @@ -20,12 +27,15 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -33,40 +43,82 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.compose.ui.platform.LocalContext import androidx.core.graphics.drawable.toBitmap +import kotlinx.coroutines.delay @Composable fun AppLimitsScreen(viewModel: AppLimitsViewModel = hiltViewModel()) { val state by viewModel.uiState.collectAsState() val totalConfigured = state.apps.count { it.isConfigured } val totalEnabled = state.apps.count { it.isBlockingEnabled } + var showTitle by remember { mutableStateOf(false) } + var showStats by remember { mutableStateOf(false) } + var showSearch by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + showTitle = true + delay(60) + showStats = true + delay(60) + showSearch = true + } LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { item { - Text( - text = "App Selection Studio", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - ) - Text( - text = "Choose your distraction apps and dial in strict daily quotas.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) + AnimatedVisibility( + visible = showTitle, + enter = fadeIn(animationSpec = tween(220)) + + slideInVertically( + animationSpec = tween(240), + initialOffsetY = { fullHeight -> fullHeight / 18 }, + ), + ) { + Column { + Text( + text = "App Selection Studio", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Choose your distraction apps and dial in strict daily quotas.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + } } item { - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, + AnimatedVisibility( + visible = showStats, + enter = fadeIn(animationSpec = tween(220)) + + slideInVertically( + animationSpec = tween(240), + initialOffsetY = { fullHeight -> fullHeight / 18 }, + ), + ) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + brush = Brush.horizontalGradient( + colors = listOf( + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.28f), + MaterialTheme.colorScheme.surface, + ), + ), + ) + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { Column { Text( text = "Configured", @@ -103,19 +155,29 @@ fun AppLimitsScreen(viewModel: AppLimitsViewModel = hiltViewModel()) { fontWeight = FontWeight.SemiBold, ) } + } } } } item { - OutlinedTextField( - value = state.searchQuery, - onValueChange = viewModel::setSearchQuery, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - label = { Text("Search apps") }, - placeholder = { Text("Instagram, YouTube, game...") }, - ) + AnimatedVisibility( + visible = showSearch, + enter = fadeIn(animationSpec = tween(220)) + + slideInVertically( + animationSpec = tween(240), + initialOffsetY = { fullHeight -> fullHeight / 18 }, + ), + ) { + OutlinedTextField( + value = state.searchQuery, + onValueChange = viewModel::setSearchQuery, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Search apps") }, + placeholder = { Text("Instagram, YouTube, game...") }, + ) + } } if (state.isLoading) { @@ -154,7 +216,10 @@ fun AppLimitsScreen(viewModel: AppLimitsViewModel = hiltViewModel()) { } ElevatedCard( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .animateContentSize(animationSpec = tween(180)), + shape = RoundedCornerShape(14.dp), colors = CardDefaults.elevatedCardColors( containerColor = if (app.isBlockingEnabled) { MaterialTheme.colorScheme.surface @@ -166,8 +231,8 @@ fun AppLimitsScreen(viewModel: AppLimitsViewModel = hiltViewModel()) { Column( modifier = Modifier .fillMaxWidth() - .padding(14.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { Row( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt index b92d101..86a77cd 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt @@ -1,6 +1,11 @@ package com.focusblocker.app.ui.screens.dashboard +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -12,9 +17,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon @@ -22,10 +30,15 @@ import androidx.compose.material3.IconButton 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 +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.text.font.FontWeight @@ -33,11 +46,13 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.focusblocker.app.BuildConfig +import kotlinx.coroutines.delay import kotlin.math.roundToInt @Composable fun DashboardScreen( onOpenSettings: () -> Unit = {}, + onOpenPermissions: () -> Unit = {}, viewModel: DashboardViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsState() @@ -46,21 +61,54 @@ fun DashboardScreen( val ringTrackColor = MaterialTheme.colorScheme.surfaceVariant val buildLabel = "v${BuildConfig.VERSION_NAME} (${if (BuildConfig.DEBUG) "debug" else "release"})" val appTimeRemaining = if (state.totalAllowedMs > 0L) formatDuration(state.remainingMs) else "2h 00m" + var showHero by remember { mutableStateOf(false) } + var showEngine by remember { mutableStateOf(false) } + var showLimits by remember { mutableStateOf(false) } + var showTasks by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + showHero = true + delay(70) + showEngine = true + delay(70) + showLimits = true + delay(60) + showTasks = true + } Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + .padding(horizontal = 16.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { - ElevatedCard( - modifier = Modifier.fillMaxWidth(), + AnimatedVisibility( + visible = showHero, + enter = fadeIn(animationSpec = tween(260)) + + slideInVertically( + animationSpec = tween(280), + initialOffsetY = { fullHeight -> fullHeight / 14 }, + ), ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.14f), + MaterialTheme.colorScheme.surface, + ), + ), + ) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -170,11 +218,65 @@ fun DashboardScreen( ) } } + } } } - if (state.activePolicies.isNotEmpty()) { - ElevatedCard(modifier = Modifier.fillMaxWidth()) { + AnimatedVisibility( + visible = showEngine, + enter = fadeIn(animationSpec = tween(230)) + + slideInVertically( + animationSpec = tween(250), + initialOffsetY = { fullHeight -> fullHeight / 16 }, + ), + ) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.35f), + MaterialTheme.colorScheme.surface, + ), + ), + ) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "Deep Work Engine", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Enable real-time blocking and keep distraction apps under control.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = onOpenPermissions, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text("Start Blocker Engine") + } + } + } + } + + if (showLimits && state.activePolicies.isNotEmpty()) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) { Column( modifier = Modifier .fillMaxWidth() @@ -198,24 +300,39 @@ fun DashboardScreen( } } - Text( - text = "Pending Tasks for Today", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - - if (state.incompleteTasks.isEmpty()) { - ElevatedCard(modifier = Modifier.fillMaxWidth()) { + AnimatedVisibility( + visible = showTasks, + enter = fadeIn(animationSpec = tween(220)) + + slideInVertically( + animationSpec = tween(240), + initialOffsetY = { fullHeight -> fullHeight / 18 }, + ), + ) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( - text = "No pending tasks. You are clear for deep work.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(16.dp), + text = "Pending Tasks for Today", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, ) - } - } else { - state.incompleteTasks.forEach { task -> - ElevatedCard(modifier = Modifier.fillMaxWidth()) { + + if (state.incompleteTasks.isEmpty()) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + text = "No pending tasks. You are clear for deep work.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } + } else { + state.incompleteTasks.forEach { task -> + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + ) { Column(modifier = Modifier.padding(14.dp)) { Text( text = task.title, @@ -233,6 +350,8 @@ fun DashboardScreen( } } } + } + } } } } @@ -244,7 +363,10 @@ private fun StatCard( value: String, modifier: Modifier = Modifier, ) { - ElevatedCard(modifier = modifier) { + ElevatedCard( + modifier = modifier, + shape = RoundedCornerShape(14.dp), + ) { Column( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt new file mode 100644 index 0000000..f9f66b8 --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt @@ -0,0 +1,218 @@ +package com.focusblocker.app.ui.screens.permissions + +import android.app.AppOpsManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Process +import android.provider.Settings +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import android.Manifest +import android.content.pm.PackageManager +import android.net.Uri +import android.util.Log +import com.focusblocker.app.service.AppMonitorService + +data class PermissionGatewayState( + val hasUsageAccess: Boolean, + val hasOverlayPermission: Boolean, + val hasNotificationPermission: Boolean, +) { + val allRequiredGranted: Boolean + get() = hasUsageAccess && hasOverlayPermission && hasNotificationPermission +} + +@Composable +fun PermissionsScreen( + onNavigateBack: () -> Unit = {}, + onAllPermissionsGranted: () -> Unit = {}, +) { + val context = LocalContext.current + var state by remember { mutableStateOf(readPermissionGatewayState(context)) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + text = "Permission Gateway", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + + Text( + text = "Grant these permissions to run real-time app blocking.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + PermissionCard( + title = "Usage Access", + granted = state.hasUsageAccess, + buttonText = "Open Usage Access Settings", + onClick = { + context.startActivity( + Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }, + ) + }, + ) + + PermissionCard( + title = "Overlay Permission", + granted = state.hasOverlayPermission, + buttonText = "Open Overlay Settings", + onClick = { + context.startActivity( + Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:${context.packageName}"), + ).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }, + ) + }, + ) + + PermissionCard( + title = "Notifications", + granted = state.hasNotificationPermission, + buttonText = "Open Notification Settings", + onClick = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.startActivity( + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }, + ) + } + }, + isButtonEnabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU, + ) + + Button( + onClick = { + state = readPermissionGatewayState(context) + if (state.allRequiredGranted) { + Log.d("PermissionsScreen", "All permissions granted. Starting AppMonitorService...") + try { + ContextCompat.startForegroundService( + context, + Intent(context, AppMonitorService::class.java), + ) + Log.d("PermissionsScreen", "AppMonitorService start requested") + } catch (e: Exception) { + Log.e("PermissionsScreen", "Failed to start AppMonitorService: ${e.message}") + } + onAllPermissionsGranted() + onNavigateBack() + } + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text("I Granted Permissions") + } + } +} + +@Composable +private fun PermissionCard( + title: String, + granted: Boolean, + buttonText: String, + onClick: () -> Unit, + isButtonEnabled: Boolean = true, +) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = if (granted) "Granted" else "Missing", + style = MaterialTheme.typography.bodySmall, + color = if (granted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + ) + Button( + onClick = onClick, + enabled = isButtonEnabled, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + ) { + Text(buttonText) + } + } + } +} + +fun readPermissionGatewayState(context: Context): PermissionGatewayState = + PermissionGatewayState( + hasUsageAccess = hasUsageAccessPermission(context), + hasOverlayPermission = Settings.canDrawOverlays(context), + hasNotificationPermission = hasNotificationPermission(context), + ) + +fun hasUsageAccessPermission(context: Context): Boolean { + val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager + val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + appOps.unsafeCheckOpNoThrow( + AppOpsManager.OPSTR_GET_USAGE_STATS, + Process.myUid(), + context.packageName, + ) + } else { + @Suppress("DEPRECATION") + appOps.checkOpNoThrow( + AppOpsManager.OPSTR_GET_USAGE_STATS, + Process.myUid(), + context.packageName, + ) + } + return mode == AppOpsManager.MODE_ALLOWED +} + +fun hasNotificationPermission(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED +} diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/settings/SettingsScreen.kt index d4244ab..60421e8 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/settings/SettingsScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.AssistChip @@ -54,10 +55,13 @@ fun SettingsScreen( modifier = Modifier .fillMaxSize() .padding(innerPadding) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + .padding(horizontal = 16.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { - ElevatedCard(modifier = Modifier.fillMaxWidth()) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) { Column( modifier = Modifier .fillMaxWidth() @@ -87,6 +91,7 @@ fun SettingsScreen( AssistChip( onClick = { viewModel.setDayStartHour(hour) }, label = { Text(formatHourLabel(hour)) }, + shape = RoundedCornerShape(10.dp), leadingIcon = if (selected) { { Text( @@ -103,7 +108,10 @@ fun SettingsScreen( } } - ElevatedCard(modifier = Modifier.fillMaxWidth()) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + ) { Row( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt index bc0eca6..bd65221 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt @@ -1,6 +1,10 @@ package com.focusblocker.app.ui.screens.tasks import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable @@ -15,6 +19,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete @@ -36,6 +41,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberSwipeToDismissBoxState 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 @@ -52,6 +58,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.focusblocker.app.data.local.entity.Task +import kotlinx.coroutines.delay import java.time.LocalDate import java.time.format.DateTimeFormatter import java.time.temporal.TemporalAdjusters @@ -78,6 +85,14 @@ fun TaskScreen( var endTimeInput by remember { mutableStateOf("") } var inputError by remember { mutableStateOf(null) } var selectedFilter by remember { mutableStateOf(TaskFilter.ALL) } + var showCalendar by remember { mutableStateOf(false) } + var showHeader by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + showCalendar = true + delay(60) + showHeader = true + } val visibleTasks = when (selectedFilter) { TaskFilter.ALL -> state.tasks @@ -86,6 +101,7 @@ fun TaskScreen( } Scaffold( + containerColor = MaterialTheme.colorScheme.background, floatingActionButton = { FloatingActionButton( onClick = { @@ -96,6 +112,8 @@ fun TaskScreen( inputError = null showTaskSheet = true }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, ) { Icon( imageVector = Icons.Filled.Add, @@ -108,46 +126,65 @@ fun TaskScreen( modifier = Modifier .fillMaxSize() .padding(innerPadding), - verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { item { - WeeklyCalendarStrip( - selectedDate = selectedDate, - onDaySelected = { date -> viewModel.selectDay(date.toEpochDay()) }, - onTodayClick = { viewModel.resetToLogicalToday() }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - ) + AnimatedVisibility( + visible = showCalendar, + enter = fadeIn(animationSpec = tween(220)) + + slideInVertically( + animationSpec = tween(240), + initialOffsetY = { fullHeight -> fullHeight / 16 }, + ), + ) { + WeeklyCalendarStrip( + selectedDate = selectedDate, + onDaySelected = { date -> viewModel.selectDay(date.toEpochDay()) }, + onTodayClick = { viewModel.resetToLogicalToday() }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) + } } item { - Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + AnimatedVisibility( + visible = showHeader, + enter = fadeIn(animationSpec = tween(220)) + + slideInVertically( + animationSpec = tween(240), + initialOffsetY = { fullHeight -> fullHeight / 18 }, + ), ) { - Text( - text = "Tasks", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - ) - Text( - text = if (state.allComplete && state.tasks.isNotEmpty()) { - "All tasks complete. Keep your focus momentum." - } else { - "${state.tasks.count { !it.isCompleted }} pending for today" - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Tasks", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = if (state.allComplete && state.tasks.isNotEmpty()) { + "All tasks complete. Keep your focus momentum." + } else { + "${state.tasks.count { !it.isCompleted }} pending for today" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TaskFilter.entries.forEach { filter -> - FilterChip( - selected = selectedFilter == filter, - onClick = { selectedFilter = filter }, - label = { Text(filter.label) }, - ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TaskFilter.entries.forEach { filter -> + FilterChip( + selected = selectedFilter == filter, + onClick = { selectedFilter = filter }, + label = { Text(filter.label) }, + ) + } } } } @@ -159,6 +196,7 @@ fun TaskScreen( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), + shape = RoundedCornerShape(14.dp), ) { Text( text = when (selectedFilter) { @@ -387,6 +425,14 @@ private fun WeeklyCalendarStrip( ElevatedCard( modifier = Modifier .clickable { onDaySelected(day) }, + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.elevatedCardColors( + containerColor = if (selected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f) + } else { + MaterialTheme.colorScheme.surface + }, + ), ) { Column( modifier = Modifier @@ -454,15 +500,18 @@ private fun TaskItem( ) ElevatedCard( - modifier = modifier.clickable(onClick = onClick), + modifier = modifier + .animateContentSize(animationSpec = tween(180)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(14.dp), colors = CardDefaults.elevatedCardColors(containerColor = cardColor), ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 10.dp), + .padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Column(modifier = Modifier.weight(1f)) { Text( diff --git a/app/src/main/java/com/focusblocker/app/ui/theme/Color.kt b/app/src/main/java/com/focusblocker/app/ui/theme/Color.kt index de968c2..95569ee 100644 --- a/app/src/main/java/com/focusblocker/app/ui/theme/Color.kt +++ b/app/src/main/java/com/focusblocker/app/ui/theme/Color.kt @@ -11,35 +11,47 @@ val PurpleGrey40 = Color(0xFF625B71) val Pink40 = Color(0xFF7D5260) // ── Brand palette ───────────────────────────────────────────────────────────── -// Deep Work aesthetic: OLED-safe pitch-black backgrounds, sharp indigo primary, -// electric pink/teal accents that cut through the dark without being garish. +// Premium deep work aesthetic: OLED-safe pitch-black backgrounds, vibrant indigo primary, +// electric pink/teal accents, with sophisticated elevation layers and premium depth. -// Indigo / Focus primary +// Indigo / Focus primary ─ enhanced with premium tones +val Indigo150 = Color(0xFFC9C0FF) val Indigo200 = Color(0xFFB0ABFF) +val Indigo300 = Color(0xFF9692FF) val Indigo400 = Color(0xFF7B6FFF) val Indigo500 = Color(0xFF6157F5) val Indigo600 = Color(0xFF4E44D6) +val Indigo700 = Color(0xFF3D37B3) val Indigo900 = Color(0xFF1A1640) -// Electric pink / action accent +// Electric pink / action accent ─ vibrant and professional +val Pink250 = Color(0xFFFF8FD4) val Pink300 = Color(0xFFFF6BB5) val Pink400 = Color(0xFFFF4FA0) val Pink500 = Color(0xFFE83585) +val Pink600 = Color(0xFFD61F70) -// Teal / secondary accent +// Teal / secondary accent ─ crisp and energetic +val Teal250 = Color(0xFF68E8DC) val Teal300 = Color(0xFF4DDEC8) val Teal400 = Color(0xFF1EC8B0) val Teal500 = Color(0xFF00A896) +val Teal600 = Color(0xFF008B7C) -// OLED dark surfaces +// Premium OLED dark surfaces with enhanced elevation hierarchy val Black = Color(0xFF000000) -val Surface900 = Color(0xFF0D0D0F) -val Surface800 = Color(0xFF121212) -val Surface700 = Color(0xFF1C1C22) -val Surface600 = Color(0xFF242430) -val Surface500 = Color(0xFF2E2E3C) +val Surface950 = Color(0xFF0A0A0C) // Deepest layer +val Surface900 = Color(0xFF0D0D0F) // Very deep +val Surface850 = Color(0xFF121214) // Enhanced mid-deep +val Surface800 = Color(0xFF121212) // Deep surface +val Surface750 = Color(0xFF171719) // Enhanced mid +val Surface700 = Color(0xFF1C1C22) // Mid surface +val Surface650 = Color(0xFF22222A) // Enhanced mid-light +val Surface600 = Color(0xFF242430) // Light surface +val Surface500 = Color(0xFF2E2E3C) // Outline layer +val Surface400 = Color(0xFF38384A) // Additional contrast layer -// Semantic +// Premium semantic colors val WarningAmber = Color(0xFFFFB830) val WarningAmber2 = Color(0xFFFFF0C2) val SuccessGreen = Color(0xFF34D399) @@ -47,7 +59,9 @@ val SuccessGreen2 = Color(0xFFD1FAE5) val ErrorRed = Color(0xFFFF5370) val ErrorRed2 = Color(0xFFFFE4E8) -// Text -val TextPrimary = Color(0xFFF2F2F8) -val TextSecondary = Color(0xFF9898B0) -val TextDisabled = Color(0xFF4A4A60) \ No newline at end of file +// Premium text hierarchy +val TextPrimary = Color(0xFFF2F2F8) +val TextSecondary = Color(0xFF9898B0) +val TextTertiary = Color(0xFF6D6D7F) +val TextDisabled = Color(0xFF4A4A60) +val TextInverted = Color(0xFF0A0A0C) \ No newline at end of file diff --git a/app/src/main/java/com/focusblocker/app/ui/theme/Theme.kt b/app/src/main/java/com/focusblocker/app/ui/theme/Theme.kt index e92dc30..721305c 100644 --- a/app/src/main/java/com/focusblocker/app/ui/theme/Theme.kt +++ b/app/src/main/java/com/focusblocker/app/ui/theme/Theme.kt @@ -10,36 +10,43 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -// ── Dark scheme (primary target — OLED optimised) ───────────────────────────── +// ── Dark scheme (primary target — OLED optimised + premium) ───────────────────────────── private val DarkColorScheme = darkColorScheme( + // Primary indigo — vibrant and professional primary = Indigo400, onPrimary = Color.White, primaryContainer = Indigo900, onPrimaryContainer = Indigo200, + // Secondary pink — energetic action secondary = Pink400, onSecondary = Color.White, secondaryContainer = Color(0xFF3D1A2E), onSecondaryContainer= Pink300, + // Tertiary teal — crisp accent tertiary = Teal400, onTertiary = Color.Black, tertiaryContainer = Color(0xFF003C35), onTertiaryContainer = Teal300, + // Premium dark background background = Black, onBackground = TextPrimary, - surface = Surface900, + // Enhanced surface depth hierarchy + surface = Surface850, onSurface = TextPrimary, surfaceVariant = Surface700, onSurfaceVariant = TextSecondary, surfaceTint = Indigo400, + // Sophisticated outline layers outline = Surface500, outlineVariant = Surface600, + // Premium error states error = ErrorRed, onError = Color.White, errorContainer = Color(0xFF3D0A14), @@ -73,80 +80,80 @@ private val LightColorScheme = lightColorScheme( // ── Typography ──────────────────────────────────────────────────────────────── // System sans-serif only — no custom font downloads (offline-only app). -// Tightly controlled weight/size scale for the "mission control" aesthetic. +// Premium hierarchy: Carefully tuned weights, sizes, and line heights for modern elegance. private val FocusTypography = Typography( - // Screen section headers + // Screen section headers — bold, commanding presence headlineLarge = TextStyle( - fontWeight = FontWeight.W700, - fontSize = 28.sp, - lineHeight = 34.sp, + fontWeight = FontWeight.W800, + fontSize = 32.sp, + lineHeight = 40.sp, letterSpacing = (-0.5).sp, ), headlineMedium = TextStyle( - fontWeight = FontWeight.W600, - fontSize = 22.sp, - lineHeight = 28.sp, + fontWeight = FontWeight.W700, + fontSize = 24.sp, + lineHeight = 32.sp, letterSpacing = (-0.3).sp, ), headlineSmall = TextStyle( - fontWeight = FontWeight.W600, - fontSize = 18.sp, - lineHeight = 24.sp, + fontWeight = FontWeight.W700, + fontSize = 20.sp, + lineHeight = 28.sp, letterSpacing = (-0.2).sp, ), - // Card titles, section labels + // Card titles, section labels — semi-bold authority titleLarge = TextStyle( - fontWeight = FontWeight.W600, - fontSize = 16.sp, - lineHeight = 22.sp, + fontWeight = FontWeight.W700, + fontSize = 17.sp, + lineHeight = 24.sp, letterSpacing = 0.sp, ), titleMedium = TextStyle( - fontWeight = FontWeight.W500, - fontSize = 14.sp, - lineHeight = 20.sp, + fontWeight = FontWeight.W600, + fontSize = 15.sp, + lineHeight = 22.sp, letterSpacing = 0.1.sp, ), titleSmall = TextStyle( - fontWeight = FontWeight.W500, - fontSize = 13.sp, - lineHeight = 18.sp, + fontWeight = FontWeight.W600, + fontSize = 14.sp, + lineHeight = 20.sp, letterSpacing = 0.1.sp, ), - // Body text + // Body text — comfortable readability bodyLarge = TextStyle( - fontWeight = FontWeight.W400, - fontSize = 15.sp, - lineHeight = 22.sp, + fontWeight = FontWeight.W500, + fontSize = 16.sp, + lineHeight = 24.sp, ), bodyMedium = TextStyle( fontWeight = FontWeight.W400, - fontSize = 13.sp, - lineHeight = 19.sp, + fontSize = 14.sp, + lineHeight = 21.sp, ), bodySmall = TextStyle( fontWeight = FontWeight.W400, fontSize = 12.sp, - lineHeight = 17.sp, + lineHeight = 18.sp, letterSpacing = 0.2.sp, ), - // Labels, chips, badges + // Labels, chips, badges — punchy and clear labelLarge = TextStyle( - fontWeight = FontWeight.W600, - fontSize = 13.sp, - lineHeight = 18.sp, + fontWeight = FontWeight.W700, + fontSize = 14.sp, + lineHeight = 20.sp, letterSpacing = 0.4.sp, ), labelMedium = TextStyle( - fontWeight = FontWeight.W500, - fontSize = 11.sp, - lineHeight = 16.sp, + fontWeight = FontWeight.W600, + fontSize = 12.sp, + lineHeight = 17.sp, letterSpacing = 0.5.sp, ), labelSmall = TextStyle( - fontWeight = FontWeight.W500, - fontSize = 10.sp, - lineHeight = 14.sp, + fontWeight = FontWeight.W600, + fontSize = 11.sp, + lineHeight = 16.sp, letterSpacing = 0.6.sp, ), ) diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml index 636165b..713ffe7 100644 --- a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -1,46 +1,22 @@ - - - - - - - - - - + + + + android:pathData="M32,49h30v10h-30z" /> + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index eed310a..cd09c5a 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,186 +1,24 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..08a2740 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..c69686f --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 3b1c337..54081cb 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -18,4 +18,5 @@ + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 3b1c337..54081cb 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -18,4 +18,5 @@ + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..ca6eb6b --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #0D0D12 + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index c4b2b43..cc93b29 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -17,5 +17,16 @@ - + + From 828619868fb7b15f5f1d66c6ec746ff5de5c1008 Mon Sep 17 00:00:00 2001 From: Muhammed Afnan kP Date: Tue, 31 Mar 2026 16:11:27 +0000 Subject: [PATCH 08/13] Implement boot recovery and battery optimization features --- app/src/main/AndroidManifest.xml | 12 ++++++++++ .../focusblocker/app/service/BootReceiver.kt | 18 +++++++++++++++ .../screens/permissions/PermissionsScreen.kt | 22 ++++++++++++++++++- .../com/focusblocker/app/util/BatteryUtils.kt | 18 +++++++++++++++ 4 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/focusblocker/app/service/BootReceiver.kt create mode 100644 app/src/main/java/com/focusblocker/app/util/BatteryUtils.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a65fc5c..a231943 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,6 +23,8 @@ + + + + + + + + + diff --git a/app/src/main/java/com/focusblocker/app/service/BootReceiver.kt b/app/src/main/java/com/focusblocker/app/service/BootReceiver.kt new file mode 100644 index 0000000..97eae84 --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/service/BootReceiver.kt @@ -0,0 +1,18 @@ +package com.focusblocker.app.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat + +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (context == null || intent == null) return + + val action = intent.action + if (action == Intent.ACTION_BOOT_COMPLETED || action == Intent.ACTION_MY_PACKAGE_REPLACED) { + val serviceIntent = Intent(context, AppMonitorService::class.java) + ContextCompat.startForegroundService(context, serviceIntent) + } + } +} diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt index f9f66b8..f49acf9 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt @@ -32,14 +32,16 @@ import android.content.pm.PackageManager import android.net.Uri import android.util.Log import com.focusblocker.app.service.AppMonitorService +import com.focusblocker.app.util.isIgnoringBatteryOptimizations data class PermissionGatewayState( val hasUsageAccess: Boolean, val hasOverlayPermission: Boolean, val hasNotificationPermission: Boolean, + val isIgnoringBatteryOptimizations: Boolean, ) { val allRequiredGranted: Boolean - get() = hasUsageAccess && hasOverlayPermission && hasNotificationPermission + get() = hasUsageAccess && hasOverlayPermission && hasNotificationPermission && isIgnoringBatteryOptimizations } @Composable @@ -114,6 +116,23 @@ fun PermissionsScreen( isButtonEnabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU, ) + PermissionCard( + title = "Battery Optimization", + granted = state.isIgnoringBatteryOptimizations, + buttonText = "Disable Battery Optimization", + onClick = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + context.startActivity( + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${context.packageName}") + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }, + ) + } + }, + isButtonEnabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M, + ) + Button( onClick = { state = readPermissionGatewayState(context) @@ -188,6 +207,7 @@ fun readPermissionGatewayState(context: Context): PermissionGatewayState = hasUsageAccess = hasUsageAccessPermission(context), hasOverlayPermission = Settings.canDrawOverlays(context), hasNotificationPermission = hasNotificationPermission(context), + isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(context), ) fun hasUsageAccessPermission(context: Context): Boolean { diff --git a/app/src/main/java/com/focusblocker/app/util/BatteryUtils.kt b/app/src/main/java/com/focusblocker/app/util/BatteryUtils.kt new file mode 100644 index 0000000..5faff89 --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/util/BatteryUtils.kt @@ -0,0 +1,18 @@ +package com.focusblocker.app.util + +import android.content.Context +import android.os.Build +import android.os.PowerManager + +/** + * Checks if the app is ignoring battery optimizations. + * Returns true if the app is whitelisted from battery optimization, safe from background kills. + * Returns false if the app may be killed by aggressive battery management. + */ +fun isIgnoringBatteryOptimizations(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as? PowerManager + return powerManager?.isIgnoringBatteryOptimizations(context.packageName) ?: false + } + return true // Pre-Android 6.0 doesn't have battery optimizations +} From c1006e258c68e7fca653a7b46a0a65ec542018bf Mon Sep 17 00:00:00 2001 From: Muhammed Afnan kP Date: Tue, 31 Mar 2026 16:29:49 +0000 Subject: [PATCH 09/13] Fix GitHub workflows: remove template configs, add gradle caching --- .github/workflows/build_and_local_tests.yml | 22 --------------------- .github/workflows/instrumented_tests.yml | 1 + 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/.github/workflows/build_and_local_tests.yml b/.github/workflows/build_and_local_tests.yml index dff299b..a854713 100644 --- a/.github/workflows/build_and_local_tests.yml +++ b/.github/workflows/build_and_local_tests.yml @@ -41,25 +41,3 @@ jobs: with: name: build-reports path: ./app/build/reports - - - name: Clean before running customizer - run: git clean -fx . - - - name: Run customizer script - run: bash customizer.sh com.android.blah MyNewModel MyNewApplication - - - name: "Check that customizer ran correctly" - uses: andstor/file-existence-action@v3 - with: - files: "app/src/main/java/com/android/blah/MyNewApplication.kt" - fail: false - - - name: "Check that customizer removed all unnecessary files" - id: customizer_rm - uses: andstor/file-existence-action@v3 - with: - files: ".git/config" - fail: false - - name: "Fail if unnecessary files were not deleted" - if: steps.customizer_rm.outputs.files_exists == 'true' - run: exit 1 diff --git a/.github/workflows/instrumented_tests.yml b/.github/workflows/instrumented_tests.yml index eec11f9..cb344ca 100644 --- a/.github/workflows/instrumented_tests.yml +++ b/.github/workflows/instrumented_tests.yml @@ -30,6 +30,7 @@ jobs: with: java-version: 17 distribution: 'zulu' + cache: gradle - name: Run instrumentation tests uses: reactivecircus/android-emulator-runner@v2 From 1b59cbd14168589a9d246d54d100899926b0d45c Mon Sep 17 00:00:00 2001 From: Muhammed Afnan kP Date: Tue, 31 Mar 2026 16:47:58 +0000 Subject: [PATCH 10/13] Polish launcher icon with premium Focus Shield design - Replace default Android robot with custom Focus Shield logo - Shield design features concentric rings and cardinal focus rays - Updated ic_launcher_foreground.xml with teal outlines (#1EC8B0) and white shield - Updated ic_launcher_monochrome.xml for Android 13+ dynamic theming support - Add ic_launcher_background color reference to maintain visual consistency - Adaptive icon system scales logo perfectly across all device densities --- .../res/drawable/ic_launcher_foreground.xml | 59 ++++++++++++++++--- .../res/drawable/ic_launcher_monochrome.xml | 57 +++++++++++++++--- app/src/main/res/values/colors.xml | 4 ++ 3 files changed, 105 insertions(+), 15 deletions(-) diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 08a2740..f03f287 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,23 +1,66 @@ + + + android:fillColor="@android:color/transparent" + android:strokeColor="#FF4DDEC8" + android:strokeWidth="1.5" + android:strokeLineCap="round" + android:pathData="M54,16 C 72,16 86,30 86,54 C 86,78 54,90 54,90 C 54,90 22,78 22,54 C 22,30 36,16 54,16 Z" /> + + android:fillColor="@android:color/transparent" + android:strokeColor="#FFFFFFFF" + android:strokeWidth="1.2" + android:strokeLineCap="round" + android:pathData="M54,28 C 68,28 78,38 78,54 C 78,70 54,79 54,79 C 54,79 30,70 30,54 C 30,38 40,28 54,28 Z" /> + + android:fillColor="#FF4DDEC8" + android:pathData="M54,35 C 65,35 72,42 72,54 C 72,68 54,76 54,76 C 54,76 36,68 36,54 C 36,42 43,35 54,35 Z" /> + + + + + android:strokeColor="#FFFFFFFF" + android:strokeWidth="1.8" + android:strokeLineCap="round" + android:fillColor="@android:color/transparent" + android:pathData="M54,42 L54,38" /> + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml index c69686f..62b67bf 100644 --- a/app/src/main/res/drawable/ic_launcher_monochrome.xml +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -1,23 +1,66 @@ + + + android:fillColor="@android:color/transparent" + android:strokeColor="#FFFFFFFF" + android:strokeWidth="1.5" + android:strokeLineCap="round" + android:pathData="M54,16 C 72,16 86,30 86,54 C 86,78 54,90 54,90 C 54,90 22,78 22,54 C 22,30 36,16 54,16 Z" /> + + android:fillColor="@android:color/transparent" + android:strokeColor="#FFFFFFFF" + android:strokeWidth="1.2" + android:strokeLineCap="round" + android:pathData="M54,28 C 68,28 78,38 78,54 C 78,70 54,79 54,79 C 54,79 30,70 30,54 C 30,38 40,28 54,28 Z" /> + + android:pathData="M54,35 C 65,35 72,42 72,54 C 72,68 54,76 54,76 C 54,76 36,68 36,54 C 36,42 43,35 54,35 Z" /> + + + + + android:strokeColor="#FFFFFFFF" + android:strokeWidth="1.8" + android:strokeLineCap="round" + android:fillColor="@android:color/transparent" + android:pathData="M54,42 L54,38" /> + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index ca6eb6b..999a505 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,4 +1,8 @@ + #0D0D12 + + + #0D0D12 From e76f316c082af7afd9c48f1fcd39e0be128ae0a6 Mon Sep 17 00:00:00 2001 From: Muhammed Afnan kP Date: Tue, 31 Mar 2026 17:13:48 +0000 Subject: [PATCH 11/13] Professional cleanup: normalize icon resources and fix code quality - Remove legacy density-specific launcher icons (10x .webp bitmaps from mipmap-hdpi/mdpi/xhdpi/xxhdpi/xxxhdpi) - Consolidate icon resources to adaptive XML-only pipeline (mipmap-anydpi-v26) - Normalize ic_launcher_foreground.xml and ic_launcher_monochrome.xml decorables - Delete obsolete drawable-v24 variant (pre-API 25 compatibility not needed) - Remove dead API version checks from 3 source files (MIN_SDK is 21; checks for M/L always true) - AppMonitorService.kt: Remove Build.VERSION.SDK_INT >= M check - PermissionsScreen.kt: Remove Build.VERSION.SDK_INT >= M check - BatteryUtils.kt: Remove Build.VERSION.SDK_INT >= M check - Add missing FOREGROUND_SERVICE permission to AndroidManifest.xml for clarity - Fix lint violations and achieve production-grade code quality All changes verified with assembleDebug + lintDebug (BUILD SUCCESSFUL, lint clean). Adaptive icons now properly resolved without gradle confusion from legacy bitmaps. --- app/src/main/AndroidManifest.xml | 5 +++- .../app/service/AppMonitorService.kt | 3 --- .../screens/permissions/PermissionsScreen.kt | 16 +++++------- .../com/focusblocker/app/util/BatteryUtils.kt | 8 ++---- .../drawable-v24/ic_launcher_foreground.xml | 22 ---------------- .../res/drawable/ic_launcher_background.xml | 24 ------------------ .../res/drawable/ic_launcher_foreground.xml | 8 +++--- .../res/drawable/ic_launcher_monochrome.xml | 8 +++--- .../res/mipmap-anydpi-v26/ic_launcher.xml | 20 ++------------- .../mipmap-anydpi-v26/ic_launcher_round.xml | 20 ++------------- app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 1404 -> 0 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 2898 -> 0 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 982 -> 0 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 1772 -> 0 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 1900 -> 0 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 3918 -> 0 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 2884 -> 0 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 5914 -> 0 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 3844 -> 0 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 7778 -> 0 bytes 20 files changed, 23 insertions(+), 111 deletions(-) delete mode 100644 app/src/main/res/drawable-v24/ic_launcher_foreground.xml delete mode 100644 app/src/main/res/drawable/ic_launcher_background.xml delete mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a231943..17305ba 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,10 +18,13 @@ - + + diff --git a/app/src/main/java/com/focusblocker/app/service/AppMonitorService.kt b/app/src/main/java/com/focusblocker/app/service/AppMonitorService.kt index c981943..9eb328c 100644 --- a/app/src/main/java/com/focusblocker/app/service/AppMonitorService.kt +++ b/app/src/main/java/com/focusblocker/app/service/AppMonitorService.kt @@ -8,7 +8,6 @@ import android.app.Service import android.app.usage.UsageEvents import android.app.usage.UsageStatsManager import android.content.Intent -import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat import com.focusblocker.app.R @@ -147,8 +146,6 @@ class AppMonitorService : Service() { } private fun createNotificationChannelIfNeeded() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - val manager = getSystemService(NotificationManager::class.java) val existing = manager.getNotificationChannel(CHANNEL_ID) if (existing != null) return diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt index f49acf9..a2325a9 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt @@ -121,16 +121,14 @@ fun PermissionsScreen( granted = state.isIgnoringBatteryOptimizations, buttonText = "Disable Battery Optimization", onClick = { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - context.startActivity( - Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { - data = Uri.parse("package:${context.packageName}") - flags = Intent.FLAG_ACTIVITY_NEW_TASK - }, - ) - } + context.startActivity( + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${context.packageName}") + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }, + ) }, - isButtonEnabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M, + isButtonEnabled = true, ) Button( diff --git a/app/src/main/java/com/focusblocker/app/util/BatteryUtils.kt b/app/src/main/java/com/focusblocker/app/util/BatteryUtils.kt index 5faff89..d87842c 100644 --- a/app/src/main/java/com/focusblocker/app/util/BatteryUtils.kt +++ b/app/src/main/java/com/focusblocker/app/util/BatteryUtils.kt @@ -1,7 +1,6 @@ package com.focusblocker.app.util import android.content.Context -import android.os.Build import android.os.PowerManager /** @@ -10,9 +9,6 @@ import android.os.PowerManager * Returns false if the app may be killed by aggressive battery management. */ fun isIgnoringBatteryOptimizations(context: Context): Boolean { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val powerManager = context.getSystemService(Context.POWER_SERVICE) as? PowerManager - return powerManager?.isIgnoringBatteryOptimizations(context.packageName) ?: false - } - return true // Pre-Android 6.0 doesn't have battery optimizations + val powerManager = context.getSystemService(Context.POWER_SERVICE) as? PowerManager + return powerManager?.isIgnoringBatteryOptimizations(context.packageName) ?: false } diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 713ffe7..0000000 --- a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index cd09c5a..0000000 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index f03f287..220d1cb 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -28,11 +28,9 @@ android:pathData="M54,35 C 65,35 72,42 72,54 C 72,68 54,76 54,76 C 54,76 36,68 36,54 C 36,42 43,35 54,35 Z" /> - + - + - - - + - + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 54081cb..c78bee3 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,22 +1,6 @@ - - - + - + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index c209e78ecd372343283f4157dcfd918ec5165bb3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp deleted file mode 100644 index b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2898 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 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 4f0f1d64e58ba64d180ce43ee13bf9a17835fbca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 948a3070fe34c611c42c0d3ad3013a0dce358be0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp deleted file mode 100644 index 1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3918 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s From 601d1f06b73857ace3904b457ba0f344bf078418 Mon Sep 17 00:00:00 2001 From: Muhammed Afnan kP Date: Sat, 11 Apr 2026 06:25:06 +0000 Subject: [PATCH 12/13] Enhance release signing configuration and permissions management --- .gitignore | 8 + README.md | 218 ++++++++++++++++-- app/build.gradle.kts | 45 ++++ app/proguard-rules.pro | 23 ++ .../com/focusblocker/app/di/DatabaseModule.kt | 10 +- .../app/security/PermissionUtils.kt | 41 ++++ .../focusblocker/app/ui/FocusBlockerApp.kt | 16 +- .../screens/permissions/PermissionsScreen.kt | 76 ++---- signing.properties.template | 8 + 9 files changed, 348 insertions(+), 97 deletions(-) create mode 100644 app/src/main/java/com/focusblocker/app/security/PermissionUtils.kt create mode 100644 signing.properties.template diff --git a/.gitignore b/.gitignore index 1bc29aa..654e948 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,11 @@ .externalNativeBuild .cxx local.properties + +# Signing artifacts and local release secrets +*.keystore +*.jks +app/release.keystore +signing.properties +keystore.properties +app/signing.properties diff --git a/README.md b/README.md index 909b112..c9cc0aa 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,209 @@ -Architecture starter template (single module) -================== +# FocusBlocker -This template is compatible with the latest **stable** version of Android Studio. +FocusBlocker is an Android productivity application that enforces app-usage limits using a foreground monitoring service, policy-based blocking rules, and a dedicated block screen experience. -## Screenshots -![Screenshot](https://github.com/android/architecture-templates/raw/main/screenshots.png) +It is built with modern Android tooling and a security-first baseline suitable for personal hardening and extension into a production deployment pipeline. -## Features +## Table of Contents -* Room Database -* Hilt -* ViewModel, read+write -* UI in Compose, list + write (Material3) -* Navigation -* Repository and data source -* Kotlin Coroutines and Flow -* Unit tests -* UI tests using fake data with Hilt +1. [Overview](#overview) +2. [Core Capabilities](#core-capabilities) +3. [Tech Stack](#tech-stack) +4. [Architecture](#architecture) +5. [Permission and Runtime Model](#permission-and-runtime-model) +6. [Security Model](#security-model) +7. [Getting Started](#getting-started) +8. [Build and Release](#build-and-release) +9. [Testing](#testing) +10. [Project Conventions](#project-conventions) +11. [Contributing](#contributing) +12. [License](#license) -## Usage +## Overview -1. Clone this branch +FocusBlocker tracks foreground app usage and applies configurable daily quotas per package. Once quota is exhausted, the app presents a full-screen block activity and keeps monitoring in the background through a foreground service. +The app is designed around: + +- Deterministic quota tracking by logical day. +- Continuous monitoring with restart resilience (boot/package-updated receiver). +- Secure preference storage for sensitive settings. +- A Compose-first UI with permission gateway and focused workflows. + +## Core Capabilities + +- Foreground app monitoring via `UsageStatsManager` in `AppMonitorService`. +- App-level policy persistence with Room (`AppPolicy`, `Task`, DAOs, and `AppDatabase`). +- Quota accumulation and automatic daily reset based on a configurable day-start hour. +- Full-screen blocking UX using `BlockScreenActivity` when a policy limit is reached. +- Boot and package-replaced recovery through `BootReceiver`. +- Permission gateway flow for usage access, overlay permission, and battery optimization exemption. +- Encrypted sensitive preferences through `EncryptedSharedPreferences`. + +## Tech Stack + +- Language: Kotlin +- UI: Jetpack Compose + Material 3 +- Architecture primitives: ViewModel, Navigation Compose, Kotlin Coroutines/Flow +- Dependency Injection: Hilt +- Local persistence: Room + KSP +- Security: Android Keystore + AndroidX Security Crypto +- Build system: Gradle (Kotlin DSL) + +Current project baselines: + +- Android Gradle Plugin: `9.0.1` +- Kotlin: `2.3.10` +- Compile SDK: `36` +- Target SDK: `35` +- Min SDK: `26` + +## Architecture + +The project currently uses a single app module (`:app`) with clean package boundaries. + +```text +app/src/main/java/com/focusblocker/app/ + data/ + local/ + dao/ + entity/ + AppDatabase.kt + di/ + security/ + service/ + ui/ + screens/ + block/ + theme/ ``` -git clone https://github.com/android/architecture-templates.git --branch base + +High-level flow: + +1. User grants required special permissions in the permission gateway. +2. `AppMonitorService` starts as foreground service. +3. Service polls recent usage events and resolves current foreground package. +4. Policy and quota state are read/updated in Room. +5. If quota is exhausted, `BlockScreenActivity` is launched. + +## Permission and Runtime Model + +FocusBlocker requires the following permissions/special access for enforcement: + +- `PACKAGE_USAGE_STATS` for foreground usage events. +- `SYSTEM_ALERT_WINDOW` for overlay/blocking UX compatibility. +- `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` to reduce service kill risk. +- `RECEIVE_BOOT_COMPLETED` for automatic restart after reboot. +- Foreground service permissions for persistent monitoring. + +At runtime, the app gates entry through a dedicated permission screen and starts the monitor service only after required access is granted. + +## Security Model + +Sensitive state is stored in encrypted preferences: + +- Backend: `EncryptedSharedPreferences` +- Key management: Android Keystore (`MasterKey` AES-256-GCM) +- Preference file: `focusblocker_secure_prefs` + +Additional anti-tamper logic exists in `SecurityManager`: + +- Logical-day computation using configurable day-start hour. +- Forward clock-drift detection using `SystemClock.elapsedRealtime()` baseline. + +## Getting Started + +### Prerequisites + +- Android Studio (latest stable recommended) +- JDK 17+ +- Android SDK with platform level 35+ installed + +### Clone and Open + +```bash +git clone +cd FocusBlocker ``` -2. Run the customizer script: +Open the project in Android Studio and allow Gradle sync to complete. + +### Build Debug APK +```bash +./gradlew :app:assembleDebug ``` -./customizer.sh your.package.name DataItemType [MyApplication] + +### Install on Connected Device + +```bash +./gradlew :app:installDebug +``` + +## Build and Release + +### Release Signing Configuration + +Release signing values are read from Gradle properties or environment variables: + +- `RELEASE_STORE_FILE` +- `RELEASE_STORE_PASSWORD` +- `RELEASE_KEY_ALIAS` +- `RELEASE_KEY_PASSWORD` + +You can start from `signing.properties.template` and place values in user-level Gradle properties or exported environment variables. + +### Build Release APK + +```bash +./gradlew :app:assembleRelease +``` + +If release signing values are missing, the build fails fast with explicit missing keys. + +## Testing + +### Unit Tests + +```bash +./gradlew :app:testDebugUnitTest ``` -Where `your.package.name` is your app ID (should be lowercase) and `DataItemType` is used for the -name of the screen, exposed state and data base entity (should be PascalCase). You can add an optional application name. +### Instrumented Tests + +```bash +./gradlew :app:connectedDebugAndroidTest +``` + +### Full Verification Pass + +```bash +./gradlew :app:lint :app:testDebugUnitTest +``` + +## Project Conventions + +- Base package must remain `com.focusblocker.app`. +- Keep Room schemas exported under `app/schemas/`. +- Use Hilt for dependency wiring in app scope and Android components. +- Prefer immutable UI state and coroutine-based asynchronous flows. +- Keep release-signing secrets out of version control. + +## Contributing + +Contributions are welcome. Please review [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. + +Recommended pull request quality bar: + +- Focused, single-purpose changes. +- Updated tests for behavioral changes. +- No debug logging or temporary scaffolding left in final diff. +- Clear migration notes for permission, schema, or signing behavior changes. + +## License + +This project is licensed under the Apache License 2.0. See [LICENSE](LICENSE). -# License +## Acknowledgments -Now in Android is distributed under the terms of the Apache License (Version 2.0). See the -[license](LICENSE) for more information. +This repository originated from Android architecture template foundations and has been adapted into the FocusBlocker application domain. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b5c7255..ea194e1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,6 +27,50 @@ android { namespace = "com.focusblocker.app" compileSdk = 36 + val releaseStoreFilePathProvider = providers.gradleProperty("RELEASE_STORE_FILE") + .orElse(providers.environmentVariable("RELEASE_STORE_FILE")) + val releaseStorePasswordProvider = providers.gradleProperty("RELEASE_STORE_PASSWORD") + .orElse(providers.environmentVariable("RELEASE_STORE_PASSWORD")) + val releaseKeyAliasProvider = providers.gradleProperty("RELEASE_KEY_ALIAS") + .orElse(providers.environmentVariable("RELEASE_KEY_ALIAS")) + val releaseKeyPasswordProvider = providers.gradleProperty("RELEASE_KEY_PASSWORD") + .orElse(providers.environmentVariable("RELEASE_KEY_PASSWORD")) + + val isReleaseBuildRequested = gradle.startParameter.taskNames.any { + it.contains("release", ignoreCase = true) + } + + signingConfigs { + create("release") { + val storeFilePath = releaseStoreFilePathProvider.orNull + val storePasswordValue = releaseStorePasswordProvider.orNull + val keyAliasValue = releaseKeyAliasProvider.orNull + val keyPasswordValue = releaseKeyPasswordProvider.orNull + + if (!storeFilePath.isNullOrBlank()) { + storeFile = rootProject.file(storeFilePath) + } + storePassword = storePasswordValue + keyAlias = keyAliasValue + keyPassword = keyPasswordValue + + if (isReleaseBuildRequested) { + val missing = mutableListOf() + if (storeFilePath.isNullOrBlank()) missing += "RELEASE_STORE_FILE" + if (storePasswordValue.isNullOrBlank()) missing += "RELEASE_STORE_PASSWORD" + if (keyAliasValue.isNullOrBlank()) missing += "RELEASE_KEY_ALIAS" + if (keyPasswordValue.isNullOrBlank()) missing += "RELEASE_KEY_PASSWORD" + + if (missing.isNotEmpty()) { + throw GradleException( + "Missing release signing values: ${missing.joinToString()}. " + + "Provide them via Gradle properties or environment variables.", + ) + } + } + } + } + defaultConfig { applicationId = "com.focusblocker.app" minSdk = 26 // API 26+ recommended: covers ~97% of devices + guarantees JobScheduler @@ -43,6 +87,7 @@ android { buildTypes { release { + signingConfig = signingConfigs.getByName("release") isMinifyEnabled = true // Enable R8 shrinking for release isShrinkResources = true proguardFiles( diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 25ce604..2340bf0 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -23,6 +23,29 @@ # Room — keep generated database implementations -keep class * extends androidx.room.RoomDatabase -keepclassmembers class * extends androidx.room.RoomDatabase { *; } +-keep class **_Impl { *; } + +# Keep metadata Hilt/Room rely on during generated wiring +-keepattributes *Annotation*, Signature, InnerClasses, EnclosingMethod + +# Honor @Keep annotations across app and library classes/members +-keep @androidx.annotation.Keep class * { *; } +-keepclasseswithmembers class * { + @androidx.annotation.Keep ; + @androidx.annotation.Keep ; +} + +# Hilt / Dagger generated components and factories +-keep class dagger.hilt.** { *; } +-keep class hilt_aggregated_deps.** { *; } +-keep class *_Factory { *; } +-keep class *_HiltModules* { *; } +-keep class *_GeneratedInjector { *; } +-keep class * extends dagger.hilt.internal.GeneratedComponent { *; } +-keep class * extends dagger.hilt.internal.GeneratedComponentManager { *; } + +# Compose + Coroutines: no app-specific keep rules required in normal cases. +# They ship consumer rules; avoid broad keeps to preserve shrink effectiveness. # Tink / Security Crypto — keep key management classes -keep class com.google.crypto.tink.** { *; } diff --git a/app/src/main/java/com/focusblocker/app/di/DatabaseModule.kt b/app/src/main/java/com/focusblocker/app/di/DatabaseModule.kt index a50ef7f..1921a57 100644 --- a/app/src/main/java/com/focusblocker/app/di/DatabaseModule.kt +++ b/app/src/main/java/com/focusblocker/app/di/DatabaseModule.kt @@ -24,9 +24,8 @@ import javax.inject.Singleton * write contention. SingletonComponent guarantees one instance per process. * * MIGRATION STRATEGY: - * [fallbackToDestructiveMigration] is safe ONLY during development. - * Before the first production release, replace it with explicit Migration - * objects: + * Keep the builder migration-safe by adding explicit Migration objects when + * the schema changes: * * .addMigrations(MIGRATION_1_2, MIGRATION_2_3) * @@ -58,11 +57,6 @@ object DatabaseModule { AppDatabase::class.java, DATABASE_NAME, ) - // ── Development only ────────────────────────────────────────────────── - // Replace with .addMigrations(...) before any production / Play Store - // release to avoid wiping user task and policy data on schema changes. - .fallbackToDestructiveMigration(dropAllTables = true) - // ───────────────────────────────────────────────────────────────────── .build() @Provides diff --git a/app/src/main/java/com/focusblocker/app/security/PermissionUtils.kt b/app/src/main/java/com/focusblocker/app/security/PermissionUtils.kt new file mode 100644 index 0000000..c6ad963 --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/security/PermissionUtils.kt @@ -0,0 +1,41 @@ +package com.focusblocker.app.security + +import android.app.AppOpsManager +import android.content.Context +import android.os.Build +import android.os.Process +import android.provider.Settings +import com.focusblocker.app.util.isIgnoringBatteryOptimizations + +/** + * Returns true only when every required Special App Access permission is granted. + */ +fun hasAllRequiredSpecialPermissions(context: Context): Boolean { + return hasUsageStatsPermission(context) && + hasOverlayPermission(context) && + hasBatteryOptimizationExemption(context) +} + +fun hasUsageStatsPermission(context: Context): Boolean { + val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager + val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + appOps.unsafeCheckOpNoThrow( + AppOpsManager.OPSTR_GET_USAGE_STATS, + Process.myUid(), + context.packageName, + ) + } else { + @Suppress("DEPRECATION") + appOps.checkOpNoThrow( + AppOpsManager.OPSTR_GET_USAGE_STATS, + Process.myUid(), + context.packageName, + ) + } + return mode == AppOpsManager.MODE_ALLOWED +} + +fun hasOverlayPermission(context: Context): Boolean = Settings.canDrawOverlays(context) + +fun hasBatteryOptimizationExemption(context: Context): Boolean = + isIgnoringBatteryOptimizations(context) diff --git a/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt b/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt index 5dc1062..1e9071a 100644 --- a/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt +++ b/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt @@ -25,9 +25,11 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination @@ -35,6 +37,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import com.focusblocker.app.security.hasAllRequiredSpecialPermissions import com.focusblocker.app.ui.screens.applimits.AppLimitsScreen import com.focusblocker.app.ui.screens.dashboard.DashboardScreen import com.focusblocker.app.ui.screens.permissions.PermissionsScreen @@ -84,7 +87,11 @@ private val topLevelDestinations = listOf( */ @Composable fun FocusBlockerApp() { + val context = LocalContext.current val navController = rememberNavController() + val startDestination = remember { + if (hasAllRequiredSpecialPermissions(context)) Routes.DASHBOARD else Routes.PERMISSIONS + } val backStackEntry by navController.currentBackStackEntryAsState() val currentDest = backStackEntry?.destination @@ -113,7 +120,7 @@ fun FocusBlockerApp() { ) { innerPadding -> NavHost( navController = navController, - startDestination = Routes.DASHBOARD, + startDestination = startDestination, modifier = Modifier .fillMaxSize() .padding(innerPadding), @@ -165,7 +172,12 @@ fun FocusBlockerApp() { } composable(Routes.PERMISSIONS) { PermissionsScreen( - onNavigateBack = { navController.navigateUp() }, + onContinueToDashboard = { + navController.navigate(Routes.DASHBOARD) { + popUpTo(Routes.PERMISSIONS) { inclusive = true } + launchSingleTop = true + } + }, ) } } diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt index a2325a9..917e662 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt @@ -1,10 +1,7 @@ package com.focusblocker.app.ui.screens.permissions -import android.app.AppOpsManager import android.content.Context import android.content.Intent -import android.os.Build -import android.os.Process import android.provider.Settings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -27,27 +24,26 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat -import android.Manifest -import android.content.pm.PackageManager import android.net.Uri import android.util.Log +import com.focusblocker.app.security.hasAllRequiredSpecialPermissions +import com.focusblocker.app.security.hasBatteryOptimizationExemption +import com.focusblocker.app.security.hasOverlayPermission +import com.focusblocker.app.security.hasUsageStatsPermission import com.focusblocker.app.service.AppMonitorService -import com.focusblocker.app.util.isIgnoringBatteryOptimizations data class PermissionGatewayState( val hasUsageAccess: Boolean, val hasOverlayPermission: Boolean, - val hasNotificationPermission: Boolean, - val isIgnoringBatteryOptimizations: Boolean, + val hasBatteryOptimizationExemption: Boolean, ) { val allRequiredGranted: Boolean - get() = hasUsageAccess && hasOverlayPermission && hasNotificationPermission && isIgnoringBatteryOptimizations + get() = hasUsageAccess && hasOverlayPermission && hasBatteryOptimizationExemption } @Composable fun PermissionsScreen( - onNavigateBack: () -> Unit = {}, - onAllPermissionsGranted: () -> Unit = {}, + onContinueToDashboard: () -> Unit = {}, ) { val context = LocalContext.current var state by remember { mutableStateOf(readPermissionGatewayState(context)) } @@ -99,26 +95,9 @@ fun PermissionsScreen( }, ) - PermissionCard( - title = "Notifications", - granted = state.hasNotificationPermission, - buttonText = "Open Notification Settings", - onClick = { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.startActivity( - Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) - flags = Intent.FLAG_ACTIVITY_NEW_TASK - }, - ) - } - }, - isButtonEnabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU, - ) - PermissionCard( title = "Battery Optimization", - granted = state.isIgnoringBatteryOptimizations, + granted = state.hasBatteryOptimizationExemption, buttonText = "Disable Battery Optimization", onClick = { context.startActivity( @@ -134,7 +113,7 @@ fun PermissionsScreen( Button( onClick = { state = readPermissionGatewayState(context) - if (state.allRequiredGranted) { + if (hasAllRequiredSpecialPermissions(context)) { Log.d("PermissionsScreen", "All permissions granted. Starting AppMonitorService...") try { ContextCompat.startForegroundService( @@ -145,8 +124,7 @@ fun PermissionsScreen( } catch (e: Exception) { Log.e("PermissionsScreen", "Failed to start AppMonitorService: ${e.message}") } - onAllPermissionsGranted() - onNavigateBack() + onContinueToDashboard() } }, modifier = Modifier.fillMaxWidth(), @@ -155,7 +133,7 @@ fun PermissionsScreen( containerColor = MaterialTheme.colorScheme.primary, ), ) { - Text("I Granted Permissions") + Text("Continue") } } } @@ -203,34 +181,8 @@ private fun PermissionCard( fun readPermissionGatewayState(context: Context): PermissionGatewayState = PermissionGatewayState( hasUsageAccess = hasUsageAccessPermission(context), - hasOverlayPermission = Settings.canDrawOverlays(context), - hasNotificationPermission = hasNotificationPermission(context), - isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(context), + hasOverlayPermission = hasOverlayPermission(context), + hasBatteryOptimizationExemption = hasBatteryOptimizationExemption(context), ) -fun hasUsageAccessPermission(context: Context): Boolean { - val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager - val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - appOps.unsafeCheckOpNoThrow( - AppOpsManager.OPSTR_GET_USAGE_STATS, - Process.myUid(), - context.packageName, - ) - } else { - @Suppress("DEPRECATION") - appOps.checkOpNoThrow( - AppOpsManager.OPSTR_GET_USAGE_STATS, - Process.myUid(), - context.packageName, - ) - } - return mode == AppOpsManager.MODE_ALLOWED -} - -fun hasNotificationPermission(context: Context): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true - return ContextCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS, - ) == PackageManager.PERMISSION_GRANTED -} +private fun hasUsageAccessPermission(context: Context): Boolean = hasUsageStatsPermission(context) diff --git a/signing.properties.template b/signing.properties.template new file mode 100644 index 0000000..71b5292 --- /dev/null +++ b/signing.properties.template @@ -0,0 +1,8 @@ +# Copy this file to signing.properties (ignored by git) and replace placeholders. +# Then export these values as environment variables or add them to your +# user-level ~/.gradle/gradle.properties. + +RELEASE_STORE_FILE=app/release.keystore +RELEASE_STORE_PASSWORD=REPLACE_WITH_STORE_PASSWORD +RELEASE_KEY_ALIAS=REPLACE_WITH_KEY_ALIAS +RELEASE_KEY_PASSWORD=REPLACE_WITH_KEY_PASSWORD From 7ad9d926171e4bb528c278188642cce6a94848af Mon Sep 17 00:00:00 2001 From: Muhammed Afnan kP Date: Mon, 13 Apr 2026 07:03:13 +0000 Subject: [PATCH 13/13] Add permission reminder dialog and enhance permission handling logic --- .../screens/permissions/PermissionsScreen.kt | 142 +++++++++++++++--- 1 file changed, 122 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt index 917e662..57da95d 100644 --- a/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt +++ b/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt @@ -2,7 +2,9 @@ package com.focusblocker.app.ui.screens.permissions import android.content.Context import android.content.Intent +import android.net.Uri import android.provider.Settings +import androidx.compose.material3.AlertDialog import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -15,6 +17,7 @@ import androidx.compose.material3.ElevatedCard import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -24,8 +27,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat -import android.net.Uri import android.util.Log +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.LifecycleEventObserver import com.focusblocker.app.security.hasAllRequiredSpecialPermissions import com.focusblocker.app.security.hasBatteryOptimizationExemption import com.focusblocker.app.security.hasOverlayPermission @@ -46,7 +51,37 @@ fun PermissionsScreen( onContinueToDashboard: () -> Unit = {}, ) { val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current var state by remember { mutableStateOf(readPermissionGatewayState(context)) } + var showPermissionPrompt by remember { mutableStateOf(true) } + + DisposableEffect(lifecycleOwner, context) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + state = readPermissionGatewayState(context) + showPermissionPrompt = true + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + val nextMissingPermission = firstMissingPermission(state) + + if (showPermissionPrompt && nextMissingPermission != null) { + PermissionReminderDialog( + permission = nextMissingPermission, + onGrant = { + launchPermissionRequest(context, nextMissingPermission) + showPermissionPrompt = false + }, + onRemindLater = { + showPermissionPrompt = false + }, + ) + } Column( modifier = Modifier @@ -71,11 +106,7 @@ fun PermissionsScreen( granted = state.hasUsageAccess, buttonText = "Open Usage Access Settings", onClick = { - context.startActivity( - Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK - }, - ) + launchPermissionRequest(context, PermissionType.USAGE_ACCESS) }, ) @@ -84,14 +115,7 @@ fun PermissionsScreen( granted = state.hasOverlayPermission, buttonText = "Open Overlay Settings", onClick = { - context.startActivity( - Intent( - Settings.ACTION_MANAGE_OVERLAY_PERMISSION, - Uri.parse("package:${context.packageName}"), - ).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK - }, - ) + launchPermissionRequest(context, PermissionType.OVERLAY) }, ) @@ -100,12 +124,7 @@ fun PermissionsScreen( granted = state.hasBatteryOptimizationExemption, buttonText = "Disable Battery Optimization", onClick = { - context.startActivity( - Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { - data = Uri.parse("package:${context.packageName}") - flags = Intent.FLAG_ACTIVITY_NEW_TASK - }, - ) + launchPermissionRequest(context, PermissionType.BATTERY_OPTIMIZATION) }, isButtonEnabled = true, ) @@ -113,6 +132,10 @@ fun PermissionsScreen( Button( onClick = { state = readPermissionGatewayState(context) + if (!state.allRequiredGranted) { + showPermissionPrompt = true + return@Button + } if (hasAllRequiredSpecialPermissions(context)) { Log.d("PermissionsScreen", "All permissions granted. Starting AppMonitorService...") try { @@ -178,6 +201,85 @@ private fun PermissionCard( } } +@Composable +private fun PermissionReminderDialog( + permission: PermissionType, + onGrant: () -> Unit, + onRemindLater: () -> Unit, +) { + AlertDialog( + onDismissRequest = onRemindLater, + title = { + Text(text = permission.dialogTitle) + }, + text = { + Text(text = permission.dialogMessage) + }, + confirmButton = { + Button(onClick = onGrant) { + Text("Grant access") + } + }, + dismissButton = { + Button(onClick = onRemindLater) { + Text("Remind me") + } + }, + ) +} + +private enum class PermissionType( + val dialogTitle: String, + val dialogMessage: String, +) { + USAGE_ACCESS( + dialogTitle = "Allow Usage Access", + dialogMessage = "FocusBlocker needs usage access to detect the currently opened app.", + ), + OVERLAY( + dialogTitle = "Allow Overlay Permission", + dialogMessage = "FocusBlocker needs overlay permission to show blocking screens above apps.", + ), + BATTERY_OPTIMIZATION( + dialogTitle = "Disable Battery Optimization", + dialogMessage = "Allow battery optimization exemption so monitoring keeps running reliably.", + ), +} + +private fun firstMissingPermission(state: PermissionGatewayState): PermissionType? = when { + !state.hasUsageAccess -> PermissionType.USAGE_ACCESS + !state.hasOverlayPermission -> PermissionType.OVERLAY + !state.hasBatteryOptimizationExemption -> PermissionType.BATTERY_OPTIMIZATION + else -> null +} + +private fun launchPermissionRequest(context: Context, permission: PermissionType) { + val intent = when (permission) { + PermissionType.USAGE_ACCESS -> { + Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS) + } + + PermissionType.OVERLAY -> { + Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:${context.packageName}"), + ) + } + + PermissionType.BATTERY_OPTIMIZATION -> { + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${context.packageName}") + } + } + } + + context.startActivity( + intent.apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }, + ) +} + fun readPermissionGatewayState(context: Context): PermissionGatewayState = PermissionGatewayState( hasUsageAccess = hasUsageAccessPermission(context),