diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..87030c9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,33 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '[BUG] ' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '...' +3. Scroll down to '...' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots/Recordings** +If applicable, add screenshots or screen recordings to help explain your problem. + +**Smartphone (please complete the following information):** +- Device: [e.g. Pixel 8] +- OS Version: [e.g. Android 14] +- Hush Version: [e.g. v1.0.0] +- AICore / Gemini Nano model status (downloaded/not downloaded): + +**Additional context/Logs** +Add any other context or logcat output here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..b7ba56f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '[FEATURE] ' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context, mockup drawings, or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..2ff73c7 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,25 @@ +## Description +Describe the changes you made and the rationale behind them. Include context, screenshots/videos if relevant, and details about the implementation. + +## Related Issue +Fixes # (issue number) + +## Type of Change +Please delete options that are not relevant: +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Chore / Documentation / Refactoring + +## How Has This Been Tested? +Please describe the tests that you ran to verify your changes: +- [ ] Unit tests (e.g. `./gradlew testDebugUnitTest`) +- [ ] Instrumented tests (e.g. `./gradlew connectedAndroidTest`) +- [ ] Manual testing (please describe target device and scenario) + +## Checklist +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] My changes generate no new warnings +- [ ] New and existing unit tests pass locally with my changes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..77726ed --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,68 @@ +# Contributing to Hush + +First off, thank you for taking the time to contribute! Contributions from the community help make Hush better for everyone. + +Here is a guide to help you get started with contributing. + +--- + +## How Can I Contribute? + +### 1. Reporting Bugs +* Check the [Issues](https://github.com/vssinghh/hush/issues) tab to see if the bug has already been reported. +* If not, open a new issue using the **Bug Report** template. +* Include clear steps to reproduce the issue, and if possible, logs (logcat) or screenshots. + +### 2. Suggesting Features +* Open an issue using the **Feature Request** template. +* Explain the feature's value and how it fits Hush's privacy-first on-device AI model. + +### 3. Submitting Pull Requests +* Fork the repository and create your branch from `main`. +* If you're fixing a bug or adding a feature, please link it to an existing open issue. +* Keep your commits focused and write descriptive commit messages. +* Ensure all tests pass before submitting. + +--- + +## Development Setup + +### Prerequisites +* **JDK 17** +* **Android SDK Platform 35** +* A device supporting **Gemini Nano** (Pixel 6+ or similar with AICore initialized). + +### Building the Project +You can build the project from the command line: + +```bash +# Clone your fork +git clone https://github.com/YOUR-USERNAME/hush.git +cd hush + +# Build debug APK +./gradlew assembleDebug +``` + +### Running Tests +Make sure unit tests pass before you submit code: + +```bash +# Run unit tests +./gradlew testDebugUnitTest + +# Run instrumented tests (requires connected device/emulator) +./gradlew connectedAndroidTest +``` + +--- + +## Code Style & Architecture +Hush follows **Clean Architecture** principles: +* **Domain Layer**: Contains use cases and pure interfaces. No Android dependencies. +* **Data Layer**: Room DB, Gemini Nano integration, and repository implementations. +* **UI Layer**: Jetpack Compose and ViewModels. + +Please follow standard Kotlin coding conventions and formatting. Keep architecture boundaries clean (e.g. do not leak UI/Android components into the Domain layer). + +Thank you for supporting open source! diff --git a/README.md b/README.md index eece97d..6b3cce8 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ I built Hush because Android's notification settings are buried and rigid. With - **Natural language rules.** Tell Hush *"Mute WhatsApp notifications except from Bob"* and it builds the rule for you. - **Voice input.** Tap the mic, say what you want. A live waveform confirms it's listening. -- **Block, Mute, or Allow** — three actions per rule. +- **Block, Mute, or Allow** — three actions per rule. Blocked notifications are dismissed instantly; muted ones are snoozed out of your shade. +- **Works everywhere.** On devices without Gemini Nano, a built-in deterministic parser handles common commands — no AI required. - **Inverted / exception rules.** *"Block all from Gmail except @company.com"* works exactly how you'd expect. - **Time windows** so rules only fire during certain hours (e.g., 10 PM – 7 AM). - Full **notification history** — see what got filtered and which rule matched. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 024dcb0..47056e3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -94,6 +94,12 @@ tasks.withType().configureEach { dependencies { + constraints { + // androidx.test 1.7/espresso 3.7 need these transitively; lift the main + // runtime graph so AGP's consistent resolution doesn't conflict. + implementation("androidx.tracing:tracing:1.1.0") + implementation("androidx.concurrent:concurrent-futures:1.2.0") + } implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.viewmodel.compose) @@ -105,6 +111,7 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) debugImplementation(libs.androidx.compose.ui.tooling) // Navigation diff --git a/app/src/androidTest/java/com/hush/app/e2e/AppFoundationE2ETest.kt b/app/src/androidTest/java/com/hush/app/e2e/AppFoundationE2ETest.kt index bd609d6..6d4e802 100644 --- a/app/src/androidTest/java/com/hush/app/e2e/AppFoundationE2ETest.kt +++ b/app/src/androidTest/java/com/hush/app/e2e/AppFoundationE2ETest.kt @@ -276,10 +276,11 @@ class AppFoundationE2ETest { onboardingPrefs.isOnboardingCompleted = true recreateActivityAndWait("ai_unsupported_banner") - // Expected Result: Persistent banner is shown and chat buttons are disabled + // Expected Result: Persistent banner is shown; chat stays usable via + // the built-in fallback parser (basic mode) composeRule.onNodeWithTag("ai_unsupported_banner").assertIsDisplayed() - composeRule.onNodeWithTag("chat_send_button").assertIsNotEnabled() - composeRule.onNodeWithTag("chat_mic_button").assertIsNotEnabled() + composeRule.onNodeWithTag("chat_send_button").assertIsEnabled() + composeRule.onNodeWithTag("chat_mic_button").assertIsEnabled() } @Test @@ -304,6 +305,7 @@ class AppFoundationE2ETest { fun testOnboarding_BatteryOptimizationRejected_AllowsProgressWithWarning() { // T2_F1_05: Verify battery optimization denial does not block onboarding onboardingPrefs.isOnboardingCompleted = false + (permissionManager as FakePermissionManager).grantBatteryOnRequest = false recreateActivityAndWait("onboarding_screen") // Go to onboarding diff --git a/app/src/androidTest/java/com/hush/app/e2e/ConversationalAIE2ETest.kt b/app/src/androidTest/java/com/hush/app/e2e/ConversationalAIE2ETest.kt index ab255e7..368498a 100644 --- a/app/src/androidTest/java/com/hush/app/e2e/ConversationalAIE2ETest.kt +++ b/app/src/androidTest/java/com/hush/app/e2e/ConversationalAIE2ETest.kt @@ -100,7 +100,9 @@ class ConversationalAIE2ETest { } // Expected Result: Chat bubble and proposed rule card displayed - composeRule.onNodeWithText("Mute WhatsApp").assertIsDisplayed() + // ("Mute WhatsApp" appears both as the user's bubble and as the card's + // rule summary, so assert on the first match.) + composeRule.onAllNodesWithText("Mute WhatsApp").onFirst().assertIsDisplayed() composeRule.onNodeWithTag("ai_rule_card").assertIsDisplayed() } diff --git a/app/src/androidTest/java/com/hush/app/e2e/CrossFeatureE2ETest.kt b/app/src/androidTest/java/com/hush/app/e2e/CrossFeatureE2ETest.kt index 95b78e7..0ac899a 100644 --- a/app/src/androidTest/java/com/hush/app/e2e/CrossFeatureE2ETest.kt +++ b/app/src/androidTest/java/com/hush/app/e2e/CrossFeatureE2ETest.kt @@ -353,9 +353,13 @@ class CrossFeatureE2ETest { ) logDao.insertLog(log) - // Delete the rule + // Delete the rule (swipe now asks for confirmation) composeRule.onNodeWithTag("bottom_nav_rules").performClick() composeRule.onNodeWithTag("rule_card_206").performTouchInput { swipeLeft() } + composeRule.waitUntil(10000) { + composeRule.onAllNodesWithTag("rule_delete_confirm_button").fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithTag("rule_delete_confirm_button").performClick() // Wait for rule card deletion in UI/DB composeRule.waitUntil(10000) { @@ -371,9 +375,9 @@ class CrossFeatureE2ETest { } composeRule.onNodeWithText("Rule deletion test").performClick() - // Expected Result: Detail renders fallback placeholder "Rule deleted" instead of crashing + // Expected Result: Detail renders a "(deleted)" fallback instead of crashing composeRule.onNodeWithTag("history_detail_dialog").assertIsDisplayed() - composeRule.onNodeWithText("Triggered by Rule: Rule deleted").assertIsDisplayed() + composeRule.onNodeWithText("Temp Rule (deleted)").assertIsDisplayed() } } } diff --git a/app/src/androidTest/java/com/hush/app/e2e/RuleManagementHistoryE2ETest.kt b/app/src/androidTest/java/com/hush/app/e2e/RuleManagementHistoryE2ETest.kt index 69062c6..05af613 100644 --- a/app/src/androidTest/java/com/hush/app/e2e/RuleManagementHistoryE2ETest.kt +++ b/app/src/androidTest/java/com/hush/app/e2e/RuleManagementHistoryE2ETest.kt @@ -153,11 +153,17 @@ class RuleManagementHistoryE2ETest { // Open Rules screen composeRule.onNodeWithTag("bottom_nav_rules").performClick() - // Swipe left on the rule card + // Swipe left on the rule card — this now asks for confirmation composeRule.onNodeWithTag("rule_card_102").performTouchInput { swipeLeft() } + // Confirm deletion in the dialog + composeRule.waitUntil(10000) { + composeRule.onAllNodesWithTag("rule_delete_confirm_button").fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithTag("rule_delete_confirm_button").performClick() + // Verify the rule card is removed from UI and DB composeRule.onNodeWithTag("rule_card_102").assertDoesNotExist() val deletedRule = ruleDao.getRuleById(102L) @@ -196,7 +202,7 @@ class RuleManagementHistoryE2ETest { // Expected Result: Detail dialog opens composeRule.onNodeWithTag("rule_detail_dialog").assertIsDisplayed() composeRule.onNode(hasText("Mute WhatsApp") and hasAnyAncestor(hasTestTag("rule_detail_dialog"))).assertIsDisplayed() - composeRule.onNodeWithText("Package: com.whatsapp").assertIsDisplayed() + composeRule.onNode(hasText("WhatsApp") and hasAnyAncestor(hasTestTag("rule_detail_dialog"))).assertIsDisplayed() // Dismiss dialog composeRule.onNodeWithText("Close").performClick() @@ -215,6 +221,11 @@ class RuleManagementHistoryE2ETest { // Open History screen composeRule.onNodeWithTag("bottom_nav_history").performClick() + // Wait for the Room flow to emit before counting + composeRule.waitUntil(10000) { + composeRule.onAllNodesWithText("Blocked text").fetchSemanticsNodes().isNotEmpty() + } + // Verify all 3 items are present composeRule.onNodeWithTag("history_list").onChildren().assertCountEquals(3) @@ -222,6 +233,9 @@ class RuleManagementHistoryE2ETest { composeRule.onNodeWithTag("history_tab_blocked").performClick() // Verify only blocked item is visible + composeRule.waitUntil(10000) { + composeRule.onAllNodesWithText("Allowed text").fetchSemanticsNodes().isEmpty() + } composeRule.onNodeWithText("Blocked text").assertIsDisplayed() composeRule.onNodeWithText("Allowed text").assertDoesNotExist() @@ -229,6 +243,9 @@ class RuleManagementHistoryE2ETest { composeRule.onNodeWithTag("history_tab_all").performClick() // Verify all 3 visible + composeRule.waitUntil(10000) { + composeRule.onAllNodesWithText("Allowed text").fetchSemanticsNodes().isNotEmpty() + } composeRule.onNodeWithTag("history_list").onChildren().assertCountEquals(3) } @@ -265,7 +282,7 @@ class RuleManagementHistoryE2ETest { // Expected Result: Detail modal opens, showing rule name composeRule.onNodeWithTag("history_detail_dialog").assertIsDisplayed() - composeRule.onNodeWithText("Triggered by Rule: Block Spam").assertIsDisplayed() + composeRule.onNode(hasText("Block Spam") and hasAnyAncestor(hasTestTag("history_detail_dialog"))).assertIsDisplayed() // Dismiss dialog composeRule.onNodeWithText("Close").performClick() diff --git a/app/src/androidTest/java/com/hush/app/mock/FakePermissionManager.kt b/app/src/androidTest/java/com/hush/app/mock/FakePermissionManager.kt index 5878e9c..2c66e27 100644 --- a/app/src/androidTest/java/com/hush/app/mock/FakePermissionManager.kt +++ b/app/src/androidTest/java/com/hush/app/mock/FakePermissionManager.kt @@ -19,6 +19,9 @@ class FakePermissionManager @Inject constructor( var batteryExempt = false var notificationDenied = false + /** Set false to simulate the user declining the battery-exemption dialog. */ + var grantBatteryOnRequest = true + private val prefs by lazy { context.getSharedPreferences("hush_preferences", Context.MODE_PRIVATE) } @@ -41,7 +44,9 @@ class FakePermissionManager @Inject constructor( } override fun requestBatteryExemption(context: Context) { - batteryExempt = true + if (grantBatteryOnRequest) { + batteryExempt = true + } } override fun setNotificationAccessDenied(denied: Boolean) { diff --git a/app/src/main/java/com/hush/app/domain/model/ChatMessage.kt b/app/src/main/java/com/hush/app/domain/model/ChatMessage.kt new file mode 100644 index 0000000..55f2c59 --- /dev/null +++ b/app/src/main/java/com/hush/app/domain/model/ChatMessage.kt @@ -0,0 +1,13 @@ +package com.hush.app.domain.model + +import java.time.LocalTime + +enum class ChatRole { + USER, ASSISTANT +} + +data class ChatMessage( + val text: String, + val role: ChatRole, + val time: LocalTime = LocalTime.now() +) diff --git a/app/src/main/java/com/hush/app/domain/model/ParsedCommand.kt b/app/src/main/java/com/hush/app/domain/model/ParsedCommand.kt index 77b1052..6033542 100644 --- a/app/src/main/java/com/hush/app/domain/model/ParsedCommand.kt +++ b/app/src/main/java/com/hush/app/domain/model/ParsedCommand.kt @@ -11,5 +11,6 @@ data class ParsedCommand( val isInverted: Boolean, val timeStart: LocalTime?, val timeEnd: LocalTime?, - val summary: String + val summary: String, + val originalPrompt: String? = null ) diff --git a/app/src/main/java/com/hush/app/domain/usecase/FallbackCommandParser.kt b/app/src/main/java/com/hush/app/domain/usecase/FallbackCommandParser.kt new file mode 100644 index 0000000..d95c9c5 --- /dev/null +++ b/app/src/main/java/com/hush/app/domain/usecase/FallbackCommandParser.kt @@ -0,0 +1,200 @@ +package com.hush.app.domain.usecase + +import com.hush.app.domain.model.MatchField +import com.hush.app.domain.model.MatchType +import com.hush.app.domain.model.ParsedCommand +import com.hush.app.domain.model.RuleAction +import com.hush.app.domain.repository.AppInfo +import java.time.LocalTime +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Deterministic, regex-based command parser used when Gemini Nano is not + * available on the device. Handles the common command shapes so the app is + * fully usable without on-device AI: + * + * "Mute Instagram", "Block Slack after 6pm", "Silence promos", + * "Mute WhatsApp except from Bob", "Block emails containing invoice", + * "Mute Slack between 10pm and 7am" + */ +@Singleton +class FallbackCommandParser @Inject constructor() { + + fun parse(prompt: String, installedApps: List): ParsedCommand { + val text = prompt.trim() + val lower = text.lowercase() + + val action = parseAction(lower) + val (timeStart, timeEnd, lowerWithoutTime) = parseTimeWindow(lower) + + // "except (from) X" → inverted rule + val exceptMatch = EXCEPT_REGEX.find(lowerWithoutTime) + val exceptPattern = exceptMatch?.groupValues?.get(2)?.trim()?.trimEnd('.', '!', ',') + val isFromException = exceptMatch?.groupValues?.get(1)?.isNotBlank() == true + + // "containing/with/about X" → content pattern + val containsMatch = CONTAINS_REGEX.find(lowerWithoutTime) + val containsPattern = containsMatch?.groupValues?.get(1)?.trim()?.trimEnd('.', '!', ',') + + val app = findApp(lowerWithoutTime, installedApps) + + // Well-known content categories ("promos", "promotions", "sales", "offers") + val categoryPattern = CATEGORY_KEYWORDS.entries + .firstOrNull { (keyword, _) -> Regex("\\b$keyword\\b").containsMatchIn(lowerWithoutTime) } + ?.value + + val (matchField, matchType, matchPattern, isInverted) = when { + exceptPattern != null -> Quad( + if (isFromException) MatchField.SENDER else MatchField.ANY, + MatchType.CONTAINS, + exceptPattern, + true + ) + containsPattern != null -> Quad(MatchField.ANY, MatchType.CONTAINS, containsPattern, false) + categoryPattern != null -> Quad(MatchField.ANY, MatchType.CONTAINS, categoryPattern, false) + else -> Quad(MatchField.ANY, MatchType.CONTAINS, null, false) + } + + if (app == null && matchPattern == null) { + throw IllegalArgumentException( + "Couldn't understand that command. Try something like \"Mute Instagram\" or \"Block Slack after 6pm\"." + ) + } + + return ParsedCommand( + action = action, + app = app?.packageName, + matchField = matchField, + matchType = matchType, + matchPattern = matchPattern, + isInverted = isInverted, + timeStart = timeStart, + timeEnd = timeEnd, + summary = buildSummary(action, app, matchField, matchPattern, isInverted, timeStart, timeEnd) + ) + } + + private fun parseAction(lower: String): RuleAction = when { + BLOCK_REGEX.containsMatchIn(lower) -> RuleAction.BLOCK + ALLOW_REGEX.containsMatchIn(lower) -> RuleAction.ALLOW + else -> RuleAction.MUTE + } + + private fun findApp(lower: String, installedApps: List): AppInfo? { + // Longest display-name match wins ("Slack" should not match inside "slacker") + return installedApps + .filter { it.displayName.length >= 3 } + .filter { app -> + Regex("\\b${Regex.escape(app.displayName.lowercase())}\\b").containsMatchIn(lower) + } + .maxByOrNull { it.displayName.length } + } + + /** Returns (timeStart, timeEnd, prompt with the time expression removed). */ + private fun parseTimeWindow(lower: String): Triple { + BETWEEN_REGEX.find(lower)?.let { m -> + val start = parseClock(m.groupValues[1]) + val end = parseClock(m.groupValues[2]) + if (start != null && end != null) { + return Triple(start, end, lower.replace(m.value, " ")) + } + } + AFTER_REGEX.find(lower)?.let { m -> + parseClock(m.groupValues[1])?.let { start -> + return Triple(start, null, lower.replace(m.value, " ")) + } + } + BEFORE_REGEX.find(lower)?.let { m -> + parseClock(m.groupValues[1])?.let { end -> + return Triple(null, end, lower.replace(m.value, " ")) + } + } + return Triple(null, null, lower) + } + + /** Parses "6pm", "6 pm", "10:30pm", "22:00", "7am". */ + private fun parseClock(raw: String): LocalTime? { + val m = CLOCK_REGEX.find(raw.trim()) ?: return null + var hour = m.groupValues[1].toIntOrNull() ?: return null + val minute = m.groupValues[2].toIntOrNull() ?: 0 + val meridiem = m.groupValues[3] + when (meridiem) { + "pm" -> if (hour < 12) hour += 12 + "am" -> if (hour == 12) hour = 0 + } + if (hour !in 0..23 || minute !in 0..59) return null + return LocalTime.of(hour, minute) + } + + private fun buildSummary( + action: RuleAction, + app: AppInfo?, + matchField: MatchField, + matchPattern: String?, + isInverted: Boolean, + timeStart: LocalTime?, + timeEnd: LocalTime? + ): String { + val verb = when (action) { + RuleAction.BLOCK -> "Block" + RuleAction.MUTE -> "Mute" + RuleAction.ALLOW -> "Allow" + } + val target = app?.displayName ?: "all apps" + val sb = StringBuilder("$verb $target notifications") + if (matchPattern != null) { + val fieldLabel = if (matchField == MatchField.SENDER) "from" else "matching" + sb.append(if (isInverted) " except $fieldLabel \"$matchPattern\"" else " $fieldLabel \"$matchPattern\"") + } + if (timeStart != null && timeEnd != null) { + sb.append(" between ${formatTime(timeStart)} and ${formatTime(timeEnd)}") + } else if (timeStart != null) { + sb.append(" after ${formatTime(timeStart)}") + } else if (timeEnd != null) { + sb.append(" before ${formatTime(timeEnd)}") + } + return sb.toString() + } + + private fun formatTime(t: LocalTime): String { + val hour12 = when { + t.hour == 0 -> 12 + t.hour > 12 -> t.hour - 12 + else -> t.hour + } + val suffix = if (t.hour < 12) "AM" else "PM" + return if (t.minute == 0) "$hour12 $suffix" else "$hour12:${"%02d".format(t.minute)} $suffix" + } + + private data class Quad( + val field: MatchField, + val type: MatchType, + val pattern: String?, + val inverted: Boolean + ) + + companion object { + private val BLOCK_REGEX = Regex("\\b(block|stop|dismiss|kill|remove)\\b") + private val ALLOW_REGEX = Regex("\\b(allow|unmute|unblock|let through|whitelist)\\b") + private val EXCEPT_REGEX = Regex("\\bexcept\\s+(from\\s+)?(.+)$") + private val CONTAINS_REGEX = Regex("\\b(?:containing|that contain[s]?|with the word[s]?|mentioning|about)\\s+[\"']?([\\w@.\\- ]+?)[\"']?$") + private val BETWEEN_REGEX = Regex("\\bbetween\\s+([\\w:]+\\s?(?:am|pm)?)\\s+(?:and|-|to)\\s+([\\w:]+\\s?(?:am|pm)?)") + private val AFTER_REGEX = Regex("\\b(?:after|past|from)\\s+(\\d{1,2}(?::\\d{2})?\\s?(?:am|pm)?)(?=\\s|$)") + private val BEFORE_REGEX = Regex("\\b(?:before|until|till)\\s+(\\d{1,2}(?::\\d{2})?\\s?(?:am|pm)?)(?=\\s|$)") + private val CLOCK_REGEX = Regex("(\\d{1,2})(?::(\\d{2}))?\\s?(am|pm)?") + + private val CATEGORY_KEYWORDS = mapOf( + "promos" to "promo", + "promotions" to "promo", + "promotional" to "promo", + "sales" to "sale", + "offers" to "offer", + "deals" to "deal", + "marketing" to "marketing", + "spam" to "spam", + "otp" to "otp", + "newsletters" to "newsletter" + ) + } +} diff --git a/app/src/main/java/com/hush/app/domain/usecase/ParseCommandUseCase.kt b/app/src/main/java/com/hush/app/domain/usecase/ParseCommandUseCase.kt index e489a2d..78ed5c0 100644 --- a/app/src/main/java/com/hush/app/domain/usecase/ParseCommandUseCase.kt +++ b/app/src/main/java/com/hush/app/domain/usecase/ParseCommandUseCase.kt @@ -9,13 +9,20 @@ class ParseCommandUseCase @Inject constructor( private val aiEngine: AIEngine, private val packageResolver: PackageResolver ) { + private val fallbackParser = FallbackCommandParser() + suspend fun execute(prompt: String): ParsedCommand { if (prompt.isBlank()) { throw IllegalArgumentException("Prompt cannot be empty") } - // 1. Call AI Engine for parsing - val parsed = aiEngine.parseCommand(prompt) + // 1. Parse: prefer on-device AI, fall back to the deterministic parser + // when Gemini Nano is unavailable on this device. + val parsed = if (aiEngine.isAvailable()) { + aiEngine.parseCommand(prompt) + } else { + fallbackParser.parse(prompt, packageResolver.getInstalledApps()) + } // 2. Perform validation on required fields if (parsed.summary.isBlank() || parsed.summary == "MALFORMED_JSON_TRIGGER") { diff --git a/app/src/main/java/com/hush/app/service/HushNotificationListener.kt b/app/src/main/java/com/hush/app/service/HushNotificationListener.kt index 0fe0598..3b7e79b 100644 --- a/app/src/main/java/com/hush/app/service/HushNotificationListener.kt +++ b/app/src/main/java/com/hush/app/service/HushNotificationListener.kt @@ -59,6 +59,14 @@ class HushNotificationListener : NotificationListenerService() { if (sbn == null) return if (!ensureInjected()) return + // Never evaluate our own notifications, and leave ongoing/foreground + // notifications (media players, navigation, etc.) alone — they can't + // be dismissed and shouldn't be filtered. + if (sbn.packageName == packageName) return + if (sbn.isOngoing || + (sbn.notification.flags and Notification.FLAG_FOREGROUND_SERVICE) != 0 + ) return + serviceScope.launch { try { val packageName = sbn.packageName @@ -115,10 +123,19 @@ class HushNotificationListener : NotificationListenerService() { Log.d("HushNotificationListener", ">> Result: action=$action for pkg=$packageName") - // Dismiss notification if matched rule action is BLOCK - if (action == RuleAction.BLOCK) { - cancelNotification(sbn.key) - Log.d("HushNotificationListener", ">> BLOCKED notification from $packageName") + when (action) { + // BLOCK: dismiss the notification entirely + RuleAction.BLOCK -> { + cancelNotification(sbn.key) + Log.d("HushNotificationListener", ">> BLOCKED notification from $packageName") + } + // MUTE: snooze it out of the shade for a while; it comes + // back later instead of interrupting right now + RuleAction.MUTE -> { + snoozeNotification(sbn.key, MUTE_SNOOZE_MS) + Log.d("HushNotificationListener", ">> MUTED (snoozed) notification from $packageName") + } + RuleAction.ALLOW -> Unit } } catch (e: Exception) { Log.e("HushNotificationListener", "Error evaluating notification: ${e.message}", e) @@ -129,4 +146,10 @@ class HushNotificationListener : NotificationListenerService() { override fun onNotificationRemoved(sbn: StatusBarNotification?) { // Optional tracking of removed notifications } + + companion object { + // Muted notifications are snoozed for an hour at a time; if the rule + // still matches when they re-post, they get snoozed again. + private const val MUTE_SNOOZE_MS = 60L * 60L * 1000L + } } diff --git a/app/src/main/java/com/hush/app/ui/components/HushComponents.kt b/app/src/main/java/com/hush/app/ui/components/HushComponents.kt new file mode 100644 index 0000000..80e835e --- /dev/null +++ b/app/src/main/java/com/hush/app/ui/components/HushComponents.kt @@ -0,0 +1,201 @@ +package com.hush.app.ui.components + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.material3.Icon +import com.hush.app.ui.theme.AccentPurple + +/** Gradient used for brand moments (logo chip, hero icons, user bubbles). */ +val HushGradient = Brush.linearGradient( + listOf(Color(0xFF7C5CFC), Color(0xFF9E7BFF), Color(0xFF5CA8FC)) +) + +/** + * Large screen header used at the top of every tab: bold title, quiet + * subtitle, and an optional trailing element (status pill, count badge). + */ +@Composable +fun HushHeader( + title: String, + subtitle: String, + modifier: Modifier = Modifier, + leadingIcon: ImageVector? = null, + trailing: (@Composable () -> Unit)? = null +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (leadingIcon != null) { + Box( + modifier = Modifier + .size(42.dp) + .clip(RoundedCornerShape(14.dp)) + .background(HushGradient), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(22.dp) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(1.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (trailing != null) { + Spacer(modifier = Modifier.width(8.dp)) + trailing() + } + } +} + +/** Small colored status pill, e.g. "● On-device AI". */ +@Composable +fun StatusPill( + label: String, + color: Color, + background: Color, + modifier: Modifier = Modifier +) { + Surface( + shape = CircleShape, + color = background, + modifier = modifier + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp) + ) { + Box( + modifier = Modifier + .size(7.dp) + .clip(CircleShape) + .background(color) + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = color + ) + } + } +} + +/** Tiny labeled chip used inside cards to show rule attributes. */ +@Composable +fun AttributeChip( + icon: ImageVector?, + label: String, + color: Color = AccentPurple, + modifier: Modifier = Modifier +) { + Surface( + shape = RoundedCornerShape(8.dp), + color = color.copy(alpha = 0.12f), + modifier = modifier + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(13.dp) + ) + } + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = color + ) + } + } +} + +/** Shared empty-state layout: soft icon disc, title, and helper text. */ +@Composable +fun EmptyState( + icon: ImageVector, + title: String, + message: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.padding(horizontal = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(88.dp) + .clip(CircleShape) + .background(AccentPurple.copy(alpha = 0.10f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = AccentPurple.copy(alpha = 0.8f) + ) + } + Spacer(modifier = Modifier.height(20.dp)) + Text( + title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } +} diff --git a/app/src/main/java/com/hush/app/ui/navigation/ScreenRoute.kt b/app/src/main/java/com/hush/app/ui/navigation/ScreenRoute.kt index 0ab3257..9880483 100644 --- a/app/src/main/java/com/hush/app/ui/navigation/ScreenRoute.kt +++ b/app/src/main/java/com/hush/app/ui/navigation/ScreenRoute.kt @@ -1,10 +1,14 @@ package com.hush.app.ui.navigation import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.DateRange -import androidx.compose.material.icons.filled.List -import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.automirrored.outlined.Chat +import androidx.compose.material.icons.filled.FilterAlt +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.outlined.FilterAlt +import androidx.compose.material.icons.outlined.History +import androidx.compose.material.icons.outlined.Settings import androidx.compose.ui.graphics.vector.ImageVector sealed class ScreenRoute(val route: String) { @@ -15,10 +19,27 @@ sealed class ScreenRoute(val route: String) { sealed class BottomTabRoute( val route: String, val title: String, - val icon: ImageVector + val icon: ImageVector, + val selectedIcon: ImageVector ) { - object Chat : BottomTabRoute("chat", "Chat", Icons.Default.Send) - object Rules : BottomTabRoute("rules", "Rules", Icons.Default.List) - object History : BottomTabRoute("history", "History", Icons.Default.DateRange) - object Settings : BottomTabRoute("settings", "Settings", Icons.Default.Settings) + object Chat : BottomTabRoute( + "chat", "Chat", + Icons.AutoMirrored.Outlined.Chat, + Icons.AutoMirrored.Filled.Chat + ) + object Rules : BottomTabRoute( + "rules", "Rules", + Icons.Outlined.FilterAlt, + Icons.Filled.FilterAlt + ) + object History : BottomTabRoute( + "history", "History", + Icons.Outlined.History, + Icons.Filled.History + ) + object Settings : BottomTabRoute( + "settings", "Settings", + Icons.Outlined.Settings, + Icons.Filled.Settings + ) } diff --git a/app/src/main/java/com/hush/app/ui/screens/MainScreen.kt b/app/src/main/java/com/hush/app/ui/screens/MainScreen.kt index f59dc06..bbaae3a 100644 --- a/app/src/main/java/com/hush/app/ui/screens/MainScreen.kt +++ b/app/src/main/java/com/hush/app/ui/screens/MainScreen.kt @@ -1,22 +1,28 @@ package com.hush.app.ui.screens +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp 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 androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition import com.hush.app.ui.navigation.BottomTabRoute import com.hush.app.ui.screens.chat.ChatScreen import com.hush.app.ui.screens.history.HistoryScreen @@ -38,15 +44,36 @@ fun MainScreen( Scaffold( bottomBar = { - NavigationBar { + NavigationBar( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 0.dp + ) { val navBackStackEntry by childNavController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route tabs.forEach { tab -> + val selected = currentRoute == tab.route NavigationBarItem( - icon = { Icon(tab.icon, contentDescription = tab.title) }, - label = { Text(tab.title) }, - selected = currentRoute == tab.route, + icon = { + Icon( + if (selected) tab.selectedIcon else tab.icon, + contentDescription = tab.title + ) + }, + label = { + Text( + tab.title, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium + ) + }, + selected = selected, + colors = NavigationBarItemDefaults.colors( + indicatorColor = MaterialTheme.colorScheme.secondaryContainer, + selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + selectedTextColor = MaterialTheme.colorScheme.onSurface, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant + ), onClick = { childNavController.navigate(tab.route) { popUpTo(childNavController.graph.findStartDestination().id) { @@ -67,8 +94,11 @@ fun MainScreen( navController = childNavController, startDestination = BottomTabRoute.Chat.route, modifier = Modifier.padding(innerPadding), - enterTransition = { EnterTransition.None }, - exitTransition = { ExitTransition.None } + // Gentle fade + settle between tabs — modern, not distracting + enterTransition = { + fadeIn(tween(220)) + scaleIn(initialScale = 0.985f, animationSpec = tween(220)) + }, + exitTransition = { fadeOut(tween(120)) } ) { composable(BottomTabRoute.Chat.route) { ChatScreen() diff --git a/app/src/main/java/com/hush/app/ui/screens/chat/ChatScreen.kt b/app/src/main/java/com/hush/app/ui/screens/chat/ChatScreen.kt index 823ed31..33f8ab7 100644 --- a/app/src/main/java/com/hush/app/ui/screens/chat/ChatScreen.kt +++ b/app/src/main/java/com/hush/app/ui/screens/chat/ChatScreen.kt @@ -13,20 +13,27 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.BorderStroke -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.keyframes import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.SwapHoriz +import androidx.compose.material.icons.outlined.VolumeOff +import androidx.compose.material.icons.outlined.DoneAll import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -37,17 +44,25 @@ import androidx.compose.ui.unit.offset import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel -import com.hush.app.domain.model.Rule +import com.hush.app.domain.model.ChatMessage +import com.hush.app.domain.model.ChatRole +import com.hush.app.domain.model.MatchField +import com.hush.app.domain.model.ParsedCommand +import com.hush.app.domain.model.RuleAction import com.hush.app.domain.repository.AIStatus +import com.hush.app.ui.components.AttributeChip +import com.hush.app.ui.components.HushGradient +import com.hush.app.ui.components.HushHeader +import com.hush.app.ui.components.StatusPill import com.hush.app.ui.theme.* -import java.time.LocalTime import java.time.format.DateTimeFormatter @OptIn(ExperimentalMaterial3Api::class) @@ -57,11 +72,10 @@ fun ChatScreen( viewModel: ChatViewModel = hiltViewModel() ) { val aiEngine = viewModel.aiEngine - val ruleRepository = viewModel.ruleRepository val permissionManager = viewModel.permissionManager val context = LocalContext.current - val mockMessages = viewModel.mockMessages + val messages = viewModel.messages val proposedRule = viewModel.proposedRule.value val errorMessage = viewModel.errorMessage.value val isListening = viewModel.isListening.value @@ -69,7 +83,6 @@ fun ChatScreen( val textState = viewModel.textState.value val aiStatus by aiEngine.status.collectAsState() - val downloadProgress by aiEngine.downloadProgress.collectAsState() val aiErrorMessage by aiEngine.errorMessage.collectAsState() val permissionLauncher = rememberLauncherForActivityResult( @@ -83,7 +96,17 @@ fun ChatScreen( } val timeFormatter = remember { DateTimeFormatter.ofPattern("h:mm a") } - val currentTime = remember { LocalTime.now().format(timeFormatter) } + val listState = rememberLazyListState() + + // Keep the conversation pinned to the latest content + LaunchedEffect(messages.size, isProcessing, proposedRule, errorMessage, isListening) { + val count = listState.layoutInfo.totalItemsCount + if (count > 0) listState.animateScrollToItem(count - 1) + } + + // Input is usable whenever we're not still probing the device: with + // Gemini Nano we parse with AI, otherwise the built-in parser takes over. + val inputEnabled = aiStatus != AIStatus.CHECKING Scaffold( modifier = modifier.testTag("chat_screen"), @@ -94,8 +117,19 @@ fun ChatScreen( .fillMaxSize() .padding(innerPadding) ) { - // Top spacing - Spacer(modifier = Modifier.height(8.dp)) + HushHeader( + title = "Hush", + subtitle = "Talk to your notifications", + leadingIcon = Icons.Outlined.NotificationsOff, + trailing = { + when (aiStatus) { + AIStatus.READY -> StatusPill("Gemini Nano", AccentGreen, AccentGreen.copy(alpha = 0.12f)) + AIStatus.CHECKING -> StatusPill("Checking…", AccentAmber, AccentAmber.copy(alpha = 0.12f)) + AIStatus.DOWNLOADING -> StatusPill("Downloading", AccentBlue, AccentBlue.copy(alpha = 0.12f)) + else -> StatusPill("Basic mode", AccentPurple, AccentPurple.copy(alpha = 0.12f)) + } + } + ) // ── Chat Messages ── LazyColumn( @@ -105,7 +139,7 @@ fun ChatScreen( .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(vertical = 8.dp), - state = rememberLazyListState() + state = listState ) { // AI Status as inline chat message @@ -138,13 +172,13 @@ fun ChatScreen( ) { Column { Text( - "Setting up on-device AI", - style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + "Set up on-device AI", + style = MaterialTheme.typography.titleSmall, color = CardOnLight ) Spacer(modifier = Modifier.height(6.dp)) Text( - "Hush needs to download the Gemini Nano AI model (~350 MB). Please make sure you're connected to WiFi and your device is up to date, then tap the button below.", + "Hush can download Gemini Nano (~350 MB) for smarter command understanding. Until then, a built-in parser handles simple commands.", style = MaterialTheme.typography.bodySmall, color = CardOnLightMuted ) @@ -173,13 +207,6 @@ fun ChatScreen( Text("Check for Updates", fontSize = 13.sp) } } - Spacer(modifier = Modifier.height(4.dp)) - Text( - currentTime, - style = MaterialTheme.typography.labelSmall, - color = CardOnLightMuted, - modifier = Modifier.align(Alignment.End) - ) } } } @@ -192,14 +219,14 @@ fun ChatScreen( Column { Text( "Downloading Gemini Nano…", - style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), - color = WarmOnSurface + style = MaterialTheme.typography.titleSmall, + color = CardOnLight ) Spacer(modifier = Modifier.height(4.dp)) Text( "This may take a few minutes. Please stay on WiFi.", style = MaterialTheme.typography.bodySmall, - color = WarmOnSurfaceVariant + color = CardOnLightMuted ) Spacer(modifier = Modifier.height(10.dp)) LinearProgressIndicator( @@ -230,7 +257,7 @@ fun ChatScreen( Text( "AI engine encountered an error", color = MaterialTheme.colorScheme.onErrorContainer, - style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold) + style = MaterialTheme.typography.titleSmall ) Text( aiErrorMessage ?: "This may be temporary. Tap Retry to try again.", @@ -255,26 +282,27 @@ fun ChatScreen( AIStatus.NOT_SUPPORTED -> { AiStatusBubble( modifier = Modifier.testTag("ai_unsupported_banner"), - containerColor = StatusBlockedBg + containerColor = AccentPurpleLight ) { Column { Row(verticalAlignment = Alignment.CenterVertically) { Icon( - imageVector = Icons.Default.Warning, - contentDescription = "Not supported", - tint = AccentRed, + imageVector = Icons.Outlined.AutoAwesome, + contentDescription = "Basic mode", + tint = AccentPurple, modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text( - "On-device AI unavailable", + "Running in basic mode", color = CardOnLight, - style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold) + style = MaterialTheme.typography.titleSmall ) } Spacer(modifier = Modifier.height(6.dp)) Text( - aiErrorMessage ?: "Gemini Nano requires a compatible device (Pixel 8+, Samsung S24+). Voice and text commands are unavailable on this device.", + aiErrorMessage + ?: "Gemini Nano isn't available on this device, so Hush uses its built-in parser. Simple commands like \"Mute Instagram\" or \"Block Slack after 6pm\" work great.", color = CardOnLightMuted, style = MaterialTheme.typography.bodySmall ) @@ -285,13 +313,13 @@ fun ChatScreen( modifier = Modifier.testTag("ai_retry_button"), shape = RoundedCornerShape(20.dp), colors = ButtonDefaults.buttonColors( - containerColor = AccentRed, - contentColor = androidx.compose.ui.graphics.Color.White + containerColor = AccentPurple, + contentColor = Color.White ) ) { Icon(Icons.Default.Refresh, contentDescription = "Retry", modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(4.dp)) - Text("Retry", fontWeight = FontWeight.SemiBold) + Text("Retry AI", fontWeight = FontWeight.SemiBold) } OutlinedButton( onClick = { viewModel.openAICoreUpdateInStore(context) }, @@ -316,14 +344,14 @@ fun ChatScreen( } // Chat message bubbles - items(mockMessages.size) { index -> - val isUser = index % 2 != 0 - // Smart timestamps: show only on first and last message - val showTimestamp = index == 0 || index == mockMessages.size - 1 + items(messages.size) { index -> + val message = messages[index] + val previous = messages.getOrNull(index - 1) + // Show timestamp when the sender changes or on the last message + val showTimestamp = previous?.role != message.role || index == messages.size - 1 ChatBubble( - message = mockMessages[index], - isUser = isUser, - timestamp = currentTime, + message = message, + timestamp = message.time.format(timeFormatter), showTimestamp = showTimestamp ) } @@ -344,12 +372,16 @@ fun ChatScreen( ) { Box( modifier = Modifier - .clip(RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp)) + .clip(RoundedCornerShape(18.dp, 18.dp, 18.dp, 6.dp)) .background(MaterialTheme.colorScheme.errorContainer) .padding(12.dp) .testTag("chat_error_message") ) { - Text(err, color = MaterialTheme.colorScheme.onErrorContainer) + Text( + err, + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyMedium + ) } } } @@ -362,7 +394,7 @@ fun ChatScreen( modifier = Modifier .fillMaxWidth() .testTag("voice_waveform_ui"), - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(18.dp), colors = CardDefaults.cardColors( containerColor = AccentPurpleLight ) @@ -374,7 +406,7 @@ fun ChatScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "Listening...", + text = "Listening…", style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium), color = AccentPurple, modifier = Modifier.padding(bottom = 8.dp) @@ -413,81 +445,24 @@ fun ChatScreen( // Show Proposed Rule Card proposedRule?.let { rule -> item { - val isInstalled = rule.app?.let { viewModel.packageResolver.isInstalled(it) } ?: true - Card( - modifier = Modifier - .fillMaxWidth() - .testTag("ai_rule_card"), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = AccentGreenLight - ) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - "Proposed Rule", - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), - color = AccentGreen - ) - Spacer(modifier = Modifier.height(8.dp)) - Text("Summary: ${rule.summary}", style = MaterialTheme.typography.bodySmall, color = CardOnLight) - Text("Action: ${rule.action}", style = MaterialTheme.typography.bodySmall, color = CardOnLightMuted) - Text("Match Field: ${rule.matchField}", style = MaterialTheme.typography.bodySmall, color = CardOnLightMuted) - Text("Match Type: ${rule.matchType}", style = MaterialTheme.typography.bodySmall, color = CardOnLightMuted) - rule.matchPattern?.let { Text("Pattern: $it", style = MaterialTheme.typography.bodySmall, color = CardOnLightMuted) } - - if (!isInstalled) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - "Warning: App package is not installed on this device.", - color = AccentRed, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.testTag("ai_rule_warning_uninstalled") - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - OutlinedButton( - onClick = { viewModel.cancelProposedRule() }, - modifier = Modifier.testTag("ai_rule_cancel"), - shape = RoundedCornerShape(20.dp), - border = BorderStroke(1.dp, CardOnLightMuted), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = CardOnLight - ) - ) { - Text("Cancel") - } - Spacer(modifier = Modifier.width(8.dp)) - Button( - onClick = { viewModel.confirmProposedRule() }, - modifier = Modifier.testTag("ai_rule_confirm"), - shape = RoundedCornerShape(20.dp), - colors = ButtonDefaults.buttonColors( - containerColor = AccentGreen, - contentColor = androidx.compose.ui.graphics.Color.White - ) - ) { - Text("Confirm", fontWeight = FontWeight.SemiBold) - } - } - } - } + ProposedRuleCard( + rule = rule, + isInstalled = rule.app?.let { viewModel.packageResolver.isInstalled(it) } ?: true, + appLabel = rule.app?.let { ChatViewModel.resolveAppDisplayName(it) }, + onConfirm = { viewModel.confirmProposedRule() }, + onCancel = { viewModel.cancelProposedRule() } + ) } } } // ── Suggestion Chips (shown only for welcome state) ── - if (mockMessages.size <= 2) { + if (messages.size <= 1) { val suggestions = listOf( "Mute Instagram", "Block Slack after 6pm", "Silence promos", - "Mute email apps" + "Mute WhatsApp except from Mom" ) LazyRow( modifier = Modifier @@ -513,10 +488,10 @@ fun ChatScreen( shape = RoundedCornerShape(20.dp), border = SuggestionChipDefaults.suggestionChipBorder( enabled = true, - borderColor = AccentPurple.copy(alpha = 0.5f) + borderColor = AccentPurple.copy(alpha = 0.4f) ), colors = SuggestionChipDefaults.suggestionChipColors( - containerColor = AccentPurpleLight, + containerColor = AccentPurple.copy(alpha = 0.08f), labelColor = AccentPurple ) ) @@ -524,51 +499,106 @@ fun ChatScreen( } } - // ── Input Bar (keeping existing design) ── + // ── Input Bar ── Row( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - OutlinedTextField( - value = textState, - onValueChange = { viewModel.textState.value = it }, - placeholder = { Text("Type command...") }, + // Pill input with the mic living inside it — one visual unit + Surface( modifier = Modifier .weight(1f) - .testTag("chat_input_field"), - shape = RoundedCornerShape(24.dp), - enabled = aiStatus == AIStatus.READY - ) - Spacer(modifier = Modifier.width(8.dp)) - FilledIconButton( - onClick = { - if (textState.isNotBlank()) { - viewModel.handleSend(textState) - } - }, - modifier = Modifier.testTag("chat_send_button"), - enabled = aiStatus == AIStatus.READY + .shadow(6.dp, RoundedCornerShape(28.dp), spotColor = Color.Black.copy(alpha = 0.35f)), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh ) { - Icon( - imageVector = Icons.Default.Send, - contentDescription = "Send" - ) - } - Spacer(modifier = Modifier.width(8.dp)) - FilledIconButton( - onClick = { - if (permissionManager.hasMicrophonePermission()) { - viewModel.toggleListening() - } else { - permissionManager.requestMicrophonePermission(permissionLauncher) + Row(verticalAlignment = Alignment.CenterVertically) { + TextField( + value = textState, + onValueChange = { viewModel.textState.value = it }, + placeholder = { + Text( + "Try \"Mute Instagram\"…", + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + ) + }, + modifier = Modifier + .weight(1f) + .testTag("chat_input_field"), + enabled = inputEnabled, + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary + ) + ) + IconButton( + onClick = { + if (permissionManager.hasMicrophonePermission()) { + viewModel.toggleListening() + } else { + permissionManager.requestMicrophonePermission(permissionLauncher) + } + }, + modifier = Modifier + .padding(end = 6.dp) + .size(40.dp) + .testTag("chat_mic_button"), + enabled = inputEnabled, + colors = IconButtonDefaults.iconButtonColors( + containerColor = if (isListening) AccentRed else Color.Transparent, + contentColor = if (isListening) Color.White + else MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Icon( + imageVector = if (isListening) Icons.Default.Stop else Icons.Default.Mic, + contentDescription = if (isListening) "Stop listening" else "Voice command", + modifier = Modifier.size(22.dp) + ) } - }, - modifier = Modifier.testTag("chat_mic_button"), - enabled = aiStatus == AIStatus.READY + } + } + // Gradient send button + Box( + modifier = Modifier + .size(52.dp) + .shadow(6.dp, CircleShape, spotColor = AccentPurple.copy(alpha = 0.5f)) + .clip(CircleShape) + .background( + if (inputEnabled) HushGradient + else SolidColor(MaterialTheme.colorScheme.surfaceContainerHigh) + ) ) { - Text("🎙️") + IconButton( + onClick = { + if (textState.isNotBlank()) { + viewModel.handleSend(textState) + } + }, + modifier = Modifier + .fillMaxSize() + .testTag("chat_send_button"), + enabled = inputEnabled, + colors = IconButtonDefaults.iconButtonColors( + contentColor = Color.White, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Send", + modifier = Modifier.size(22.dp) + ) + } } } } @@ -580,7 +610,7 @@ fun ChatScreen( @Composable private fun AiStatusBubble( modifier: Modifier = Modifier, - containerColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surfaceVariant, + containerColor: Color = MaterialTheme.colorScheme.surfaceVariant, content: @Composable () -> Unit ) { Box( @@ -589,8 +619,8 @@ private fun AiStatusBubble( ) { Surface( modifier = modifier - .fillMaxWidth(0.9f), - shape = RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp), + .fillMaxWidth(0.94f), + shape = RoundedCornerShape(18.dp, 18.dp, 18.dp, 6.dp), color = containerColor, tonalElevation = 1.dp ) { @@ -603,56 +633,232 @@ private fun AiStatusBubble( @Composable private fun ChatBubble( - message: String, - isUser: Boolean, + message: ChatMessage, timestamp: String, showTimestamp: Boolean = true ) { + val isUser = message.role == ChatRole.USER val isDark = isSystemInDarkTheme() val bubbleShape = RoundedCornerShape( - topStart = 16.dp, - topEnd = 16.dp, - bottomStart = if (isUser) 16.dp else 4.dp, - bottomEnd = if (isUser) 4.dp else 16.dp + topStart = if (isUser) 18.dp else 6.dp, + topEnd = if (isUser) 6.dp else 18.dp, + bottomStart = 18.dp, + bottomEnd = 18.dp ) Box( modifier = Modifier.fillMaxWidth(), contentAlignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart ) { - Column( - horizontalAlignment = if (isUser) Alignment.End else Alignment.Start - ) { - Surface( - modifier = Modifier - .widthIn(max = 280.dp) - .then( - // Add subtle shadow to system bubbles in light mode - if (!isUser && !isDark) Modifier.shadow( - elevation = 2.dp, - shape = bubbleShape - ) else Modifier - ), - shape = bubbleShape, - color = if (isUser) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surfaceVariant, - tonalElevation = if (isUser) 0.dp else 1.dp + Row(verticalAlignment = Alignment.Top) { + if (!isUser) { + Box( + modifier = Modifier + .padding(top = 2.dp) + .size(28.dp) + .clip(CircleShape) + .background(HushGradient), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.NotificationsOff, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(15.dp) + ) + } + Spacer(modifier = Modifier.width(8.dp)) + } + Column( + horizontalAlignment = if (isUser) Alignment.End else Alignment.Start ) { - Text( - text = message, - modifier = Modifier.padding(12.dp), - color = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer - else MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyMedium - ) + if (isUser) { + // Gradient user bubble — the brand moment of the chat + Box( + modifier = Modifier + .widthIn(max = 290.dp) + .shadow(4.dp, bubbleShape, spotColor = AccentPurple.copy(alpha = 0.4f)) + .clip(bubbleShape) + .background(HushGradient) + ) { + Text( + text = message.text, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 11.dp), + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) + } + } else { + Surface( + modifier = Modifier + .widthIn(max = 290.dp) + .then( + if (!isDark) Modifier.shadow( + elevation = 2.dp, + shape = bubbleShape + ) else Modifier + ), + shape = bubbleShape, + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Text( + text = message.text, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 11.dp), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium + ) + } + } + if (showTimestamp) { + Text( + text = timestamp, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(top = 3.dp, start = 4.dp, end = 4.dp) + ) + } } - // Smart timestamps: only show when requested - if (showTimestamp) { - Text( - text = timestamp, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 2.dp, start = 4.dp, end = 4.dp) - ) + } + } +} + +@Composable +private fun ProposedRuleCard( + rule: ParsedCommand, + isInstalled: Boolean, + appLabel: String?, + onConfirm: () -> Unit, + onCancel: () -> Unit +) { + val actionColor = when (rule.action) { + RuleAction.BLOCK -> AccentRed + RuleAction.MUTE -> AccentAmber + RuleAction.ALLOW -> AccentGreen + } + val actionIcon = when (rule.action) { + RuleAction.BLOCK -> Icons.Outlined.Block + RuleAction.MUTE -> Icons.Outlined.VolumeOff + RuleAction.ALLOW -> Icons.Outlined.DoneAll + } + val timeFormatter = remember { DateTimeFormatter.ofPattern("h:mm a") } + + Surface( + modifier = Modifier + .fillMaxWidth() + .testTag("ai_rule_card"), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke(1.5.dp, actionColor.copy(alpha = 0.5f)), + tonalElevation = 2.dp + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(38.dp) + .clip(RoundedCornerShape(12.dp)) + .background(actionColor.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = actionIcon, + contentDescription = null, + tint = actionColor, + modifier = Modifier.size(20.dp) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + "New rule ready", + style = MaterialTheme.typography.labelSmall, + color = actionColor, + fontWeight = FontWeight.SemiBold + ) + Text( + rule.summary, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Attribute chips describing the parsed rule + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + AttributeChip(icon = actionIcon, label = rule.action.name.lowercase().replaceFirstChar { it.uppercase() }, color = actionColor) + AttributeChip(icon = Icons.Outlined.Apps, label = appLabel ?: "All apps", color = AccentBlue) + if (rule.timeStart != null || rule.timeEnd != null) { + val window = listOfNotNull( + rule.timeStart?.format(timeFormatter), + rule.timeEnd?.format(timeFormatter) + ).joinToString("–") + AttributeChip(icon = Icons.Outlined.Schedule, label = window, color = AccentTeal) + } + } + if (rule.matchPattern != null || rule.isInverted) { + Spacer(modifier = Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + rule.matchPattern?.let { pattern -> + val patternIcon = if (rule.matchField == MatchField.SENDER) Icons.Outlined.Person else Icons.Outlined.Search + AttributeChip(icon = patternIcon, label = "\"$pattern\"", color = AccentPurple) + } + if (rule.isInverted) { + AttributeChip(icon = Icons.Outlined.SwapHoriz, label = "Exception rule", color = AccentAmber) + } + } + } + + if (!isInstalled) { + Spacer(modifier = Modifier.height(10.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.testTag("ai_rule_warning_uninstalled") + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = AccentRed, + modifier = Modifier.size(15.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + "This app isn't installed on this device.", + color = AccentRed, + style = MaterialTheme.typography.bodySmall + ) + } + } + + Spacer(modifier = Modifier.height(14.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton( + onClick = onCancel, + modifier = Modifier.testTag("ai_rule_cancel"), + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Text("Cancel") + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = onConfirm, + modifier = Modifier.testTag("ai_rule_confirm"), + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults.buttonColors( + containerColor = actionColor, + contentColor = Color.White + ) + ) { + Text("Add Rule", fontWeight = FontWeight.SemiBold) + } } } } @@ -666,7 +872,7 @@ private fun ThinkingBubble() { ) { Surface( modifier = Modifier.testTag("ai_thinking_bubble"), - shape = RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp), + shape = RoundedCornerShape(18.dp, 18.dp, 18.dp, 6.dp), color = MaterialTheme.colorScheme.surfaceVariant, tonalElevation = 1.dp ) { diff --git a/app/src/main/java/com/hush/app/ui/screens/chat/ChatViewModel.kt b/app/src/main/java/com/hush/app/ui/screens/chat/ChatViewModel.kt index 16c9d8b..2475bd1 100644 --- a/app/src/main/java/com/hush/app/ui/screens/chat/ChatViewModel.kt +++ b/app/src/main/java/com/hush/app/ui/screens/chat/ChatViewModel.kt @@ -8,6 +8,8 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.hush.app.domain.model.ChatMessage +import com.hush.app.domain.model.ChatRole import com.hush.app.domain.model.ParsedCommand import com.hush.app.domain.model.Rule import com.hush.app.domain.permission.PermissionManager @@ -33,9 +35,11 @@ class ChatViewModel @Inject constructor( val permissionManager: PermissionManager ) : ViewModel() { - val mockMessages = mutableStateListOf( - "Welcome to Hush! Speak or type a filtering command (e.g., 'Mute Instagram').", - "Mute WhatsApp notifications except from Bob." + val messages = mutableStateListOf( + ChatMessage( + text = "Hi! I'm Hush. Tell me which notifications to quiet down — try \"Mute Instagram\" or \"Block Slack after 6pm\".", + role = ChatRole.ASSISTANT + ) ) val proposedRule = mutableStateOf(null) @@ -104,8 +108,9 @@ class ChatViewModel @Inject constructor( fun handleSend(prompt: String) { if (prompt.isBlank()) return - mockMessages.add(prompt) + messages.add(ChatMessage(prompt, ChatRole.USER)) textState.value = "" + errorMessage.value = null aiJob?.cancel() aiJob = viewModelScope.launch { @@ -115,11 +120,11 @@ class ChatViewModel @Inject constructor( if (result.summary == "MALFORMED_JSON_TRIGGER") { errorMessage.value = "Failed to parse command" } else { - proposedRule.value = result + proposedRule.value = result.copy(originalPrompt = prompt) errorMessage.value = null } } catch (e: Exception) { - errorMessage.value = "AI Engine error: ${e.message}" + errorMessage.value = e.message ?: "Something went wrong while parsing that command." } finally { isProcessing.value = false } @@ -135,7 +140,7 @@ class ChatViewModel @Inject constructor( val entity = Rule( name = rule.summary, enabled = true, - originalPrompt = rule.summary, + originalPrompt = rule.originalPrompt ?: rule.summary, appPackage = rule.app, appDisplayName = appDisplayName, matchField = rule.matchField, @@ -150,7 +155,7 @@ class ChatViewModel @Inject constructor( updatedAt = Instant.now() ) ruleRepository.insertRule(entity) - mockMessages.add("Rule created successfully") + messages.add(ChatMessage("Rule created successfully", ChatRole.ASSISTANT)) proposedRule.value = null } catch (e: Exception) { errorMessage.value = "Failed to save rule: ${e.message}" @@ -160,6 +165,7 @@ class ChatViewModel @Inject constructor( fun cancelProposedRule() { proposedRule.value = null + messages.add(ChatMessage("No problem — rule discarded. Tell me what you'd like instead.", ChatRole.ASSISTANT)) } fun startModelDownload() { @@ -226,4 +232,3 @@ class ChatViewModel @Inject constructor( } } } - diff --git a/app/src/main/java/com/hush/app/ui/screens/history/HistoryScreen.kt b/app/src/main/java/com/hush/app/ui/screens/history/HistoryScreen.kt index b1c9ae0..e718796 100644 --- a/app/src/main/java/com/hush/app/ui/screens/history/HistoryScreen.kt +++ b/app/src/main/java/com/hush/app/ui/screens/history/HistoryScreen.kt @@ -4,11 +4,12 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.Inbox import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* @@ -17,13 +18,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.hush.app.domain.model.NotificationEvent import com.hush.app.domain.model.RuleAction +import com.hush.app.ui.components.EmptyState +import com.hush.app.ui.components.HushHeader import com.hush.app.ui.theme.* +import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter import kotlin.math.absoluteValue @@ -34,32 +40,47 @@ fun HistoryScreen( viewModel: HistoryViewModel = hiltViewModel() ) { val searchQuery by viewModel.searchQuery.collectAsState() - val historyLogs by viewModel.historyLogs.collectAsState() val selectedFilter by viewModel.selectedFilter.collectAsState() + val historyLogs by viewModel.historyLogs.collectAsState() + var selectedLog by remember { mutableStateOf(null) } var showClearDialog by remember { mutableStateOf(false) } val timeFormatter = remember { DateTimeFormatter.ofPattern("hh:mm a").withZone(ZoneId.systemDefault()) } + val dayFormatter = remember { DateTimeFormatter.ofPattern("EEEE, MMM d") } Column( modifier = modifier .fillMaxSize() .testTag("history_screen") - .padding(horizontal = 16.dp) ) { - Spacer(modifier = Modifier.height(16.dp)) + HushHeader( + title = "History", + subtitle = "Everything Hush has filtered", + leadingIcon = Icons.Outlined.Inbox, + trailing = { + IconButton( + onClick = { showClearDialog = true }, + enabled = historyLogs.isNotEmpty(), + modifier = Modifier.testTag("history_clear_button") + ) { + Icon( + imageVector = Icons.Outlined.DeleteSweep, + contentDescription = "Clear all history", + tint = if (historyLogs.isNotEmpty()) AccentRed + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + } + } + ) - // ── Search Input + Clear All ── - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + // ── Search Input ── TextField( value = searchQuery, onValueChange = { viewModel.setSearchQuery(it) }, - placeholder = { Text("Search notifications...") }, + placeholder = { Text("Search notifications…") }, leadingIcon = { Icon( imageVector = Icons.Default.Search, @@ -68,7 +89,7 @@ fun HistoryScreen( ) }, modifier = Modifier - .weight(1f) + .fillMaxWidth() .testTag("history_search_input"), singleLine = true, shape = RoundedCornerShape(24.dp), @@ -81,72 +102,124 @@ fun HistoryScreen( ) ) - IconButton( - onClick = { showClearDialog = true }, - enabled = historyLogs.isNotEmpty(), - modifier = Modifier.testTag("history_clear_button") - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = "Clear all history", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - Spacer(modifier = Modifier.height(12.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { + Spacer(modifier = Modifier.height(10.dp)) - FilterChip( - selected = selectedFilter == RuleAction.BLOCK, - onClick = { - viewModel.toggleFilter(RuleAction.BLOCK) - }, - label = { - Text("Blocked") - } + // ── Filter Tabs (null filter == "All") ── + val tabs = listOf( + FilterTab("All", null, MaterialTheme.colorScheme.primary), + FilterTab("Blocked", RuleAction.BLOCK, StatusBlocked), + FilterTab("Muted", RuleAction.MUTE, StatusMuted), + FilterTab("Delivered", RuleAction.ALLOW, StatusDelivered) ) - - FilterChip( - selected = selectedFilter == RuleAction.MUTE, - onClick = { - viewModel.toggleFilter(RuleAction.MUTE) - }, - label = { - Text("Muted") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + tabs.forEach { tab -> + val selected = selectedFilter == tab.action + FilterChip( + selected = selected, + onClick = { + if (tab.action == null) viewModel.clearFilter() + else viewModel.toggleFilter(tab.action) + }, + label = { + Text( + tab.label, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal + ) + }, + shape = RoundedCornerShape(20.dp), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = tab.color.copy(alpha = 0.15f), + selectedLabelColor = tab.color + ), + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = selected, + borderColor = MaterialTheme.colorScheme.outlineVariant, + selectedBorderColor = tab.color.copy(alpha = 0.4f), + selectedBorderWidth = 1.dp + ), + modifier = Modifier.testTag( + "history_tab_${tab.label.lowercase().replace("delivered", "allowed")}" + ) + ) } - ) + } - FilterChip( - selected = selectedFilter == RuleAction.ALLOW, - onClick = { - viewModel.toggleFilter(RuleAction.ALLOW) - }, - label = { - Text("Delivered") + Spacer(modifier = Modifier.height(4.dp)) + + // ── Logs List ── + // The list stays composed even when empty (tests and semantics rely + // on a stable node); the empty state is drawn over it. + Box(modifier = Modifier.fillMaxSize()) { + if (historyLogs.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + EmptyState( + icon = Icons.Outlined.Inbox, + title = if (searchQuery.isBlank() && selectedFilter == null) "Nothing filtered yet" + else "No matches", + message = if (searchQuery.isBlank() && selectedFilter == null) + "Once your rules start catching notifications, they'll show up here." + else + "Try a different search or filter.", + modifier = Modifier.padding(bottom = 48.dp) + ) + } + } + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag("history_list"), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + itemsIndexed( + items = historyLogs, + key = { _, log -> log.id } + ) { index, log -> + // Day header is rendered inside the item so list + // children == log count (grouping without extra rows) + val logDate = remember(log.timestamp) { + log.timestamp.atZone(ZoneId.systemDefault()).toLocalDate() + } + val prevDate = if (index == 0) null else remember(historyLogs[index - 1].timestamp) { + historyLogs[index - 1].timestamp.atZone(ZoneId.systemDefault()).toLocalDate() + } + Column( + modifier = Modifier + .animateItem() + // One structural node per log entry (the day + // header rides along inside the same item) + .semantics { isTraversalGroup = true } + ) { + if (logDate != prevDate) { + val label = when (logDate) { + LocalDate.now() -> "Today" + LocalDate.now().minusDays(1) -> "Yesterday" + else -> logDate.format(dayFormatter) + } + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + start = 4.dp, + top = if (index == 0) 0.dp else 10.dp, + bottom = 8.dp + ) + ) + } + HistoryEntryCard( + log = log, + timeFormatter = timeFormatter, + onClick = { selectedLog = log } + ) + } + } } - ) - } - Spacer(modifier = Modifier.height(12.dp)) - // ── Logs List ── - LazyColumn( - modifier = Modifier - .fillMaxSize() - .testTag("history_list"), - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(vertical = 8.dp) - ) { - items( - items = historyLogs, - key = { it.id } - ) { log -> - HistoryEntryCard( - log = log, - timeFormatter = timeFormatter, - onClick = { selectedLog = log } - ) } } } @@ -156,22 +229,36 @@ fun HistoryScreen( val log = selectedLog!! AlertDialog( onDismissRequest = { selectedLog = null }, - title = { Text(log.appName) }, + shape = RoundedCornerShape(24.dp), + title = { + Column { + Text( + "Notification", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(2.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(log.appName, style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.width(10.dp)) + StatusBadge(action = log.actionTaken) + } + } + }, text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Package: ${log.packageName}") - Text("Title: ${log.title ?: "No Title"}") - Text("Content: ${log.text ?: "No Content"}") + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + DetailRow("Package", log.packageName) + DetailRow("Title", log.title ?: "No Title") + DetailRow("Content", log.text ?: "No Content") if (log.sender != null) { - Text("Sender: ${log.sender}") + DetailRow("Sender", log.sender) } - Text("Action: ${log.actionTaken}") val ruleText = if (log.matchedRuleId == null && log.matchedRuleName != null) { - "Rule deleted" + "${log.matchedRuleName} (deleted)" } else { log.matchedRuleName ?: "None" } - Text("Triggered by Rule: $ruleText") + DetailRow("Triggered by rule", ruleText) } }, confirmButton = { @@ -187,6 +274,7 @@ fun HistoryScreen( if (showClearDialog) { AlertDialog( onDismissRequest = { showClearDialog = false }, + shape = RoundedCornerShape(24.dp), title = { Text("Clear all history?") }, text = { Text("This will permanently delete all notification logs.") }, confirmButton = { @@ -194,7 +282,7 @@ fun HistoryScreen( viewModel.clearAll() showClearDialog = false }) { - Text("Clear") + Text("Clear", color = AccentRed, fontWeight = FontWeight.SemiBold) } }, dismissButton = { @@ -207,6 +295,25 @@ fun HistoryScreen( } } +private data class FilterTab(val label: String, val action: RuleAction?, val color: Color) + +@Composable +private fun DetailRow(label: String, value: String) { + Column { + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(1.dp)) + Text( + value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + // ── Single History Entry Card ── @Composable private fun HistoryEntryCard( @@ -231,7 +338,7 @@ private fun HistoryEntryCard( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick), - shape = RoundedCornerShape(12.dp), + shape = RoundedCornerShape(14.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant ) @@ -246,14 +353,14 @@ private fun HistoryEntryCard( // ── App Icon Circle ── Box( modifier = Modifier - .size(40.dp) + .size(42.dp) .clip(CircleShape) .background(iconColor.copy(alpha = 0.15f)), contentAlignment = Alignment.Center ) { Text( text = initial, - style = MaterialTheme.typography.titleSmall, + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = iconColor ) @@ -270,7 +377,6 @@ private fun HistoryEntryCard( Text( text = log.appName, style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface ) val timeStr = runCatching { timeFormatter.format(log.timestamp) }.getOrDefault("") diff --git a/app/src/main/java/com/hush/app/ui/screens/history/HistoryViewModel.kt b/app/src/main/java/com/hush/app/ui/screens/history/HistoryViewModel.kt index 6b1369e..c46321f 100644 --- a/app/src/main/java/com/hush/app/ui/screens/history/HistoryViewModel.kt +++ b/app/src/main/java/com/hush/app/ui/screens/history/HistoryViewModel.kt @@ -62,6 +62,11 @@ class HistoryViewModel @Inject constructor( } } + /** Clears any active filter, showing all logs (the "All" chip). */ + fun clearFilter() { + _selectedFilter.value = null + } + fun clearAll() { viewModelScope.launch { historyRepository.clearAllLogs() diff --git a/app/src/main/java/com/hush/app/ui/screens/onboarding/OnboardingScreen.kt b/app/src/main/java/com/hush/app/ui/screens/onboarding/OnboardingScreen.kt index 45a0f5a..a1eb477 100644 --- a/app/src/main/java/com/hush/app/ui/screens/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/hush/app/ui/screens/onboarding/OnboardingScreen.kt @@ -1,35 +1,46 @@ package com.hush.app.ui.screens.onboarding -import android.Manifest -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.os.PowerManager -import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.clickable +import androidx.compose.animation.* +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.BatteryChargingFull +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.animation.* -import androidx.compose.animation.core.tween +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver - -import androidx.hilt.navigation.compose.hiltViewModel +import com.hush.app.ui.components.HushGradient +import com.hush.app.ui.theme.AccentGreen +import com.hush.app.ui.theme.AccentPurple @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -66,8 +77,6 @@ fun OnboardingScreen( viewModel.refreshPermissions() } - // Battery Optimization — launch directly, ON_RESUME will refresh permissions - // Warning Dialog for Battery Optimization Denial if (showBatteryWarning) { AlertDialog( @@ -87,12 +96,8 @@ fun OnboardingScreen( } Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { Text("Welcome to Hush") } - ) - }, - modifier = modifier.testTag("onboarding_screen") + modifier = modifier.testTag("onboarding_screen"), + containerColor = MaterialTheme.colorScheme.background ) { innerPadding -> Column( modifier = Modifier @@ -134,9 +139,9 @@ fun OnboardingScreen( }, onRequestBattery = { viewModel.requestBatteryExemption(context) - }, - onRequestDenyNotification = { - viewModel.denyNotificationAccess() + // If the exemption still isn't granted, gently + // explain why it helps (battery is optional). + if (!viewModel.isBatteryExempt) showBatteryWarning = true }, onNext = { currentStep = 2 }, canProceed = viewModel.hasNotificationAccess && !viewModel.isNotificationAccessDenied, @@ -149,29 +154,28 @@ fun OnboardingScreen( } } - // Step Indicator dots + // Step Indicator — animated pill for the active step Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth().padding(top = 16.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 20.dp) ) { repeat(3) { index -> - val color = if (index == currentStep) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) - } + val isActive = index == currentStep + val width by animateDpAsState( + targetValue = if (isActive) 28.dp else 8.dp, + animationSpec = tween(300), + label = "dot_width_$index" + ) Box( modifier = Modifier - .size(10.dp) - .padding(2.dp) - .weight(1f, false) - ) { - Surface( - shape = MaterialTheme.shapes.extraSmall, - color = color, - modifier = Modifier.fillMaxSize() - ) {} - } + .height(8.dp) + .width(width) + .clip(CircleShape) + .background( + if (isActive) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f) + ) + ) } } } @@ -180,30 +184,117 @@ fun OnboardingScreen( @Composable fun ColumnScope.WelcomeStep(onNext: () -> Unit) { + // Springy hero entrance + var appeared by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { appeared = true } + val heroScale by animateFloatAsState( + targetValue = if (appeared) 1f else 0.6f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow), + label = "hero_scale" + ) + Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, modifier = Modifier.weight(1f) ) { + Box( + modifier = Modifier + .size(104.dp) + .graphicsLayer { + scaleX = heroScale + scaleY = heroScale + } + .clip(RoundedCornerShape(32.dp)) + .background(HushGradient), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.NotificationsOff, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(52.dp) + ) + } + Spacer(modifier = Modifier.height(28.dp)) Text( - text = "Privacy-first notification filtering", - style = MaterialTheme.typography.headlineMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.primary + text = "Hush", + style = MaterialTheme.typography.displayLarge, + color = MaterialTheme.colorScheme.onBackground ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Hush runs fully on-device, using Gemini Nano to understand your commands. Block, allow, or mute notifications without compromising your data.", + text = "Control notifications by simply\ntalking to your phone", style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(36.dp)) + + FeatureRow( + icon = Icons.Outlined.AutoAwesome, + title = "Natural language rules", + description = "\"Mute Slack after 10pm\" — done." + ) + Spacer(modifier = Modifier.height(14.dp)) + FeatureRow( + icon = Icons.Outlined.Lock, + title = "100% private", + description = "Gemini Nano runs on-device. Nothing leaves your phone." + ) + Spacer(modifier = Modifier.height(14.dp)) + FeatureRow( + icon = Icons.Outlined.Schedule, + title = "Time-aware filtering", + description = "Rules that only fire when you need quiet." + ) + + Spacer(modifier = Modifier.height(40.dp)) Button( onClick = onNext, - modifier = Modifier.testTag("onboarding_next_button") + modifier = Modifier + .fillMaxWidth() + .height(54.dp) + .testTag("onboarding_next_button"), + shape = RoundedCornerShape(27.dp) + ) { + Text("Get Started", style = MaterialTheme.typography.labelLarge) + } + } +} + +@Composable +private fun FeatureRow(icon: ImageVector, title: String, description: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(RoundedCornerShape(14.dp)) + .background(AccentPurple.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center ) { - Text("Get Started") + Icon( + imageVector = icon, + contentDescription = null, + tint = AccentPurple, + modifier = Modifier.size(22.dp) + ) + } + Spacer(modifier = Modifier.width(14.dp)) + Column { + Text( + title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onBackground + ) + Text( + description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } } @@ -216,48 +307,59 @@ fun ColumnScope.PermissionsStep( onRequestNotification: () -> Unit, onRequestMicrophone: () -> Unit, onRequestBattery: () -> Unit, - onRequestDenyNotification: () -> Unit, onNext: () -> Unit, canProceed: Boolean, showDenyRationale: Boolean ) { Column( verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f) ) { Text( - text = "Configure Permissions", - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 16.dp), - textAlign = TextAlign.Center + text = "A few permissions", + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onBackground ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "Hush needs these to quiet things down.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(28.dp)) // 1. Notification Access PermissionRow( - title = "Notification Interception", - description = "Allows Hush to read and filter notifications. (Mandatory)", + icon = Icons.Filled.Notifications, + title = "Notification access", + description = "Read and filter incoming notifications. Required.", isGranted = hasNotificationAccess, onRequest = onRequestNotification, buttonTag = "onboarding_grant_notification" ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(12.dp)) // 2. Microphone PermissionRow( - title = "Microphone Access", - description = "Enables natural language voice commands. (Optional)", + icon = Icons.Filled.Mic, + title = "Microphone", + description = "Speak your rules out loud. Optional.", isGranted = hasMicrophonePermission, onRequest = onRequestMicrophone, buttonTag = "onboarding_grant_mic" ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(12.dp)) // 3. Battery Exclusion PermissionRow( - title = "Keep App Alive", - description = "Exempts Hush from battery restrictions so it runs in the background. (Optional)", + icon = Icons.Outlined.BatteryChargingFull, + title = "Keep app alive", + description = "Skip battery limits so filtering never sleeps. Optional.", isGranted = isBatteryExempt, onRequest = onRequestBattery, buttonTag = "onboarding_ignore_battery" @@ -269,17 +371,18 @@ fun ColumnScope.PermissionsStep( onClick = onNext, enabled = canProceed, modifier = Modifier - .align(Alignment.CenterHorizontally) - .testTag("onboarding_next_button") + .fillMaxWidth() + .height(54.dp) + .testTag("onboarding_next_button"), + shape = RoundedCornerShape(27.dp) ) { - Text("Continue") + Text("Continue", style = MaterialTheme.typography.labelLarge) } AnimatedVisibility( visible = showDenyRationale, enter = fadeIn(tween(500)), - exit = fadeOut(tween(500)), - modifier = Modifier.align(Alignment.CenterHorizontally) + exit = fadeOut(tween(500)) ) { Text( text = "Grant notification access to continue", @@ -299,10 +402,12 @@ fun PermissionRow( description: String, isGranted: Boolean, onRequest: () -> Unit, - buttonTag: String? = null + buttonTag: String? = null, + icon: ImageVector = Icons.Filled.Notifications ) { Card( modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant ) @@ -311,23 +416,45 @@ fun PermissionRow( modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { + Box( + modifier = Modifier + .size(42.dp) + .clip(RoundedCornerShape(13.dp)) + .background( + if (isGranted) AccentGreen.copy(alpha = 0.14f) + else AccentPurple.copy(alpha = 0.12f) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = if (isGranted) AccentGreen else AccentPurple, + modifier = Modifier.size(21.dp) + ) + } + Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.titleMedium) - Text(description, style = MaterialTheme.typography.bodySmall) + Text(title, style = MaterialTheme.typography.titleSmall) + Spacer(modifier = Modifier.height(1.dp)) + Text( + description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } Spacer(modifier = Modifier.width(8.dp)) if (isGranted) { Icon( imageVector = Icons.Default.CheckCircle, contentDescription = "Granted", - tint = MaterialTheme.colorScheme.primary + tint = AccentGreen ) } else { Button( onClick = onRequest, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondary - ), + shape = RoundedCornerShape(20.dp), + contentPadding = PaddingValues(horizontal = 18.dp, vertical = 8.dp), modifier = if (buttonTag != null) Modifier.testTag(buttonTag) else Modifier ) { Text("Grant", style = MaterialTheme.typography.labelMedium) @@ -344,30 +471,43 @@ fun ColumnScope.AICoreStep(onComplete: () -> Unit) { verticalArrangement = Arrangement.Center, modifier = Modifier.weight(1f) ) { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = "AI Core", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(64.dp) - ) - Spacer(modifier = Modifier.height(16.dp)) + Box( + modifier = Modifier + .size(104.dp) + .clip(CircleShape) + .background(AccentGreen.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Ready", + tint = AccentGreen, + modifier = Modifier.size(56.dp) + ) + } + Spacer(modifier = Modifier.height(28.dp)) Text( - text = "AI Engine Verification", - style = MaterialTheme.typography.titleLarge + text = "You're all set", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Hush uses Google's on-device Gemini Nano model. We have verified your system is ready to process commands locally.", + text = "Hush parses your commands right on this device — with Gemini Nano when available, and a built-in parser everywhere else. No cloud, ever.", style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(36.dp)) Button( onClick = onComplete, - modifier = Modifier.testTag("onboarding_start_button") + modifier = Modifier + .fillMaxWidth() + .height(54.dp) + .testTag("onboarding_start_button"), + shape = RoundedCornerShape(27.dp) ) { - Text("Enter Hush") + Text("Enter Hush", style = MaterialTheme.typography.labelLarge) } } } - diff --git a/app/src/main/java/com/hush/app/ui/screens/rules/RulesScreen.kt b/app/src/main/java/com/hush/app/ui/screens/rules/RulesScreen.kt index ca2170a..57e8ee7 100644 --- a/app/src/main/java/com/hush/app/ui/screens/rules/RulesScreen.kt +++ b/app/src/main/java/com/hush/app/ui/screens/rules/RulesScreen.kt @@ -6,38 +6,49 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.CircleShape 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.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.DoneAll +import androidx.compose.material.icons.outlined.FilterAlt +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.SwapHoriz +import androidx.compose.material.icons.outlined.VolumeOff import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.hush.app.domain.model.MatchField import com.hush.app.domain.model.Rule import com.hush.app.domain.model.RuleAction +import com.hush.app.ui.components.AttributeChip +import com.hush.app.ui.components.EmptyState +import com.hush.app.ui.components.HushHeader import com.hush.app.ui.theme.* +import java.time.format.DateTimeFormatter -// Accent color pairs cycled by index % 6 -private data class AccentPair(val main: Color, val light: Color) +private fun actionColor(action: RuleAction): Color = when (action) { + RuleAction.BLOCK -> AccentRed + RuleAction.MUTE -> AccentAmber + RuleAction.ALLOW -> AccentGreen +} -private val accentPairs = listOf( - AccentPair(AccentPurple, AccentPurpleLight), - AccentPair(AccentBlue, AccentBlueLight), - AccentPair(AccentGreen, AccentGreenLight), - AccentPair(AccentRed, AccentRedLight), - AccentPair(AccentAmber, AccentAmberLight), - AccentPair(AccentTeal, AccentTealLight), -) +private fun actionIcon(action: RuleAction): ImageVector = when (action) { + RuleAction.BLOCK -> Icons.Outlined.Block + RuleAction.MUTE -> Icons.Outlined.VolumeOff + RuleAction.ALLOW -> Icons.Outlined.DoneAll +} @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -48,18 +59,21 @@ fun RulesScreen( val rulesList by viewModel.rulesList.collectAsState() var selectedRule by remember { mutableStateOf(null) } var rulePendingDeletion by remember { mutableStateOf(null) } + val timeFormatter = remember { DateTimeFormatter.ofPattern("h:mm a") } + Surface( modifier = modifier .fillMaxSize() .testTag("rules_screen"), color = MaterialTheme.colorScheme.background ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(16.dp)) + Column(modifier = Modifier.fillMaxSize()) { + HushHeader( + title = "Rules", + subtitle = if (rulesList.isEmpty()) "Your filters live here" + else "${rulesList.count { it.enabled }} of ${rulesList.size} active", + leadingIcon = Icons.Outlined.FilterAlt + ) // ── Content ── if (rulesList.isEmpty()) { @@ -69,62 +83,47 @@ fun RulesScreen( .testTag("rules_empty_state"), contentAlignment = Alignment.Center ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon( - imageVector = Icons.Outlined.Notifications, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - "No rules yet", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - "Head to Chat and tell the AI what to filter.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } + EmptyState( + icon = Icons.Outlined.FilterAlt, + title = "No active rules", + message = "Head to Chat and tell Hush what to filter — try \"Mute Instagram\"." + ) } } else { LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(vertical = 8.dp) ) { itemsIndexed( items = rulesList, key = { _, rule -> rule.id } - ) { index, rule -> - val accent = accentPairs[index % accentPairs.size] - + ) { _, rule -> val dismissState = rememberSwipeToDismissBoxState( confirmValueChange = { dismissValue -> + // Ask for confirmation before deleting; don't + // let the card actually dismiss on swipe. if (dismissValue == SwipeToDismissBoxValue.EndToStart) { - rulePendingDeletion=rule - false - } else { - false + rulePendingDeletion = rule } + false } ) SwipeToDismissBox( state = dismissState, + modifier = Modifier.animateItem(), enableDismissFromStartToEnd = false, enableDismissFromEndToStart = true, backgroundContent = { Box( modifier = Modifier .fillMaxSize() - .clip(RoundedCornerShape(16.dp)) - .background(Color.Red.copy(alpha = 0.8f)) - .padding(horizontal = 16.dp), + .clip(RoundedCornerShape(18.dp)) + .background(AccentRed) + .padding(horizontal = 20.dp), contentAlignment = Alignment.CenterEnd ) { Icon( @@ -135,82 +134,12 @@ fun RulesScreen( } }, content = { - Card( - modifier = Modifier - .fillMaxWidth() - .clickable { selectedRule = rule } - .testTag("rule_card_${rule.id}"), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Colored icon circle - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(accent.light), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Outlined.Notifications, - contentDescription = null, - tint = accent.main, - modifier = Modifier.size(20.dp) - ) - } - Spacer(modifier = Modifier.width(12.dp)) - - // Rule info - Column(modifier = Modifier.weight(1f)) { - Text( - text = rule.name, - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold - ), - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = rule.appDisplayName - ?: rule.appPackage - ?: "All Apps", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (rule.action == RuleAction.BLOCK || rule.action == RuleAction.MUTE) { - Spacer(modifier = Modifier.height(4.dp)) - Surface( - shape = RoundedCornerShape(6.dp), - color = if (rule.action == RuleAction.BLOCK) AccentRed.copy(alpha = 0.15f) else AccentAmber.copy(alpha = 0.15f) - ) { - Text( - text = rule.action.name, - style = MaterialTheme.typography.labelSmall, - color = if (rule.action == RuleAction.BLOCK) AccentRed else AccentAmber, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp) - ) - } - } - } - - // Toggle switch - Switch( - checked = rule.enabled, - onCheckedChange = { - viewModel.toggleRuleEnabled(rule) - }, - modifier = Modifier.testTag("rule_toggle_${rule.id}") - ) - } - } + RuleCard( + rule = rule, + timeFormatter = timeFormatter, + onClick = { selectedRule = rule }, + onToggle = { viewModel.toggleRuleEnabled(rule) } + ) } ) } @@ -225,48 +154,78 @@ fun RulesScreen( var actionState by remember(rule) { mutableStateOf(rule.action) } AlertDialog( onDismissRequest = { selectedRule = null }, - title = { Text(rule.name) }, + shape = RoundedCornerShape(24.dp), + title = { + Column { + Text( + "Rule details", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(2.dp)) + Text(rule.name, style = MaterialTheme.typography.titleLarge) + } + }, text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Original Prompt: ${rule.originalPrompt}") - if (rule.appPackage != null) { - Text("Package: ${rule.appPackage}") - } - if (rule.appDisplayName != null) { - Text("App Name: ${rule.appDisplayName}") - } - Text("Match Field: ${rule.matchField}") - Text("Match Type: ${rule.matchType}") - if (rule.matchPattern != null) { - Text("Pattern: ${rule.matchPattern}") + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + DetailRow("Original prompt", "\"${rule.originalPrompt}\"") + DetailRow("App", rule.appDisplayName ?: rule.appPackage ?: "All apps") + DetailRow("Matches", buildString { + append(rule.matchField.name.lowercase().replaceFirstChar { it.uppercase() }) + append(" · ") + append(rule.matchType.name.lowercase()) + rule.matchPattern?.let { append(" \"$it\"") } + if (rule.isInverted) append(" (exception)") + }) + if (rule.timeStart != null || rule.timeEnd != null) { + DetailRow( + "Active window", + listOfNotNull( + rule.timeStart?.format(timeFormatter), + rule.timeEnd?.format(timeFormatter) + ).joinToString(" – ") + ) } - Spacer(modifier = Modifier.height(8.dp)) - Text("Action:") - Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { + + Spacer(modifier = Modifier.height(4.dp)) + Text( + "Action", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { RuleAction.entries.filter { it != RuleAction.ALLOW }.forEach { action -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable { actionState = action } - .padding(vertical = 2.dp) - ) { - RadioButton( - selected = actionState == action, - onClick = { actionState = action }, - modifier = Modifier.testTag("rule_edit_action_${action.name.lowercase()}") - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(action.name) - } + val selected = actionState == action + val color = actionColor(action) + FilterChip( + selected = selected, + onClick = { actionState = action }, + label = { + Text( + action.name.lowercase().replaceFirstChar { it.uppercase() }, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal + ) + }, + leadingIcon = { + Icon( + imageVector = actionIcon(action), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = color.copy(alpha = 0.15f), + selectedLabelColor = color, + selectedLeadingIconColor = color + ), + modifier = Modifier.testTag("rule_edit_action_${action.name.lowercase()}") + ) } } - Spacer(modifier = Modifier.height(8.dp)) - // Delete button + + Spacer(modifier = Modifier.height(4.dp)) OutlinedButton( - onClick = { - rulePendingDeletion=rule - }, + onClick = { rulePendingDeletion = rule }, modifier = Modifier .fillMaxWidth() .testTag("rule_delete_button"), @@ -294,7 +253,7 @@ fun RulesScreen( }, modifier = Modifier.testTag("rule_edit_save_button") ) { - Text("Save") + Text("Save", fontWeight = FontWeight.SemiBold) } }, dismissButton = { @@ -303,50 +262,166 @@ fun RulesScreen( } }, modifier = Modifier.testTag("rule_detail_dialog") - ) } + + // ── Delete Confirmation Dialog ── if (rulePendingDeletion != null) { val rule = rulePendingDeletion!! - AlertDialog( - onDismissRequest = { - rulePendingDeletion = null - }, - title = { - Text("Delete rule?") - }, + onDismissRequest = { rulePendingDeletion = null }, + shape = RoundedCornerShape(24.dp), + title = { Text("Delete rule?") }, text = { - Column { - Text(rule.name) - Spacer(modifier = Modifier.height(8.dp)) - Text("This action cannot be undone.") + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + rule.name, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + "This can't be undone.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } }, confirmButton = { TextButton( onClick = { viewModel.deleteRule(rule) - - if (selectedRule?.id == rule.id) { - selectedRule = null - } - + if (selectedRule?.id == rule.id) selectedRule = null rulePendingDeletion = null - } + }, + modifier = Modifier.testTag("rule_delete_confirm_button") ) { - Text("Delete") + Text("Delete", color = AccentRed, fontWeight = FontWeight.SemiBold) } }, dismissButton = { - TextButton( - onClick = { - rulePendingDeletion = null - } - ) { + TextButton(onClick = { rulePendingDeletion = null }) { Text("Cancel") } + }, + modifier = Modifier.testTag("rule_delete_dialog") + ) + } +} + +@Composable +private fun RuleCard( + rule: Rule, + timeFormatter: DateTimeFormatter, + onClick: () -> Unit, + onToggle: () -> Unit +) { + val color = actionColor(rule.action) + val contentAlpha = if (rule.enabled) 1f else 0.55f + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .testTag("rule_card_${rule.id}"), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Action-colored icon tile + Box( + modifier = Modifier + .size(44.dp) + .clip(RoundedCornerShape(14.dp)) + .background(color.copy(alpha = if (rule.enabled) 0.15f else 0.08f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = actionIcon(rule.action), + contentDescription = null, + tint = color.copy(alpha = contentAlpha), + modifier = Modifier.size(22.dp) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + + // Rule info + Column(modifier = Modifier.weight(1f)) { + Text( + text = rule.name, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha), + maxLines = 2 + ) + Spacer(modifier = Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + AttributeChip( + icon = Icons.Outlined.Apps, + label = rule.appDisplayName ?: rule.appPackage ?: "All apps", + color = AccentBlue.copy(alpha = contentAlpha) + ) + if (rule.timeStart != null || rule.timeEnd != null) { + AttributeChip( + icon = Icons.Outlined.Schedule, + label = listOfNotNull( + rule.timeStart?.format(timeFormatter), + rule.timeEnd?.format(timeFormatter) + ).joinToString("–"), + color = AccentTeal.copy(alpha = contentAlpha) + ) + } + if (rule.isInverted) { + AttributeChip( + icon = Icons.Outlined.SwapHoriz, + label = "Exception", + color = AccentAmber.copy(alpha = contentAlpha) + ) + } else if (rule.matchPattern != null) { + val patternIcon = if (rule.matchField == MatchField.SENDER) Icons.Outlined.Person else Icons.Outlined.Search + AttributeChip( + icon = patternIcon, + label = "\"${rule.matchPattern}\"", + color = AccentPurple.copy(alpha = contentAlpha) + ) + } + } } + + Spacer(modifier = Modifier.width(8.dp)) + + // Toggle switch + Switch( + checked = rule.enabled, + onCheckedChange = { onToggle() }, + colors = SwitchDefaults.colors( + checkedTrackColor = color, + checkedThumbColor = Color.White + ), + modifier = Modifier.testTag("rule_toggle_${rule.id}") + ) + } + } +} + +@Composable +private fun DetailRow(label: String, value: String) { + Column { + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(1.dp)) + Text( + value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface ) } } diff --git a/app/src/main/java/com/hush/app/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/hush/app/ui/screens/settings/SettingsScreen.kt index 397c30e..719cb91 100644 --- a/app/src/main/java/com/hush/app/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/hush/app/ui/screens/settings/SettingsScreen.kt @@ -9,13 +9,15 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.AutoDelete import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -26,6 +28,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -47,6 +50,10 @@ fun SettingsScreen( val isNotificationActive by viewModel.isNotificationActive.collectAsState() val isVoiceActive by viewModel.isVoiceActive.collectAsState() + // Refresh once when the screen first appears (tab navigation does not + // trigger ON_RESUME on the host activity) + LaunchedEffect(Unit) { viewModel.refreshPermissions() } + val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner, viewModel) { val observer = LifecycleEventObserver { _, event -> @@ -79,7 +86,11 @@ fun SettingsScreen( .padding(innerPadding) .verticalScroll(rememberScrollState()) ) { - Spacer(modifier = Modifier.height(16.dp)) + com.hush.app.ui.components.HushHeader( + title = "Settings", + subtitle = "Service status & preferences", + leadingIcon = Icons.Outlined.Settings + ) // ── Section: Service Status ── SectionLabel("SERVICE STATUS") @@ -102,7 +113,7 @@ fun SettingsScreen( // Voice Input SettingsRow( - icon = Icons.Filled.Settings, + icon = Icons.Filled.Mic, iconTint = Color.White, iconBackground = AccentGreen, title = "Voice Input", @@ -121,7 +132,7 @@ fun SettingsScreen( SectionLabel("APPEARANCE") SettingsRow( - icon = Icons.Filled.Star, + icon = Icons.Filled.Palette, iconTint = Color.White, iconBackground = AccentPurple, title = "Theme", @@ -192,7 +203,7 @@ fun SettingsScreen( SectionLabel("DATA") SettingsRow( - icon = Icons.Filled.Delete, + icon = Icons.Filled.AutoDelete, iconTint = Color.White, iconBackground = AccentBlue, title = "History Retention", @@ -448,7 +459,7 @@ fun SettingsScreen( // Reset Onboarding SettingsRow( - icon = Icons.Filled.Warning, + icon = Icons.Filled.RestartAlt, iconTint = Color.White, iconBackground = AccentRed, title = "Reset Onboarding", @@ -559,7 +570,8 @@ private fun StatusBadge(isActive: Boolean, modifier: Modifier = Modifier) { Surface( shape = CircleShape, color = bgColor, - modifier = modifier + // Read the badge as a single element (also lets tests query its text) + modifier = modifier.semantics(mergeDescendants = true) {} ) { Text( text = label, diff --git a/app/src/main/java/com/hush/app/ui/theme/Color.kt b/app/src/main/java/com/hush/app/ui/theme/Color.kt index 4ae0ea0..ad7121e 100644 --- a/app/src/main/java/com/hush/app/ui/theme/Color.kt +++ b/app/src/main/java/com/hush/app/ui/theme/Color.kt @@ -2,19 +2,39 @@ package com.hush.app.ui.theme import androidx.compose.ui.graphics.Color -// ── Primary Brand ── +// ── Brand ── +val BrandViolet = Color(0xFF6C4EE3) +val BrandVioletBright = Color(0xFF7C5CFC) + +// ── Light Palette (warm, paper-like) ── val WarmCream = Color(0xFFFAF8F4) val WarmSurface = Color(0xFFF5F2ED) val WarmCard = Color(0xFFFFFFFF) val WarmOnSurface = Color(0xFF1C1B1F) val WarmOnSurfaceVariant = Color(0xFF6B6A6E) +val WarmOutline = Color(0xFFDDD9D1) + +// Light tonal surface containers (M3) +val LightContainerLowest = Color(0xFFFFFFFF) +val LightContainerLow = Color(0xFFF6F3EE) +val LightContainer = Color(0xFFF1EDE7) +val LightContainerHigh = Color(0xFFEBE7E0) +val LightContainerHighest = Color(0xFFE5E1D9) + +// ── Dark Palette (deep violet-black) ── +val DarkBackground = Color(0xFF131118) +val DarkSurface = Color(0xFF131118) +val DarkCard = Color(0xFF211D29) +val DarkOnSurface = Color(0xFFE7E1EA) +val DarkOnSurfaceVariant = Color(0xFFA29DA8) +val DarkOutline = Color(0xFF3A3542) -// ── Dark Mode ── -val DarkBackground = Color(0xFF141218) -val DarkSurface = Color(0xFF1E1C22) -val DarkCard = Color(0xFF28262C) -val DarkOnSurface = Color(0xFFE6E1E5) -val DarkOnSurfaceVariant = Color(0xFFA09DA2) +// Dark tonal surface containers (M3) +val DarkContainerLowest = Color(0xFF0E0C12) +val DarkContainerLow = Color(0xFF1A1721) +val DarkContainer = Color(0xFF1F1B27) +val DarkContainerHigh = Color(0xFF29242F) +val DarkContainerHighest = Color(0xFF342E3D) // ── Accent Colors (for rule cards) ── val AccentPurple = Color(0xFF7C5CFC) @@ -43,15 +63,15 @@ val StatusAllowed = Color(0xFF3B82F6) val StatusAllowedBg = Color(0xFFDBEAFE) // ── Primary for interactions ── -val PrimaryLight = Color(0xFF6750A4) +val PrimaryLight = BrandViolet val OnPrimaryLight = Color(0xFFFFFFFF) -val PrimaryContainerLight = Color(0xFFEADDFF) -val OnPrimaryContainerLight = Color(0xFF21005D) +val PrimaryContainerLight = Color(0xFFE9E1FF) +val OnPrimaryContainerLight = Color(0xFF22005D) -val PrimaryDark = Color(0xFFD0BCFF) -val OnPrimaryDark = Color(0xFF381E72) -val PrimaryContainerDark = Color(0xFF4F378B) -val OnPrimaryContainerDark = Color(0xFFEADDFF) +val PrimaryDark = Color(0xFFCDBDFF) +val OnPrimaryDark = Color(0xFF32206B) +val PrimaryContainerDark = Color(0xFF4C3A94) +val OnPrimaryContainerDark = Color(0xFFE9E1FF) // ── Error ── val ErrorLight = Color(0xFFBA1A1A) @@ -63,8 +83,8 @@ val ErrorContainerDark = Color(0xFF93000A) val OnErrorContainerDark = Color(0xFFFFDAD6) // ── Bottom Nav ── -val NavIndicator = Color(0xFFEADDFF) -val NavIndicatorDark = Color(0xFF4F378B) +val NavIndicator = Color(0xFFE9E1FF) +val NavIndicatorDark = Color(0xFF4C3A94) // ── Card-on-Light (for status cards that always have light backgrounds) ── val CardOnLight = Color(0xFF1C1B1F) diff --git a/app/src/main/java/com/hush/app/ui/theme/Theme.kt b/app/src/main/java/com/hush/app/ui/theme/Theme.kt index 1f39603..7bcc4d2 100644 --- a/app/src/main/java/com/hush/app/ui/theme/Theme.kt +++ b/app/src/main/java/com/hush/app/ui/theme/Theme.kt @@ -12,13 +12,23 @@ private val LightColorScheme = lightColorScheme( primaryContainer = PrimaryContainerLight, onPrimaryContainer = OnPrimaryContainerLight, secondary = AccentPurple, + onSecondary = OnPrimaryLight, + secondaryContainer = NavIndicator, + onSecondaryContainer = OnPrimaryContainerLight, tertiary = AccentTeal, background = WarmCream, - surface = WarmSurface, + surface = WarmCream, surfaceVariant = WarmCard, + surfaceContainerLowest = LightContainerLowest, + surfaceContainerLow = LightContainerLow, + surfaceContainer = LightContainer, + surfaceContainerHigh = LightContainerHigh, + surfaceContainerHighest = LightContainerHighest, onBackground = WarmOnSurface, onSurface = WarmOnSurface, onSurfaceVariant = WarmOnSurfaceVariant, + outline = WarmOutline, + outlineVariant = WarmOutline, error = ErrorLight, errorContainer = ErrorContainerLight, onErrorContainer = OnErrorContainerLight @@ -30,13 +40,23 @@ private val DarkColorScheme = darkColorScheme( primaryContainer = PrimaryContainerDark, onPrimaryContainer = OnPrimaryContainerDark, secondary = AccentPurple, + onSecondary = OnPrimaryLight, + secondaryContainer = NavIndicatorDark, + onSecondaryContainer = OnPrimaryContainerDark, tertiary = AccentTeal, background = DarkBackground, surface = DarkSurface, surfaceVariant = DarkCard, + surfaceContainerLowest = DarkContainerLowest, + surfaceContainerLow = DarkContainerLow, + surfaceContainer = DarkContainer, + surfaceContainerHigh = DarkContainerHigh, + surfaceContainerHighest = DarkContainerHighest, onBackground = DarkOnSurface, onSurface = DarkOnSurface, onSurfaceVariant = DarkOnSurfaceVariant, + outline = DarkOutline, + outlineVariant = DarkOutline, error = ErrorDark, errorContainer = ErrorContainerDark, onErrorContainer = OnErrorContainerDark diff --git a/app/src/main/java/com/hush/app/ui/theme/Type.kt b/app/src/main/java/com/hush/app/ui/theme/Type.kt index a74fa4d..1ffa5e6 100644 --- a/app/src/main/java/com/hush/app/ui/theme/Type.kt +++ b/app/src/main/java/com/hush/app/ui/theme/Type.kt @@ -10,8 +10,36 @@ val Typography = Typography( displayLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Bold, - fontSize = 32.sp, + fontSize = 34.sp, lineHeight = 40.sp, + letterSpacing = (-0.5).sp + ), + displayMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 34.sp, + letterSpacing = (-0.25).sp + ), + headlineLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 26.sp, + lineHeight = 32.sp, + letterSpacing = (-0.25).sp + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + lineHeight = 30.sp, + letterSpacing = (-0.25).sp + ), + headlineSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 26.sp, letterSpacing = 0.sp ), titleLarge = TextStyle( @@ -21,25 +49,60 @@ val Typography = Typography( lineHeight = 28.sp, letterSpacing = 0.sp ), + titleMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 22.sp, + letterSpacing = 0.1.sp + ), + titleSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), bodyLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, - letterSpacing = 0.5.sp + letterSpacing = 0.25.sp ), bodyMedium = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 14.sp, + lineHeight = 21.sp, + letterSpacing = 0.15.sp + ), + bodySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.5.sp, + lineHeight = 18.sp, + letterSpacing = 0.15.sp + ), + labelLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, lineHeight = 20.sp, - letterSpacing = 0.25.sp + letterSpacing = 0.1.sp ), labelMedium = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp, - letterSpacing = 0.5.sp + letterSpacing = 0.4.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 15.sp, + letterSpacing = 0.4.sp ) ) diff --git a/app/src/test/java/com/hush/app/domain/usecase/FallbackCommandParserTest.kt b/app/src/test/java/com/hush/app/domain/usecase/FallbackCommandParserTest.kt new file mode 100644 index 0000000..2f5130a --- /dev/null +++ b/app/src/test/java/com/hush/app/domain/usecase/FallbackCommandParserTest.kt @@ -0,0 +1,96 @@ +package com.hush.app.domain.usecase + +import com.hush.app.domain.model.MatchField +import com.hush.app.domain.model.RuleAction +import com.hush.app.domain.repository.AppInfo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalTime + +class FallbackCommandParserTest { + + private val parser = FallbackCommandParser() + private val apps = listOf( + AppInfo("Instagram", "com.instagram.android"), + AppInfo("WhatsApp", "com.whatsapp"), + AppInfo("Slack", "com.slack"), + AppInfo("Gmail", "com.google.android.gm") + ) + + @Test + fun muteApp_simple() { + val result = parser.parse("Mute Instagram", apps) + assertEquals(RuleAction.MUTE, result.action) + assertEquals("com.instagram.android", result.app) + assertNull(result.matchPattern) + assertFalse(result.isInverted) + } + + @Test + fun blockApp_withAfterTime() { + val result = parser.parse("Block Slack after 6pm", apps) + assertEquals(RuleAction.BLOCK, result.action) + assertEquals("com.slack", result.app) + assertEquals(LocalTime.of(18, 0), result.timeStart) + assertNull(result.timeEnd) + } + + @Test + fun muteApp_betweenTimes() { + val result = parser.parse("Mute Slack between 10pm and 7am", apps) + assertEquals(RuleAction.MUTE, result.action) + assertEquals("com.slack", result.app) + assertEquals(LocalTime.of(22, 0), result.timeStart) + assertEquals(LocalTime.of(7, 0), result.timeEnd) + } + + @Test + fun exceptionRule_invertedSenderMatch() { + val result = parser.parse("Mute WhatsApp except from Bob", apps) + assertEquals(RuleAction.MUTE, result.action) + assertEquals("com.whatsapp", result.app) + assertTrue(result.isInverted) + assertEquals(MatchField.SENDER, result.matchField) + assertEquals("bob", result.matchPattern?.lowercase()) + } + + @Test + fun categoryKeyword_promos() { + val result = parser.parse("Silence promos", apps) + assertEquals(RuleAction.MUTE, result.action) + assertNull(result.app) + assertEquals("promo", result.matchPattern) + } + + @Test + fun containsPattern_freeText() { + val result = parser.parse("Block Gmail containing invoice", apps) + assertEquals(RuleAction.BLOCK, result.action) + assertEquals("com.google.android.gm", result.app) + assertEquals("invoice", result.matchPattern) + } + + @Test + fun allowAction_recognized() { + val result = parser.parse("Allow WhatsApp", apps) + assertEquals(RuleAction.ALLOW, result.action) + assertEquals("com.whatsapp", result.app) + } + + @Test + fun unintelligible_throws() { + assertThrows(IllegalArgumentException::class.java) { + parser.parse("What is the weather today", apps) + } + } + + @Test + fun summary_isHumanReadable() { + val result = parser.parse("Block Slack after 6pm", apps) + assertEquals("Block Slack notifications after 6 PM", result.summary) + } +} diff --git a/app/src/test/java/com/hush/app/ui/screens/chat/ChatViewModelTest.kt b/app/src/test/java/com/hush/app/ui/screens/chat/ChatViewModelTest.kt index 12a11b0..8db4c07 100644 --- a/app/src/test/java/com/hush/app/ui/screens/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/hush/app/ui/screens/chat/ChatViewModelTest.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.result.ActivityResult +import com.hush.app.domain.model.ChatRole import com.hush.app.domain.model.MatchField import com.hush.app.domain.model.MatchType import com.hush.app.domain.model.ParsedCommand @@ -274,14 +275,15 @@ class ChatViewModelTest { @Test fun testHandleSend_addsMessageAndTriggersAI() = runTest { - val initialSize = viewModel.mockMessages.size + val initialSize = viewModel.messages.size viewModel.handleSend("Mute WhatsApp") testScheduler.advanceUntilIdle() - // Prompt added to mockMessages - assertEquals(initialSize + 1, viewModel.mockMessages.size) - assertEquals("Mute WhatsApp", viewModel.mockMessages.last()) + // Prompt added to messages as a user message + assertEquals(initialSize + 1, viewModel.messages.size) + assertEquals("Mute WhatsApp", viewModel.messages.last().text) + assertEquals(ChatRole.USER, viewModel.messages.last().role) // Input text cleared assertEquals("", viewModel.textState.value) @@ -295,12 +297,12 @@ class ChatViewModelTest { @Test fun testHandleSend_blankPrompt_ignored() = runTest { - val initialSize = viewModel.mockMessages.size + val initialSize = viewModel.messages.size viewModel.handleSend(" ") testScheduler.advanceUntilIdle() - assertEquals(initialSize, viewModel.mockMessages.size) + assertEquals(initialSize, viewModel.messages.size) assertNull(viewModel.proposedRule.value) } @@ -310,7 +312,7 @@ class ChatViewModelTest { testScheduler.advanceUntilIdle() assertNull(viewModel.proposedRule.value) - assertEquals("AI Engine error: Malformed AI response: summary is missing or invalid", viewModel.errorMessage.value) + assertEquals("Malformed AI response: summary is missing or invalid", viewModel.errorMessage.value) } @Test @@ -318,7 +320,7 @@ class ChatViewModelTest { // Set up a proposed rule viewModel.handleSend("Mute WhatsApp") testScheduler.advanceUntilIdle() - val initialMessagesSize = viewModel.mockMessages.size + val initialMessagesSize = viewModel.messages.size // Confirm it viewModel.confirmProposedRule() @@ -334,8 +336,9 @@ class ChatViewModelTest { assertTrue(saved.enabled) // Success bubble added to chat log - assertEquals(initialMessagesSize + 1, viewModel.mockMessages.size) - assertEquals("Rule created successfully", viewModel.mockMessages.last()) + assertEquals(initialMessagesSize + 1, viewModel.messages.size) + assertEquals("Rule created successfully", viewModel.messages.last().text) + assertEquals(ChatRole.ASSISTANT, viewModel.messages.last().role) // Proposed rule cleared assertNull(viewModel.proposedRule.value) diff --git a/gradle.properties b/gradle.properties index 7159c46..ffb54aa 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ android.enableJetifier=false android.nonTransitiveRClass=true # Optimal Gradle Daemon memory limits -org.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=384m +org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m # Kotlin code formatting guidelines kotlin.code.style=official diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8a0849a..550d8d5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,13 +7,13 @@ room = "2.7.2" androidx-core = "1.13.1" androidx-lifecycle = "2.8.2" activity-compose = "1.9.0" -compose-bom = "2024.06.00" +compose-bom = "2025.05.00" navigation-compose = "2.7.7" hilt-navigation-compose = "1.2.0" coroutines = "1.8.1" junit = "4.13.2" -androidx-test-ext = "1.1.5" -espresso-core = "3.5.1" +androidx-test-ext = "1.3.0" +espresso-core = "3.7.0" mlkit-genai-prompt = "1.0.0-beta2" kotlinx-coroutines-play-services = "1.8.1" @@ -30,6 +30,7 @@ androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graph androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" } hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-navigation-compose" }