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 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 7e1f3a4..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( @@ -59,6 +104,7 @@ android { buildFeatures { compose = true + buildConfig = true } } @@ -70,6 +116,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) @@ -92,6 +139,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 +160,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/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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ede66d1..17305ba 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,6 +18,17 @@ + + + + + + + + + + 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 d3109cc..1921a57 100644 --- a/app/src/main/java/com/focusblocker/app/di/DatabaseModule.kt +++ b/app/src/main/java/com/focusblocker/app/di/DatabaseModule.kt @@ -1,6 +1,8 @@ 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 @@ -22,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) * @@ -56,13 +57,21 @@ 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 + @Singleton + fun providePackageManager( + @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/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/service/AppMonitorService.kt b/app/src/main/java/com/focusblocker/app/service/AppMonitorService.kt new file mode 100644 index 0000000..9eb328c --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/service/AppMonitorService.kt @@ -0,0 +1,188 @@ +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.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() { + 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/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/FocusBlockerApp.kt b/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt new file mode 100644 index 0000000..1e9071a --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/FocusBlockerApp.kt @@ -0,0 +1,228 @@ +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 +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.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 +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 +import com.focusblocker.app.ui.screens.settings.SettingsScreen +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" + const val SETTINGS = "settings" + const val PERMISSIONS = "permissions" +} + +// ── 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, "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 ─────────────────────────────────────────────────────────── + +/** + * 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 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 + + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = MaterialTheme.colorScheme.background, + bottomBar = { + 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 + } + }, + ) + } + }, + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = startDestination, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + 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) { + TaskScreen() + } + composable(Routes.APP_LIMITS) { + AppLimitsScreen() + } + composable(Routes.SETTINGS) { + SettingsScreen( + onNavigateBack = { navController.navigateUp() }, + ) + } + composable(Routes.PERMISSIONS) { + PermissionsScreen( + onContinueToDashboard = { + navController.navigate(Routes.DASHBOARD) { + popUpTo(Routes.PERMISSIONS) { inclusive = true } + launchSingleTop = true + } + }, + ) + } + } + } +} + +// ── Bottom navigation bar ───────────────────────────────────────────────────── + +@Composable +private fun FocusNavBar( + destinations: List, + currentDest: androidx.navigation.NavDestination?, + onDestSelected: (TopLevelDest) -> Unit, +) { + Surface( + color = Surface800, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp), + ) { + 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/MainActivity.kt b/app/src/main/java/com/focusblocker/app/ui/MainActivity.kt index fcf15f1..1fd955b 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,35 @@ 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 androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 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, - ), - ) + installSplashScreen() 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/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/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 new file mode 100644 index 0000000..42da868 --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsScreen.kt @@ -0,0 +1,375 @@ +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 +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize +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 +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.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 +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(horizontal = 16.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + 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 { + 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", + 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 { + 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) { + item { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Scanning installed apps...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } + } + } + + if (!state.isLoading && state.apps.isEmpty()) { + item { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Text( + text = "No launchable user apps found.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } + } + } + + items(state.apps, key = { it.packageName }) { app -> + val isUnlimited = app.quotaMs >= UNLIMITED_QUOTA_MS + var quotaMinutes by remember(app.packageName, app.quotaMs) { + mutableFloatStateOf( + if (isUnlimited) 240f + else (app.quotaMs / 60_000L).coerceIn(10L, 240L).toFloat(), + ) + } + + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(animationSpec = tween(180)), + shape = RoundedCornerShape(14.dp), + colors = CardDefaults.elevatedCardColors( + containerColor = if (app.isBlockingEnabled) { + MaterialTheme.colorScheme.surface + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + }, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.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 = 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 }, + 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 = "◻", + style = MaterialTheme.typography.titleLarge, + 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 new file mode 100644 index 0000000..d95d4ce --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/screens/applimits/AppLimitsViewModel.kt @@ -0,0 +1,191 @@ +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.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +const val UNLIMITED_QUOTA_MS = 31_536_000_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 apps: List = emptyList(), + val searchQuery: String = "", + val error: String? = null, +) + +@HiltViewModel +class AppLimitsViewModel @Inject constructor( + private val appPolicyDao: AppPolicyDao, + private val packageManager: PackageManager, +) : ViewModel() { + + private data class LaunchableApp( + val packageName: String, + val appLabel: String, + ) + + private val installedApps = MutableStateFlow>(emptyList()) + private val searchQuery = MutableStateFlow("") + + private val policies: StateFlow> = + appPolicyDao.observeAllPolicies() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) + + val uiState: StateFlow = + 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 ?: 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 = filteredRows, + searchQuery = query, + ) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = AppLimitsUiState(isLoading = true), + ) + + fun setSearchQuery(value: String) { + searchQuery.value = value + } + + init { + refreshInstalledApps() + } + + fun refreshInstalledApps() { + viewModelScope.launch { + 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 toggleBlocking(app: InstalledAppUi, enabled: Boolean) { + viewModelScope.launch { + 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 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 new file mode 100644 index 0000000..86a77cd --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardScreen.kt @@ -0,0 +1,415 @@ +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 +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.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 +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 +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() + 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" + 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(horizontal = 16.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + AnimatedVisibility( + visible = showHero, + enter = fadeIn(animationSpec = tween(260)) + + slideInVertically( + animationSpec = tween(280), + initialOffsetY = { fullHeight -> fullHeight / 14 }, + ), + ) { + 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, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Focus Hub", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + 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() + .padding(vertical = 4.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier.size(180.dp), + contentAlignment = Alignment.Center, + ) { + Canvas(modifier = Modifier.matchParentSize()) { + drawArc( + color = ringTrackColor, + startAngle = -90f, + sweepAngle = 360f, + useCenter = false, + style = Stroke(width = 16.dp.toPx(), cap = StrokeCap.Round), + ) + } + + CircularProgressIndicator( + progress = { progress }, + modifier = Modifier.matchParentSize(), + strokeWidth = 16.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 = "Daily Focus Score", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Text( + text = "${state.completedTasks} of ${state.totalTasks} tasks completed", + 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), + ) + } + + 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, + ) + } + } + } + } + } + + 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() + .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, + ) + } + } + } + } + + AnimatedVisibility( + visible = showTasks, + enter = fadeIn(animationSpec = tween(220)) + + slideInVertically( + animationSpec = tween(240), + initialOffsetY = { fullHeight -> fullHeight / 18 }, + ), + ) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = "Pending Tasks for Today", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + + 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, + 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, + ) + } + } + } + } + } + } + } + } +} + +@Composable +private fun StatCard( + title: String, + value: String, + modifier: Modifier = Modifier, +) { + ElevatedCard( + modifier = modifier, + shape = RoundedCornerShape(14.dp), + ) { + 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 + 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) +} + +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/dashboard/DashboardViewModel.kt b/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardViewModel.kt new file mode 100644 index 0000000..1736ed7 --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/screens/dashboard/DashboardViewModel.kt @@ -0,0 +1,100 @@ +// ═════════════════════════════════════════════════════════════════════════════ +// 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 totalTasks: Int = 0, + val completedTasks: Int = 0, + 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 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 + 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 totalTasks = tasks.size + val completedTasks = tasks.count { 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, + totalTasks = totalTasks, + completedTasks = completedTasks, + 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/permissions/PermissionsScreen.kt b/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt new file mode 100644 index 0000000..57da95d --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/screens/permissions/PermissionsScreen.kt @@ -0,0 +1,290 @@ +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 +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.DisposableEffect +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.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 +import com.focusblocker.app.security.hasUsageStatsPermission +import com.focusblocker.app.service.AppMonitorService + +data class PermissionGatewayState( + val hasUsageAccess: Boolean, + val hasOverlayPermission: Boolean, + val hasBatteryOptimizationExemption: Boolean, +) { + val allRequiredGranted: Boolean + get() = hasUsageAccess && hasOverlayPermission && hasBatteryOptimizationExemption +} + +@Composable +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 + .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 = { + launchPermissionRequest(context, PermissionType.USAGE_ACCESS) + }, + ) + + PermissionCard( + title = "Overlay Permission", + granted = state.hasOverlayPermission, + buttonText = "Open Overlay Settings", + onClick = { + launchPermissionRequest(context, PermissionType.OVERLAY) + }, + ) + + PermissionCard( + title = "Battery Optimization", + granted = state.hasBatteryOptimizationExemption, + buttonText = "Disable Battery Optimization", + onClick = { + launchPermissionRequest(context, PermissionType.BATTERY_OPTIMIZATION) + }, + isButtonEnabled = true, + ) + + Button( + onClick = { + state = readPermissionGatewayState(context) + if (!state.allRequiredGranted) { + showPermissionPrompt = true + return@Button + } + if (hasAllRequiredSpecialPermissions(context)) { + 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}") + } + onContinueToDashboard() + } + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text("Continue") + } + } +} + +@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) + } + } + } +} + +@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), + hasOverlayPermission = hasOverlayPermission(context), + hasBatteryOptimizationExemption = hasBatteryOptimizationExemption(context), + ) + +private fun hasUsageAccessPermission(context: Context): Boolean = hasUsageStatsPermission(context) 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..60421e8 --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/screens/settings/SettingsScreen.kt @@ -0,0 +1,145 @@ +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.foundation.shape.RoundedCornerShape +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(horizontal = 16.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) { + 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)) }, + shape = RoundedCornerShape(10.dp), + leadingIcon = if (selected) { + { + Text( + text = "•", + style = MaterialTheme.typography.bodyLarge, + ) + } + } else { + null + }, + ) + } + } + } + } + + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + ) { + 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 new file mode 100644 index 0000000..bd65221 --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskScreen.kt @@ -0,0 +1,567 @@ +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 +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.foundation.shape.RoundedCornerShape +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 +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 +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.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 +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 +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 +import java.time.ZoneId + +private enum class TaskFilter(val label: String) { + ALL("All"), + ACTIVE("Active"), + COMPLETED("Completed"), +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TaskScreen( + viewModel: TaskViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsState() + val selectedDate = LocalDate.ofEpochDay(state.selectedEpochDay) + + 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) } + 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 + TaskFilter.ACTIVE -> state.tasks.filter { !it.isCompleted } + TaskFilter.COMPLETED -> state.tasks.filter { it.isCompleted } + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + floatingActionButton = { + FloatingActionButton( + onClick = { + editingTask = null + titleInput = "" + startTimeInput = "" + endTimeInput = "" + inputError = null + showTaskSheet = true + }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "Add task", + ) + } + }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + 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 { + AnimatedVisibility( + visible = showHeader, + enter = fadeIn(animationSpec = tween(220)) + + slideInVertically( + animationSpec = tween(240), + initialOffsetY = { fullHeight -> fullHeight / 18 }, + ), + ) { + 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) }, + ) + } + } + } + } + } + + if (visibleTasks.isEmpty()) { + item { + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(14.dp), + ) { + Text( + 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), + ) + } + } + } else { + items(items = visibleTasks, key = { it.id }) { task -> + 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 (showTaskSheet) { + ModalBottomSheet( + onDismissRequest = { + showTaskSheet = false + editingTask = null + titleInput = "" + startTimeInput = "" + endTimeInput = "" + inputError = null + }, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = if (editingTask == null) "Add Task" else "Edit Task", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + + OutlinedTextField( + value = titleInput, + onValueChange = { titleInput = it }, + label = { Text("Title") }, + modifier = Modifier.fillMaxWidth(), + 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.isEmpty()) { + inputError = "Title is required." + return@Button + } + + val hasAnyTimeInput = startTimeInput.isNotBlank() || endTimeInput.isNotBlank() + val startMs = if (startTimeInput.isBlank()) null else parseTimeInputToEpochMs( + dateEpochDay = editingTask?.dateEpochDay ?: state.selectedEpochDay, + value = startTimeInput, + ) + val endMs = if (endTimeInput.isBlank()) null else parseTimeInputToEpochMs( + dateEpochDay = editingTask?.dateEpochDay ?: state.selectedEpochDay, + 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 + + if (editingTask == null) { + viewModel.addTask( + title = title, + startTimeMs = startMs, + endTimeMs = endMs, + ) + } else { + 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 + titleInput = "" + startTimeInput = "" + endTimeInput = "" + showTaskSheet = false + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Save") + } + } + } + } +} + +@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) }, + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.elevatedCardColors( + containerColor = if (selected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f) + } else { + MaterialTheme.colorScheme.surface + }, + ), + ) { + 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, + onClick: () -> Unit, + 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 + .animateContentSize(animationSpec = tween(180)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(14.dp), + colors = CardDefaults.elevatedCardColors(containerColor = cardColor), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + 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 = subTitleColor, + modifier = Modifier.alpha(1f - (completionProgress * 0.2f)), + ) + } + } + + 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) +} + +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 new file mode 100644 index 0000000..d08322a --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/ui/screens/tasks/TaskViewModel.kt @@ -0,0 +1,106 @@ +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.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 selectedEpochDay = MutableStateFlow(securityManager.resolveLogicalDay()) + + val uiState: StateFlow = + 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, + selectedEpochDay = securityManager.resolveLogicalDay(), + ), + ) + + fun selectDay(epochDay: Long) { + selectedEpochDay.update { epochDay } + } + + fun resetToLogicalToday() { + selectedEpochDay.update { securityManager.resolveLogicalDay() } + } + + 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( + 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) { + viewModelScope.launch { taskDao.updateTask(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..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 @@ -9,3 +9,59 @@ val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650A4) val PurpleGrey40 = Color(0xFF625B71) val Pink40 = Color(0xFF7D5260) + +// ── Brand palette ───────────────────────────────────────────────────────────── +// 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 ─ 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 ─ 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 ─ crisp and energetic +val Teal250 = Color(0xFF68E8DC) +val Teal300 = Color(0xFF4DDEC8) +val Teal400 = Color(0xFF1EC8B0) +val Teal500 = Color(0xFF00A896) +val Teal600 = Color(0xFF008B7C) + +// Premium OLED dark surfaces with enhanced elevation hierarchy +val Black = Color(0xFF000000) +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 + +// Premium semantic colors +val WarningAmber = Color(0xFFFFB830) +val WarningAmber2 = Color(0xFFFFF0C2) +val SuccessGreen = Color(0xFF34D399) +val SuccessGreen2 = Color(0xFFD1FAE5) +val ErrorRed = Color(0xFFFF5370) +val ErrorRed2 = Color(0xFFFFE4E8) + +// 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 1b48a1b..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 @@ -1,46 +1,185 @@ 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 + premium) ───────────────────────────── private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80, + // 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, + + // 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), + 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). +// Premium hierarchy: Carefully tuned weights, sizes, and line heights for modern elegance. +private val FocusTypography = Typography( + // Screen section headers — bold, commanding presence + headlineLarge = TextStyle( + fontWeight = FontWeight.W800, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = (-0.5).sp, + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.W700, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = (-0.3).sp, + ), + headlineSmall = TextStyle( + fontWeight = FontWeight.W700, + fontSize = 20.sp, + lineHeight = 28.sp, + letterSpacing = (-0.2).sp, + ), + // Card titles, section labels — semi-bold authority + titleLarge = TextStyle( + fontWeight = FontWeight.W700, + fontSize = 17.sp, + lineHeight = 24.sp, + letterSpacing = 0.sp, + ), + titleMedium = TextStyle( + fontWeight = FontWeight.W600, + fontSize = 15.sp, + lineHeight = 22.sp, + letterSpacing = 0.1.sp, + ), + titleSmall = TextStyle( + fontWeight = FontWeight.W600, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + // Body text — comfortable readability + bodyLarge = TextStyle( + fontWeight = FontWeight.W500, + fontSize = 16.sp, + lineHeight = 24.sp, + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.W400, + fontSize = 14.sp, + lineHeight = 21.sp, + ), + bodySmall = TextStyle( + fontWeight = FontWeight.W400, + fontSize = 12.sp, + lineHeight = 18.sp, + letterSpacing = 0.2.sp, + ), + // Labels, chips, badges — punchy and clear + labelLarge = TextStyle( + fontWeight = FontWeight.W700, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.4.sp, + ), + labelMedium = TextStyle( + fontWeight = FontWeight.W600, + fontSize = 12.sp, + lineHeight = 17.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = TextStyle( + fontWeight = FontWeight.W600, + fontSize = 11.sp, + lineHeight = 16.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/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..d87842c --- /dev/null +++ b/app/src/main/java/com/focusblocker/app/util/BatteryUtils.kt @@ -0,0 +1,14 @@ +package com.focusblocker.app.util + +import android.content.Context +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 { + 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 636165b..0000000 --- a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,46 +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 eed310a..0000000 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,186 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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..220d1cb --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + 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..3fc8a3b --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + 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..c78bee3 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,21 +1,6 @@ - - - + + 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..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,21 +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 c209e78..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ 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 b2dfe3d..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ 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 4f0f1d6..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp deleted file mode 100644 index 62b611d..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ 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 948a307..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ 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 1b9a695..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 28d4b77..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9287f50..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index aa7d642..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9126ae3..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..999a505 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,8 @@ + + + + #0D0D12 + + + #0D0D12 + 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/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 @@ - + + diff --git a/gradle.properties b/gradle.properties index e52b54e..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=-Xmx1536m -XX:MaxMetaspaceSize=512m -Dkotlin.daemon.jvm.options="-Xmx512m" +org.gradle.parallel=false +org.gradle.unsafe.configuration-cache=false \ 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" } 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