first commit
73
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# =========================
|
||||
# IDE (Android Studio / IntelliJ)
|
||||
# =========================
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
# =========================
|
||||
# Gradle / Kotlin
|
||||
# =========================
|
||||
.gradle/
|
||||
build/
|
||||
out/
|
||||
/.kotlin/
|
||||
|
||||
# =========================
|
||||
# Local configuration
|
||||
# =========================
|
||||
local.properties
|
||||
|
||||
# =========================
|
||||
# Native / C++ builds
|
||||
# =========================
|
||||
.cxx/
|
||||
.externalNativeBuild/
|
||||
|
||||
# =========================
|
||||
# Generated / temporary
|
||||
# =========================
|
||||
captures/
|
||||
.navigation/
|
||||
bin/
|
||||
gen/
|
||||
proguard/
|
||||
*.log
|
||||
|
||||
# =========================
|
||||
# Build artifacts
|
||||
# =========================
|
||||
*.apk
|
||||
*.ap_
|
||||
*.aab
|
||||
*.dex
|
||||
*.class
|
||||
|
||||
# =========================
|
||||
# Keystores (VERY important)
|
||||
# =========================
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# =========================
|
||||
# OS junk
|
||||
# =========================
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# =========================
|
||||
# Tools / plugins / others
|
||||
# =========================
|
||||
/.continue/
|
||||
/eva/
|
||||
/tellnext/
|
||||
/browsernext/
|
||||
/speakernext/
|
||||
/transfernext/
|
||||
/wtCaptures/
|
||||
/logs/
|
||||
/tests/
|
||||
*.sh
|
||||
*.swp
|
||||
TellNextTrace*.txt
|
||||
BNWT.txt
|
||||
|
||||
598
CLAUDE.md
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
# NotificationSaver — Claude Code Instructions
|
||||
|
||||
## READ FIRST
|
||||
|
||||
Read `NotificationSaver-ARCHITECTURE.md` before doing anything. It contains the full architecture, module structure, database schema, all features, and the test instrumentation protocol.
|
||||
|
||||
---
|
||||
|
||||
## Project Philosophy
|
||||
|
||||
This project follows an **iterative build-test-verify** methodology. You never write a large chunk of code and hope it works. Instead:
|
||||
|
||||
1. Write a small, focused piece of code
|
||||
2. Build it
|
||||
3. Test it (install, run, verify via ADB)
|
||||
4. Review it (is it clean? follows standards? no duplication?)
|
||||
5. Only then move to the next piece
|
||||
|
||||
**Quality over speed.** Every file you create must follow Google's Android coding standards, use proper Hilt injection, have no duplicate logic, and be production-ready.
|
||||
|
||||
---
|
||||
|
||||
## Development Standards
|
||||
|
||||
### Mandatory for ALL code
|
||||
|
||||
- **Kotlin coding conventions** — official Kotlin style guide
|
||||
- **Jetpack Compose best practices** — stable types, skip-friendly composables, no side effects in composition
|
||||
- **Clean Architecture** — domain layer has ZERO Android imports, presentation never touches data layer directly
|
||||
- **Hilt/Dagger DI** — no manual instantiation of repositories, use cases, or ViewModels. Everything injected.
|
||||
- **No duplicate code** — if two screens share logic, extract to a use case or shared composable
|
||||
- **Repository pattern** — interfaces in domain, implementations in data
|
||||
- **Coroutines + Flow** — suspend for one-shot, Flow for streams. viewModelScope in ViewModels. IO dispatcher for DB.
|
||||
- **Single Activity** — Compose Navigation for all screens
|
||||
- **Material 3** — dynamic color, proper theming, dark mode
|
||||
- **Edge-to-edge** — handle insets properly with `WindowInsets`
|
||||
- **Proper error handling** — try/catch in data layer, sealed Result types, never crash on data errors
|
||||
- **No hardcoded strings** — all user-visible text in `strings.xml`
|
||||
- **Content descriptions** — on all icons and interactive elements for accessibility
|
||||
- **ProGuard-safe** — keep annotations where needed, test release builds
|
||||
|
||||
### Before committing any file, ask yourself:
|
||||
- Does this class have a single responsibility?
|
||||
- Is there another class that already does something similar?
|
||||
- Are all dependencies injected via Hilt?
|
||||
- Would a senior Android developer approve this code?
|
||||
- Is the domain layer still free of Android imports?
|
||||
- Are Compose previews provided for screens?
|
||||
|
||||
---
|
||||
|
||||
## Build Flavors
|
||||
|
||||
Three flavors exist: `free`, `pro`, `testing`.
|
||||
|
||||
```bash
|
||||
# Build commands
|
||||
./gradlew assembleFreeDebug # Free flavor, debug
|
||||
./gradlew assembleProDebug # Pro flavor, debug
|
||||
./gradlew assembleTestingDebug # Testing flavor, debug (this is your main one)
|
||||
./gradlew assembleProRelease # Pro release (minified)
|
||||
./gradlew assembleFreeRelease # Free release (minified + ads)
|
||||
|
||||
# Install testing flavor
|
||||
adb install -r app/build/outputs/apk/testing/debug/app-testing-debug.apk
|
||||
|
||||
# Launch testing flavor
|
||||
adb shell am start -n com.rounding.notisaver.testing.debug/com.rounding.notisaver.presentation.MainActivity
|
||||
|
||||
# Grant notification listener
|
||||
adb shell cmd notification allow_listener \
|
||||
com.rounding.notisaver.testing.debug/com.rounding.notisaver.service.NotificationCaptureService
|
||||
```
|
||||
|
||||
The **testing** flavor is your primary development target. It has all pro features plus the test instrumentation layer. Build and test against it. Periodically also build the free and pro flavors to verify they compile and the flavor-specific code (ads, feature gates) works.
|
||||
|
||||
---
|
||||
|
||||
## ADB Testing Protocol
|
||||
|
||||
### Reading app state
|
||||
|
||||
```bash
|
||||
# Dump all visible UI element positions → logcat
|
||||
adb shell am broadcast -a com.rounding.notisaver.test.DUMP_UI
|
||||
adb logcat -d -s CLAUDE_TEST:V
|
||||
|
||||
# Dump app state → logcat
|
||||
adb shell am broadcast -a com.rounding.notisaver.test.DUMP_STATE
|
||||
adb logcat -d -s CLAUDE_TEST:V
|
||||
|
||||
# Stream test logs live
|
||||
adb logcat -c && adb logcat -s CLAUDE_TEST:V
|
||||
|
||||
# Check for crashes
|
||||
adb logcat -d | grep -E "(CLAUDE_TEST|AndroidRuntime|FATAL)"
|
||||
```
|
||||
|
||||
### Interacting
|
||||
|
||||
```bash
|
||||
# Tap (always DUMP_UI first to get coordinates)
|
||||
adb shell input tap <x> <y>
|
||||
|
||||
# Type text (%s = space)
|
||||
adb shell input text "hello%sworld"
|
||||
|
||||
# Keys
|
||||
adb shell input keyevent KEYCODE_BACK
|
||||
adb shell input keyevent KEYCODE_HOME
|
||||
adb shell input keyevent KEYCODE_ENTER
|
||||
adb shell input keyevent KEYCODE_DEL # backspace
|
||||
|
||||
# Swipe (scroll)
|
||||
adb shell input swipe 540 1800 540 600 300 # scroll down
|
||||
|
||||
# Screenshot
|
||||
adb shell screencap -p /sdcard/screen.png && adb pull /sdcard/screen.png ./screenshot.png
|
||||
|
||||
# UI automator fallback
|
||||
adb shell uiautomator dump && adb pull /sdcard/window_dump.xml
|
||||
```
|
||||
|
||||
### Injecting test data
|
||||
|
||||
```bash
|
||||
# Single notification
|
||||
adb shell am broadcast -a com.rounding.notisaver.test.INJECT_NOTIFICATION \
|
||||
--es package "com.whatsapp" --es app_name "WhatsApp" \
|
||||
--es title "John" --es text "See you later" \
|
||||
--es category "message" --ei priority 2
|
||||
|
||||
# Batch
|
||||
adb shell am broadcast -a com.rounding.notisaver.test.INJECT_BATCH \
|
||||
--es json '[{"package":"com.whatsapp","title":"John","text":"Hi"},{"package":"com.google.android.gm","app_name":"Gmail","title":"Anna","text":"Meeting"}]'
|
||||
|
||||
# Remove notification
|
||||
adb shell am broadcast -a com.rounding.notisaver.test.REMOVE_NOTIFICATION --ei notification_id 123
|
||||
|
||||
# Navigate directly
|
||||
adb shell am broadcast -a com.rounding.notisaver.test.NAVIGATE --es screen "settings"
|
||||
|
||||
# Set preference
|
||||
adb shell am broadcast -a com.rounding.notisaver.test.SET_STATE --es key "retention_days" --es value "7"
|
||||
|
||||
# Clear DB
|
||||
adb shell am broadcast -a com.rounding.notisaver.test.CLEAR_DB
|
||||
|
||||
# Trigger workers
|
||||
adb shell am broadcast -a com.rounding.notisaver.test.TRIGGER_CLEANUP
|
||||
adb shell am broadcast -a com.rounding.notisaver.test.TRIGGER_DIGEST
|
||||
```
|
||||
|
||||
### CRITICAL RULE: Always DUMP_UI before tapping
|
||||
|
||||
Never guess coordinates. Always:
|
||||
1. `adb shell am broadcast -a com.rounding.notisaver.test.DUMP_UI`
|
||||
2. `adb logcat -d -s CLAUDE_TEST:V` → parse VIEW lines
|
||||
3. Calculate center of target element: `x = (left+right)/2, y = (top+bottom)/2`
|
||||
4. `adb shell input tap x y`
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Implementation Plan
|
||||
|
||||
**Follow these phases IN ORDER.** Each phase builds on the previous one. Do NOT skip ahead. Each step within a phase has a verification checkpoint — complete it before moving on.
|
||||
|
||||
---
|
||||
|
||||
### PHASE 0: Project Skeleton
|
||||
|
||||
#### Step 0.1 — Gradle setup
|
||||
Create the project structure with:
|
||||
- Project-level `build.gradle.kts` with Kotlin, Hilt, Firebase plugins
|
||||
- `gradle/libs.versions.toml` version catalog
|
||||
- App-level `build.gradle.kts` with all 3 flavors configured
|
||||
- `settings.gradle.kts`
|
||||
|
||||
**Verify:** `./gradlew assembleTestingDebug` compiles (empty app is fine).
|
||||
|
||||
#### Step 0.2 — Application class + Hilt
|
||||
- `App.kt` with `@HiltAndroidApp`
|
||||
- `MainActivity.kt` with `@AndroidEntryPoint` and empty Compose content
|
||||
- `AndroidManifest.xml` with application, activity, theme
|
||||
|
||||
**Verify:** Build, install on emulator, app launches showing blank screen. No crash in logcat.
|
||||
|
||||
#### Step 0.3 — Theme
|
||||
- Material 3 theme with dynamic color
|
||||
- Dark/light mode support
|
||||
- Color.kt, Type.kt, Theme.kt
|
||||
|
||||
**Verify:** Build, install, verify dark mode toggle works via `adb shell cmd uimode night yes` / `adb shell cmd uimode night no`. Screenshot both modes.
|
||||
|
||||
#### Step 0.4 — Navigation skeleton
|
||||
- `Route.kt` sealed interface with all routes
|
||||
- `NavGraph.kt` with empty placeholder screens
|
||||
- Bottom navigation bar: Timeline, Stats, Settings
|
||||
- Wire up in MainActivity
|
||||
|
||||
**Verify:** Build, install, tap each bottom nav item. DUMP_UI to confirm navigation works. Screenshot each tab.
|
||||
|
||||
#### Step 0.5 — Test instrumentation layer
|
||||
- `TestInstrumentation.kt` interface in main
|
||||
- `NoOpTestInstrumentation` (used by free/pro flavors)
|
||||
- `LiveTestInstrumentation` with `TestLogEmitter`, `UIPositionTracker` (testing flavor)
|
||||
- `TestBroadcastReceiver` with DUMP_UI, DUMP_STATE
|
||||
- `testTrack()` Modifier extension
|
||||
- Hilt modules for each flavor
|
||||
|
||||
**Verify:**
|
||||
1. Build and install testing flavor
|
||||
2. `adb shell am broadcast -a com.rounding.notisaver.test.DUMP_UI`
|
||||
3. `adb logcat -d -s CLAUDE_TEST:V` → should see VIEW lines for bottom nav items
|
||||
4. Build free flavor → verify it compiles (no-op instrumentation)
|
||||
5. Build pro flavor → verify it compiles
|
||||
|
||||
#### Step 0.6 — Code review checkpoint
|
||||
Review ALL code written so far:
|
||||
- Is Hilt injection correct everywhere?
|
||||
- No Android imports in domain layer?
|
||||
- No duplicate code?
|
||||
- Naming conventions followed?
|
||||
- ProGuard rules in place?
|
||||
Fix any issues before proceeding.
|
||||
|
||||
---
|
||||
|
||||
### PHASE 1: Database + Capture Service
|
||||
|
||||
#### Step 1.1 — Room database
|
||||
- `AppDatabase.kt` with all entities and DAOs
|
||||
- `NotificationEntity.kt`, `NotificationDao.kt` (CRUD + Flow queries + FTS)
|
||||
- `HiddenAppEntity.kt`, `HiddenAppDao.kt`
|
||||
- `Converters.kt` for JSON fields
|
||||
- Database module for Hilt
|
||||
|
||||
**Verify:**
|
||||
1. Build
|
||||
2. Write a quick unit test or use the testing flavor to insert/query a row via broadcast
|
||||
3. Check logcat for any Room errors
|
||||
|
||||
#### Step 1.2 — Domain models + repository interface
|
||||
- `CapturedNotification.kt` and other domain models
|
||||
- `NotificationRepository.kt` interface in domain
|
||||
- `NotificationRepositoryImpl.kt` in data with mapper
|
||||
- `NotificationMapper.kt` (entity ↔ domain)
|
||||
|
||||
**Verify:** Build compiles. Domain models have no Android imports. Repository interface is clean.
|
||||
|
||||
#### Step 1.3 — NotificationListenerService
|
||||
- `NotificationCaptureService.kt` — capture posted/removed notifications
|
||||
- `NotificationParser.kt` — extract all fields from StatusBarNotification
|
||||
- Wire through repository to Room
|
||||
- Handle hidden apps (skip capture)
|
||||
|
||||
**Verify:**
|
||||
1. Build and install testing flavor
|
||||
2. Grant notification listener permission via adb
|
||||
3. Send a test notification from another app (or use `adb shell am` to trigger a system notification)
|
||||
4. DUMP_STATE → check `db_count` increased
|
||||
5. Check logcat for EVENT notification_captured
|
||||
|
||||
#### Step 1.4 — FakeNotificationInjector (testing flavor)
|
||||
- Implement INJECT_NOTIFICATION and INJECT_BATCH broadcast handlers
|
||||
- Insert directly into Room via repository (bypass service)
|
||||
- Wire up CLEAR_DB, DUMP_STATE with real db counts
|
||||
|
||||
**Verify:**
|
||||
1. CLEAR_DB
|
||||
2. Inject 5 notifications via broadcast
|
||||
3. DUMP_STATE → db_count=5
|
||||
4. CLEAR_DB → DUMP_STATE → db_count=0
|
||||
|
||||
#### Step 1.5 — Code review checkpoint
|
||||
Review database layer, service, repository, mapper. Check:
|
||||
- Proper indices on Room entities?
|
||||
- Mapper handles nulls correctly?
|
||||
- Service properly handles edge cases (null title, missing extras)?
|
||||
- No memory leaks in service?
|
||||
|
||||
---
|
||||
|
||||
### PHASE 2: Timeline Screen
|
||||
|
||||
#### Step 2.1 — GetTimelineUseCase
|
||||
- Use case that returns `Flow<List<CapturedNotification>>` from repository
|
||||
- Supports sort order parameter
|
||||
- Filters hidden apps
|
||||
|
||||
**Verify:** Build compiles. Use case has no Android imports.
|
||||
|
||||
#### Step 2.2 — TimelineViewModel
|
||||
- Hilt ViewModel
|
||||
- Exposes StateFlow of timeline data
|
||||
- Sort order state
|
||||
- Loading/error/empty states
|
||||
|
||||
**Verify:** Build compiles. ViewModel uses viewModelScope correctly.
|
||||
|
||||
#### Step 2.3 — TimelineScreen composable
|
||||
- LazyColumn with NotificationItem composables
|
||||
- Time block headers (Today, Yesterday, etc.)
|
||||
- Sort toggle in top bar
|
||||
- Pull-to-refresh (if needed) or auto-update via Flow
|
||||
- Empty state when no notifications
|
||||
- `testTrack()` on every interactive element
|
||||
|
||||
**Verify:**
|
||||
1. Build and install
|
||||
2. CLEAR_DB → inject 10 notifications
|
||||
3. DUMP_UI → verify notif_item_0 through notif_item_9 appear with correct bounds
|
||||
4. Screenshot → verify layout looks good
|
||||
5. Tap sort toggle → DUMP_UI → verify order changed
|
||||
6. CLEAR_DB → verify empty state appears
|
||||
7. Screenshot empty state
|
||||
|
||||
#### Step 2.4 — NotificationItem shared composable
|
||||
- App icon, app name, title, text preview, timestamp
|
||||
- Compact and detailed layout variants
|
||||
- Deleted message badge
|
||||
- Bookmark indicator
|
||||
- Shared across timeline, search results, filter results
|
||||
|
||||
**Verify:** Visual inspection via screenshots in both compact and detailed modes.
|
||||
|
||||
#### Step 2.5 — Code review checkpoint
|
||||
- Is LazyColumn using `key` parameter for stable recomposition?
|
||||
- Are composables skip-friendly (stable parameters)?
|
||||
- Is the shared NotificationItem truly reusable?
|
||||
- No business logic in composables?
|
||||
|
||||
---
|
||||
|
||||
### PHASE 3: Detail Screen
|
||||
|
||||
#### Step 3.1 — GetNotificationDetailUseCase
|
||||
**Verify:** Build compiles. Pure domain.
|
||||
|
||||
#### Step 3.2 — DetailScreen + ViewModel
|
||||
- Full notification content display
|
||||
- Actions: copy, share, open source app, bookmark toggle
|
||||
- Extras section (collapsible)
|
||||
- Navigation from timeline item tap
|
||||
|
||||
**Verify:**
|
||||
1. Inject notification, tap it on timeline (using DUMP_UI coordinates)
|
||||
2. DUMP_UI on detail screen → verify all elements present
|
||||
3. Tap copy → verify clipboard (or just verify no crash)
|
||||
4. Tap back → verify return to timeline
|
||||
5. Screenshot detail screen
|
||||
|
||||
---
|
||||
|
||||
### PHASE 4: Search
|
||||
|
||||
#### Step 4.1 — Room FTS setup
|
||||
- Add FTS virtual table for notifications
|
||||
- `SearchNotificationsUseCase`
|
||||
|
||||
**Verify:** Build compiles. Test search query returns correct results via DUMP_STATE.
|
||||
|
||||
#### Step 4.2 — SearchScreen + ViewModel
|
||||
- Search bar with debounced input
|
||||
- Results list using shared NotificationItem
|
||||
- Empty results state
|
||||
- Recent searches (optional)
|
||||
|
||||
**Verify:**
|
||||
1. Inject 20 notifications, 3 with "Amazon" in text
|
||||
2. Navigate to search → type "Amazon"
|
||||
3. DUMP_UI → verify 3 results
|
||||
4. Screenshot
|
||||
5. Clear search → verify all results gone
|
||||
6. Type gibberish → verify empty state
|
||||
|
||||
---
|
||||
|
||||
### PHASE 5: Filter
|
||||
|
||||
#### Step 5.1 — FilterByAppUseCase + FilterScreen
|
||||
- Checkbox list of all apps with notification counts
|
||||
- Select all / deselect all
|
||||
- Persist filter in app_filter_prefs
|
||||
|
||||
**Verify:**
|
||||
1. Inject from 4 different apps
|
||||
2. Open filter → DUMP_UI → verify 4 apps listed
|
||||
3. Deselect one → go to timeline → verify filtered
|
||||
4. Go back to filter → reselect → verify restored
|
||||
|
||||
---
|
||||
|
||||
### PHASE 6: Settings + Security
|
||||
|
||||
#### Step 6.1 — SettingsScreen
|
||||
- Retention period selector
|
||||
- Hidden apps management
|
||||
- App lock toggle
|
||||
- Theme mode toggle
|
||||
- Developer mode toggle (shows raw data viewer)
|
||||
- About section
|
||||
|
||||
**Verify:** Build, install, navigate to settings, toggle options, DUMP_STATE to confirm persisted.
|
||||
|
||||
#### Step 6.2 — AppLockManager + LockScreen
|
||||
- PIN setup/verification with EncryptedSharedPreferences
|
||||
- BiometricPrompt integration
|
||||
- Lock on app background
|
||||
|
||||
**Verify:**
|
||||
1. Enable PIN in settings
|
||||
2. Press home → relaunch
|
||||
3. DUMP_UI → verify lock screen shown
|
||||
4. Enter PIN via adb input
|
||||
5. DUMP_UI → verify timeline shown
|
||||
|
||||
#### Step 6.3 — Onboarding flow
|
||||
- Welcome → permission grant → optional lock setup → done
|
||||
|
||||
**Verify:** Clear app data, relaunch, walk through onboarding via ADB taps. Verify permission prompt appears.
|
||||
|
||||
---
|
||||
|
||||
### PHASE 7: Deleted Message Detection + Conversation View
|
||||
|
||||
#### Step 7.1 — DetectDeletedMessageUseCase
|
||||
- Logic for flagging rapid removals
|
||||
|
||||
**Verify:** Inject notification → wait 1s → remove → check flag in DB.
|
||||
|
||||
#### Step 7.2 — ConversationScreen
|
||||
- Group by sender, chat-bubble layout
|
||||
|
||||
**Verify:** Inject 10 messages between 2 senders. Open conversation view. Screenshot.
|
||||
|
||||
---
|
||||
|
||||
### PHASE 8: Statistics
|
||||
|
||||
#### Step 8.1 — ComputeStatisticsUseCase + StatsScreen
|
||||
- Per-app counts, frequency graph, noisy apps ranking
|
||||
|
||||
**Verify:** Inject 50 varied notifications. Navigate to stats. Screenshot. Verify numbers match.
|
||||
|
||||
---
|
||||
|
||||
### PHASE 9: Export
|
||||
|
||||
#### Step 9.1 — ExportNotificationsUseCase + ExportScreen
|
||||
- CSV, JSON, TXT, PDF with date/app filters
|
||||
- Save to Downloads
|
||||
|
||||
**Verify:** Export CSV → pull via adb → check content matches DB data.
|
||||
|
||||
---
|
||||
|
||||
### PHASE 10: Firebase + Crashlytics
|
||||
|
||||
#### Step 10.1 — Firebase setup
|
||||
- Add google-services.json per flavor
|
||||
- Initialize Crashlytics
|
||||
- Add custom keys (flavor, version)
|
||||
- Add non-fatal exception logging in catch blocks
|
||||
|
||||
**Verify:** Build all 3 flavors. Force a crash in testing flavor → check Crashlytics dashboard (or just verify no build errors).
|
||||
|
||||
#### Step 10.2 — Analytics events
|
||||
- Screen views, feature usage events (aggregate only, no PII)
|
||||
|
||||
**Verify:** Build free + pro. Verify events fire (logcat Firebase debug).
|
||||
|
||||
---
|
||||
|
||||
### PHASE 11: Ads (free flavor)
|
||||
|
||||
#### Step 11.1 — AdMob integration
|
||||
- `AdsModule.kt` in free flavor
|
||||
- `AdManager.kt` — initialize, load banner
|
||||
- `AdBanner.kt` composable — bottom of TimelineScreen only
|
||||
- Wrap in `if (BuildConfig.ADS_ENABLED)`
|
||||
|
||||
**Verify:**
|
||||
1. Build free flavor
|
||||
2. Install → verify banner appears on timeline
|
||||
3. Navigate to other screens → verify NO ads anywhere else
|
||||
4. Build pro flavor → verify no ad code compiled
|
||||
5. Build testing flavor → verify no ad code compiled
|
||||
|
||||
#### Step 11.2 — PremiumGate composable
|
||||
- Shown when free users tap pro features
|
||||
- "Upgrade to Pro" with feature list
|
||||
- Link to Play Store listing
|
||||
|
||||
**Verify:** Build free flavor, tap export (pro feature) → verify gate shown instead.
|
||||
|
||||
---
|
||||
|
||||
### PHASE 12: Polish + Release Prep
|
||||
|
||||
#### Step 12.1 — Widget
|
||||
- Home screen widget showing last N notifications
|
||||
|
||||
#### Step 12.2 — Regex filters + keyword alerts (pro)
|
||||
|
||||
#### Step 12.3 — Notification diffing UI (pro)
|
||||
|
||||
#### Step 12.4 — Backup/restore (pro)
|
||||
|
||||
#### Step 12.5 — Full regression test
|
||||
Run all test scenarios from the architecture doc (Scenarios 1-10) against the testing flavor. Fix anything broken.
|
||||
|
||||
#### Step 12.6 — Build release APKs
|
||||
```bash
|
||||
./gradlew assembleFreeRelease
|
||||
./gradlew assembleProRelease
|
||||
```
|
||||
Verify both build with ProGuard enabled. Install release builds on emulator and smoke test.
|
||||
|
||||
#### Step 12.7 — Final code review
|
||||
Walk through every file. Check:
|
||||
- No TODO comments left
|
||||
- No hardcoded strings
|
||||
- No unused imports
|
||||
- No duplicate logic
|
||||
- All Hilt modules correct
|
||||
- All content descriptions present
|
||||
- ProGuard rules complete
|
||||
- Crashlytics not logging PII
|
||||
- Ads only in free flavor
|
||||
- Test instrumentation only in testing flavor
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### App won't install
|
||||
```bash
|
||||
adb shell pm list packages | grep notisaver
|
||||
adb uninstall com.rounding.notisaver.testing.debug
|
||||
adb install -r app/build/outputs/apk/testing/debug/app-testing-debug.apk
|
||||
```
|
||||
|
||||
### Notification listener not working
|
||||
```bash
|
||||
adb shell cmd notification list_listeners
|
||||
adb shell cmd notification allow_listener \
|
||||
com.rounding.notisaver.testing.debug/com.rounding.notisaver.service.NotificationCaptureService
|
||||
```
|
||||
|
||||
### No CLAUDE_TEST output
|
||||
- Verify testing flavor installed: `adb shell pm list packages | grep testing`
|
||||
- App running: `adb shell pidof com.rounding.notisaver.testing.debug`
|
||||
- Try DUMP_STATE and wait before reading logcat
|
||||
|
||||
### Build failures
|
||||
```bash
|
||||
./gradlew clean assembleTestingDebug --stacktrace
|
||||
java -version # must be 17+
|
||||
```
|
||||
|
||||
### Firebase issues
|
||||
- Verify google-services.json exists for the flavor being built
|
||||
- Check `app/src/<flavor>/google-services.json`
|
||||
|
||||
### Emulator
|
||||
```bash
|
||||
adb devices # list connected
|
||||
emulator -list-avds # list available
|
||||
emulator -avd <name> & # start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Editor
|
||||
|
||||
Use **vim** for all terminal text editing. Never use nano.
|
||||
|
||||
---
|
||||
|
||||
## Decision Authority
|
||||
|
||||
You have full authority to make UI/UX decisions. When choosing between approaches:
|
||||
- Pick the one that's most user-friendly
|
||||
- Pick the one that follows Material 3 guidelines
|
||||
- Pick the one that requires less code (simpler is better)
|
||||
- Pick the one a senior Android developer would choose
|
||||
|
||||
If a decision is genuinely ambiguous (e.g., bottom sheet vs full screen for filters), just pick one, implement it, test it, and note your reasoning in a code comment. We can always change it later.
|
||||
|
||||
You also decide:
|
||||
- Exact color schemes (within Material 3 dynamic color system)
|
||||
- Icon choices (Material Icons)
|
||||
- Animation details (keep subtle and functional)
|
||||
- Spacing and padding values (follow Material 3 specs)
|
||||
- Error message wording
|
||||
- Empty state illustrations or text
|
||||
- Settings organization and grouping
|
||||
1
app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
165
app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
val keystorePropsFile = file("${System.getProperty("user.home")}/AndroidStudioProjects/rounding/.keystore/rmt.properties")
|
||||
val keystoreProps = Properties()
|
||||
if (keystorePropsFile.exists()) {
|
||||
keystoreProps.load(FileInputStream(keystorePropsFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.roundingmobile.notisaver"
|
||||
compileSdk = 36
|
||||
|
||||
signingConfigs {
|
||||
create("rmt") {
|
||||
storeFile = file("${System.getProperty("user.home")}/AndroidStudioProjects/rounding/.keystore/${keystoreProps.getProperty("keystore", "")}")
|
||||
storePassword = keystoreProps.getProperty("keystore.password", "")
|
||||
keyAlias = keystoreProps.getProperty("release.alias", "")
|
||||
keyPassword = keystoreProps.getProperty("alias.password", "")
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.roundingmobile.notisaver"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0.0.beta01"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
flavorDimensions += "tier"
|
||||
productFlavors {
|
||||
create("free") {
|
||||
dimension = "tier"
|
||||
applicationId = "com.roundingmobile.notisaver"
|
||||
buildConfigField("boolean", "IS_FREE", "true")
|
||||
buildConfigField("boolean", "IS_PRO", "false")
|
||||
buildConfigField("boolean", "TEST_INSTRUMENTED", "false")
|
||||
buildConfigField("boolean", "ADS_ENABLED", "true")
|
||||
buildConfigField("int", "RETENTION_LIMIT_HOURS", "24")
|
||||
}
|
||||
create("pro") {
|
||||
dimension = "tier"
|
||||
applicationId = "com.roundingmobile.notisaver.pro"
|
||||
buildConfigField("boolean", "IS_FREE", "false")
|
||||
buildConfigField("boolean", "IS_PRO", "true")
|
||||
buildConfigField("boolean", "TEST_INSTRUMENTED", "false")
|
||||
buildConfigField("boolean", "ADS_ENABLED", "false")
|
||||
buildConfigField("int", "RETENTION_LIMIT_HOURS", "0")
|
||||
}
|
||||
create("dev") {
|
||||
dimension = "tier"
|
||||
applicationId = "com.roundingmobile.notisaver.dev"
|
||||
buildConfigField("boolean", "IS_FREE", "false")
|
||||
buildConfigField("boolean", "IS_PRO", "true")
|
||||
buildConfigField("boolean", "TEST_INSTRUMENTED", "true")
|
||||
buildConfigField("boolean", "ADS_ENABLED", "false")
|
||||
buildConfigField("int", "RETENTION_LIMIT_HOURS", "0")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
signingConfig = signingConfigs.getByName("rmt")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
signingConfig = signingConfigs.getByName("rmt")
|
||||
// No applicationIdSuffix — debug and release share the same package name
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// AndroidX Core
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
// Compose
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.graphics)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.material.icons.extended)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
|
||||
// Navigation
|
||||
implementation(libs.navigation.compose)
|
||||
|
||||
// Lifecycle
|
||||
implementation(libs.lifecycle.runtime.compose)
|
||||
implementation(libs.lifecycle.viewmodel.compose)
|
||||
|
||||
// Hilt
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.hilt.navigation.compose)
|
||||
|
||||
// Room
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
ksp(libs.room.compiler)
|
||||
|
||||
// WorkManager
|
||||
implementation(libs.work.runtime.ktx)
|
||||
implementation(libs.hilt.work)
|
||||
|
||||
// Coroutines
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(libs.coroutines.android)
|
||||
|
||||
// Security
|
||||
implementation(libs.security.crypto)
|
||||
implementation(libs.biometric)
|
||||
|
||||
// Serialization
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
// Testing
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
}
|
||||
30
app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Firebase Crashlytics
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-keep public class * extends java.lang.Exception
|
||||
|
||||
# Room
|
||||
-keep class * extends androidx.room.RoomDatabase
|
||||
-keep @androidx.room.Entity class *
|
||||
|
||||
# Hilt
|
||||
-keep class dagger.hilt.** { *; }
|
||||
-keep class * extends dagger.hilt.android.lifecycle.HiltViewModel
|
||||
|
||||
# Kotlin Serialization
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.AnnotationsKt
|
||||
-keepclassmembers class kotlinx.serialization.json.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class kotlinx.serialization.json.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# Keep @Serializable classes
|
||||
-keep,includedescriptorclasses class com.roundingmobile.notisaver.**$$serializer { *; }
|
||||
-keepclassmembers class com.roundingmobile.notisaver.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class com.roundingmobile.notisaver.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "49ae503adf3a70e553335f5f614e56ce",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "notifications",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `package_name` TEXT NOT NULL, `app_name` TEXT NOT NULL, `title` TEXT, `text` TEXT, `big_text` TEXT, `category` TEXT, `priority` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `notification_id` INTEGER NOT NULL, `notification_tag` TEXT, `is_update` INTEGER NOT NULL, `previous_version_id` INTEGER, `is_removed` INTEGER NOT NULL, `removed_at` INTEGER, `removal_delay_ms` INTEGER, `is_bookmarked` INTEGER NOT NULL, `extras_json` TEXT, `actions_json` TEXT, `icon_uri` TEXT, `created_at` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "package_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "appName",
|
||||
"columnName": "app_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "text",
|
||||
"columnName": "text",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "bigText",
|
||||
"columnName": "big_text",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "category",
|
||||
"columnName": "category",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "priority",
|
||||
"columnName": "priority",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationId",
|
||||
"columnName": "notification_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationTag",
|
||||
"columnName": "notification_tag",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isUpdate",
|
||||
"columnName": "is_update",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "previousVersionId",
|
||||
"columnName": "previous_version_id",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRemoved",
|
||||
"columnName": "is_removed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "removedAt",
|
||||
"columnName": "removed_at",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "removalDelayMs",
|
||||
"columnName": "removal_delay_ms",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isBookmarked",
|
||||
"columnName": "is_bookmarked",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "extrasJson",
|
||||
"columnName": "extras_json",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "actionsJson",
|
||||
"columnName": "actions_json",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "iconUri",
|
||||
"columnName": "icon_uri",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_notifications_package_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"package_name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_package_name` ON `${TABLE_NAME}` (`package_name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_notifications_timestamp",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"timestamp"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_timestamp` ON `${TABLE_NAME}` (`timestamp`)"
|
||||
},
|
||||
{
|
||||
"name": "index_notifications_category",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"category"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_category` ON `${TABLE_NAME}` (`category`)"
|
||||
},
|
||||
{
|
||||
"name": "index_notifications_is_bookmarked",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"is_bookmarked"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_is_bookmarked` ON `${TABLE_NAME}` (`is_bookmarked`)"
|
||||
},
|
||||
{
|
||||
"name": "index_notifications_package_name_timestamp",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"package_name",
|
||||
"timestamp"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_package_name_timestamp` ON `${TABLE_NAME}` (`package_name`, `timestamp`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "notifications_fts",
|
||||
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`title` TEXT, `text` TEXT, `big_text` TEXT, content=`notifications`)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "text",
|
||||
"columnName": "text",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "bigText",
|
||||
"columnName": "big_text",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": []
|
||||
},
|
||||
"ftsVersion": "FTS4",
|
||||
"ftsOptions": {
|
||||
"tokenizer": "simple",
|
||||
"tokenizerArgs": [],
|
||||
"contentTable": "notifications",
|
||||
"languageIdColumnName": "",
|
||||
"matchInfo": "FTS4",
|
||||
"notIndexedColumns": [],
|
||||
"prefixSizes": [],
|
||||
"preferredOrder": "ASC"
|
||||
},
|
||||
"contentSyncTriggers": [
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_notifications_fts_BEFORE_UPDATE BEFORE UPDATE ON `notifications` BEGIN DELETE FROM `notifications_fts` WHERE `docid`=OLD.`rowid`; END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_notifications_fts_BEFORE_DELETE BEFORE DELETE ON `notifications` BEGIN DELETE FROM `notifications_fts` WHERE `docid`=OLD.`rowid`; END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_notifications_fts_AFTER_UPDATE AFTER UPDATE ON `notifications` BEGIN INSERT INTO `notifications_fts`(`docid`, `title`, `text`, `big_text`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`text`, NEW.`big_text`); END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_notifications_fts_AFTER_INSERT AFTER INSERT ON `notifications` BEGIN INSERT INTO `notifications_fts`(`docid`, `title`, `text`, `big_text`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`text`, NEW.`big_text`); END"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "hidden_apps",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `hidden_at` INTEGER NOT NULL, PRIMARY KEY(`package_name`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "package_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hiddenAt",
|
||||
"columnName": "hidden_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"package_name"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "keyword_alerts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyword` TEXT NOT NULL, `is_regex` INTEGER NOT NULL, `is_active` INTEGER NOT NULL, `created_at` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "keyword",
|
||||
"columnName": "keyword",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRegex",
|
||||
"columnName": "is_regex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isActive",
|
||||
"columnName": "is_active",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "app_filter_prefs",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `is_visible` INTEGER NOT NULL, PRIMARY KEY(`package_name`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "package_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isVisible",
|
||||
"columnName": "is_visible",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"package_name"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '49ae503adf3a70e553335f5f614e56ce')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "49ae503adf3a70e553335f5f614e56ce",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "notifications",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `package_name` TEXT NOT NULL, `app_name` TEXT NOT NULL, `title` TEXT, `text` TEXT, `big_text` TEXT, `category` TEXT, `priority` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `notification_id` INTEGER NOT NULL, `notification_tag` TEXT, `is_update` INTEGER NOT NULL, `previous_version_id` INTEGER, `is_removed` INTEGER NOT NULL, `removed_at` INTEGER, `removal_delay_ms` INTEGER, `is_bookmarked` INTEGER NOT NULL, `extras_json` TEXT, `actions_json` TEXT, `icon_uri` TEXT, `created_at` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "package_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "appName",
|
||||
"columnName": "app_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "text",
|
||||
"columnName": "text",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "bigText",
|
||||
"columnName": "big_text",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "category",
|
||||
"columnName": "category",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "priority",
|
||||
"columnName": "priority",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationId",
|
||||
"columnName": "notification_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationTag",
|
||||
"columnName": "notification_tag",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isUpdate",
|
||||
"columnName": "is_update",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "previousVersionId",
|
||||
"columnName": "previous_version_id",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRemoved",
|
||||
"columnName": "is_removed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "removedAt",
|
||||
"columnName": "removed_at",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "removalDelayMs",
|
||||
"columnName": "removal_delay_ms",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isBookmarked",
|
||||
"columnName": "is_bookmarked",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "extrasJson",
|
||||
"columnName": "extras_json",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "actionsJson",
|
||||
"columnName": "actions_json",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "iconUri",
|
||||
"columnName": "icon_uri",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_notifications_package_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"package_name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_package_name` ON `${TABLE_NAME}` (`package_name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_notifications_timestamp",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"timestamp"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_timestamp` ON `${TABLE_NAME}` (`timestamp`)"
|
||||
},
|
||||
{
|
||||
"name": "index_notifications_category",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"category"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_category` ON `${TABLE_NAME}` (`category`)"
|
||||
},
|
||||
{
|
||||
"name": "index_notifications_is_bookmarked",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"is_bookmarked"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_is_bookmarked` ON `${TABLE_NAME}` (`is_bookmarked`)"
|
||||
},
|
||||
{
|
||||
"name": "index_notifications_package_name_timestamp",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"package_name",
|
||||
"timestamp"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_package_name_timestamp` ON `${TABLE_NAME}` (`package_name`, `timestamp`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "notifications_fts",
|
||||
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`title` TEXT, `text` TEXT, `big_text` TEXT, content=`notifications`)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "text",
|
||||
"columnName": "text",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "bigText",
|
||||
"columnName": "big_text",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": []
|
||||
},
|
||||
"ftsVersion": "FTS4",
|
||||
"ftsOptions": {
|
||||
"tokenizer": "simple",
|
||||
"tokenizerArgs": [],
|
||||
"contentTable": "notifications",
|
||||
"languageIdColumnName": "",
|
||||
"matchInfo": "FTS4",
|
||||
"notIndexedColumns": [],
|
||||
"prefixSizes": [],
|
||||
"preferredOrder": "ASC"
|
||||
},
|
||||
"contentSyncTriggers": [
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_notifications_fts_BEFORE_UPDATE BEFORE UPDATE ON `notifications` BEGIN DELETE FROM `notifications_fts` WHERE `docid`=OLD.`rowid`; END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_notifications_fts_BEFORE_DELETE BEFORE DELETE ON `notifications` BEGIN DELETE FROM `notifications_fts` WHERE `docid`=OLD.`rowid`; END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_notifications_fts_AFTER_UPDATE AFTER UPDATE ON `notifications` BEGIN INSERT INTO `notifications_fts`(`docid`, `title`, `text`, `big_text`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`text`, NEW.`big_text`); END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_notifications_fts_AFTER_INSERT AFTER INSERT ON `notifications` BEGIN INSERT INTO `notifications_fts`(`docid`, `title`, `text`, `big_text`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`text`, NEW.`big_text`); END"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "hidden_apps",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `hidden_at` INTEGER NOT NULL, PRIMARY KEY(`package_name`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "package_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hiddenAt",
|
||||
"columnName": "hidden_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"package_name"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "keyword_alerts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyword` TEXT NOT NULL, `is_regex` INTEGER NOT NULL, `is_active` INTEGER NOT NULL, `created_at` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "keyword",
|
||||
"columnName": "keyword",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRegex",
|
||||
"columnName": "is_regex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isActive",
|
||||
"columnName": "is_active",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "app_filter_prefs",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `is_visible` INTEGER NOT NULL, PRIMARY KEY(`package_name`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "package_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isVisible",
|
||||
"columnName": "is_visible",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"package_name"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '49ae503adf3a70e553335f5f614e56ce')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,407 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "fdae8dd35af19ff1c55b3b47d655d07d",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "notifications",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `package_name` TEXT NOT NULL, `app_name` TEXT NOT NULL, `title` TEXT, `text` TEXT, `big_text` TEXT, `category` TEXT, `priority` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `notification_id` INTEGER NOT NULL, `notification_tag` TEXT, `is_update` INTEGER NOT NULL, `previous_version_id` INTEGER, `is_removed` INTEGER NOT NULL, `removed_at` INTEGER, `removal_delay_ms` INTEGER, `is_bookmarked` INTEGER NOT NULL, `extras_json` TEXT, `actions_json` TEXT, `icon_uri` TEXT, `created_at` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "package_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "appName",
|
||||
"columnName": "app_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "text",
|
||||
"columnName": "text",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "bigText",
|
||||
"columnName": "big_text",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "category",
|
||||
"columnName": "category",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "priority",
|
||||
"columnName": "priority",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationId",
|
||||
"columnName": "notification_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationTag",
|
||||
"columnName": "notification_tag",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isUpdate",
|
||||
"columnName": "is_update",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "previousVersionId",
|
||||
"columnName": "previous_version_id",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRemoved",
|
||||
"columnName": "is_removed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "removedAt",
|
||||
"columnName": "removed_at",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "removalDelayMs",
|
||||
"columnName": "removal_delay_ms",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isBookmarked",
|
||||
"columnName": "is_bookmarked",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "extrasJson",
|
||||
"columnName": "extras_json",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "actionsJson",
|
||||
"columnName": "actions_json",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "iconUri",
|
||||
"columnName": "icon_uri",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_notifications_package_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"package_name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_package_name` ON `${TABLE_NAME}` (`package_name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_notifications_timestamp",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"timestamp"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_timestamp` ON `${TABLE_NAME}` (`timestamp`)"
|
||||
},
|
||||
{
|
||||
"name": "index_notifications_category",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"category"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_category` ON `${TABLE_NAME}` (`category`)"
|
||||
},
|
||||
{
|
||||
"name": "index_notifications_is_bookmarked",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"is_bookmarked"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_is_bookmarked` ON `${TABLE_NAME}` (`is_bookmarked`)"
|
||||
},
|
||||
{
|
||||
"name": "index_notifications_package_name_timestamp",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"package_name",
|
||||
"timestamp"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_package_name_timestamp` ON `${TABLE_NAME}` (`package_name`, `timestamp`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "notifications_fts",
|
||||
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`title` TEXT, `text` TEXT, `big_text` TEXT, content=`notifications`)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "text",
|
||||
"columnName": "text",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "bigText",
|
||||
"columnName": "big_text",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": []
|
||||
},
|
||||
"ftsVersion": "FTS4",
|
||||
"ftsOptions": {
|
||||
"tokenizer": "simple",
|
||||
"tokenizerArgs": [],
|
||||
"contentTable": "notifications",
|
||||
"languageIdColumnName": "",
|
||||
"matchInfo": "FTS4",
|
||||
"notIndexedColumns": [],
|
||||
"prefixSizes": [],
|
||||
"preferredOrder": "ASC"
|
||||
},
|
||||
"contentSyncTriggers": [
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_notifications_fts_BEFORE_UPDATE BEFORE UPDATE ON `notifications` BEGIN DELETE FROM `notifications_fts` WHERE `docid`=OLD.`rowid`; END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_notifications_fts_BEFORE_DELETE BEFORE DELETE ON `notifications` BEGIN DELETE FROM `notifications_fts` WHERE `docid`=OLD.`rowid`; END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_notifications_fts_AFTER_UPDATE AFTER UPDATE ON `notifications` BEGIN INSERT INTO `notifications_fts`(`docid`, `title`, `text`, `big_text`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`text`, NEW.`big_text`); END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_notifications_fts_AFTER_INSERT AFTER INSERT ON `notifications` BEGIN INSERT INTO `notifications_fts`(`docid`, `title`, `text`, `big_text`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`text`, NEW.`big_text`); END"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "hidden_apps",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `hidden_at` INTEGER NOT NULL, PRIMARY KEY(`package_name`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "package_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hiddenAt",
|
||||
"columnName": "hidden_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"package_name"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "keyword_alerts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyword` TEXT NOT NULL, `is_regex` INTEGER NOT NULL, `is_active` INTEGER NOT NULL, `created_at` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "keyword",
|
||||
"columnName": "keyword",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRegex",
|
||||
"columnName": "is_regex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isActive",
|
||||
"columnName": "is_active",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "app_filter_prefs",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `is_visible` INTEGER NOT NULL, PRIMARY KEY(`package_name`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "package_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isVisible",
|
||||
"columnName": "is_visible",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"package_name"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "filter_rules",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `action` TEXT NOT NULL, `match_field` TEXT NOT NULL, `match_type` TEXT NOT NULL, `pattern` TEXT NOT NULL, `package_name` TEXT, `app_name` TEXT, `is_enabled` INTEGER NOT NULL, `is_built_in` INTEGER NOT NULL, `created_at` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "action",
|
||||
"columnName": "action",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "matchField",
|
||||
"columnName": "match_field",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "matchType",
|
||||
"columnName": "match_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pattern",
|
||||
"columnName": "pattern",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "package_name",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "appName",
|
||||
"columnName": "app_name",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isEnabled",
|
||||
"columnName": "is_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isBuiltIn",
|
||||
"columnName": "is_built_in",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_filter_rules_is_enabled",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"is_enabled"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_filter_rules_is_enabled` ON `${TABLE_NAME}` (`is_enabled`)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fdae8dd35af19ff1c55b3b47d655d07d')"
|
||||
]
|
||||
}
|
||||
}
|
||||
23
app/src/dev/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<receiver
|
||||
android:name="com.roundingmobile.notisaver.instrumentation.TestBroadcastReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="com.roundingmobile.notisaver.test.DUMP_UI" />
|
||||
<action android:name="com.roundingmobile.notisaver.test.DUMP_STATE" />
|
||||
<action android:name="com.roundingmobile.notisaver.test.NAVIGATE" />
|
||||
<action android:name="com.roundingmobile.notisaver.test.SET_STATE" />
|
||||
<action android:name="com.roundingmobile.notisaver.test.CLEAR_DB" />
|
||||
<action android:name="com.roundingmobile.notisaver.test.INJECT_NOTIFICATION" />
|
||||
<action android:name="com.roundingmobile.notisaver.test.INJECT_BATCH" />
|
||||
<action android:name="com.roundingmobile.notisaver.test.REMOVE_NOTIFICATION" />
|
||||
<action android:name="com.roundingmobile.notisaver.test.TRIGGER_CLEANUP" />
|
||||
<action android:name="com.roundingmobile.notisaver.test.TRIGGER_DIGEST" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.roundingmobile.notisaver.di
|
||||
|
||||
import com.roundingmobile.notisaver.instrumentation.LiveTestInstrumentation
|
||||
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class FlavorModule {
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindTestInstrumentation(impl: LiveTestInstrumentation): TestInstrumentation
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package com.roundingmobile.notisaver.instrumentation
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class LiveTestInstrumentation @Inject constructor() : TestInstrumentation {
|
||||
|
||||
private val viewPositions = ConcurrentHashMap<String, ViewInfo>()
|
||||
private val stateMap = ConcurrentHashMap<String, String>()
|
||||
|
||||
data class ViewInfo(
|
||||
val id: String,
|
||||
val type: String,
|
||||
val bounds: Rect,
|
||||
val visible: Boolean,
|
||||
val text: String
|
||||
)
|
||||
|
||||
override fun trackView(id: String, type: String, bounds: Rect, visible: Boolean, text: String) {
|
||||
viewPositions[id] = ViewInfo(id, type, bounds, visible, text)
|
||||
}
|
||||
|
||||
override fun reportState(key: String, value: String) {
|
||||
stateMap[key] = value
|
||||
}
|
||||
|
||||
override fun reportEvent(event: String, params: Map<String, String>) {
|
||||
val paramsStr = params.entries.joinToString(" ") { "${it.key}=${it.value}" }
|
||||
Log.v(TAG, "EVENT $event $paramsStr".trim())
|
||||
}
|
||||
|
||||
override fun dumpUI() {
|
||||
viewPositions.forEach { (_, info) ->
|
||||
Log.v(
|
||||
TAG,
|
||||
"VIEW id=${info.id} type=${info.type} " +
|
||||
"bounds=${info.bounds.left.toInt()},${info.bounds.top.toInt()}," +
|
||||
"${info.bounds.right.toInt()},${info.bounds.bottom.toInt()} " +
|
||||
"visible=${info.visible} text=\"${info.text}\""
|
||||
)
|
||||
}
|
||||
if (viewPositions.isEmpty()) {
|
||||
Log.v(TAG, "VIEW none_tracked")
|
||||
}
|
||||
}
|
||||
|
||||
override fun dumpState() {
|
||||
val stateStr = stateMap.entries.joinToString(" ") { "${it.key}=${it.value}" }
|
||||
Log.v(TAG, "STATE $stateStr".trim())
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "CLAUDE_TEST"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
package com.roundingmobile.notisaver.instrumentation
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.roundingmobile.notisaver.domain.model.CapturedNotification
|
||||
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class TestBroadcastReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject lateinit var instrumentation: TestInstrumentation
|
||||
@Inject lateinit var repository: NotificationRepository
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
ACTION_DUMP_UI -> {
|
||||
Log.v(TAG, "DUMP_UI requested")
|
||||
instrumentation.dumpUI()
|
||||
}
|
||||
ACTION_DUMP_STATE -> {
|
||||
scope.launch {
|
||||
val count = repository.getCount()
|
||||
Log.v(TAG, "STATE db_count=$count")
|
||||
instrumentation.reportState("db_count", count.toString())
|
||||
instrumentation.dumpState()
|
||||
}
|
||||
}
|
||||
ACTION_INJECT_NOTIFICATION -> {
|
||||
val pkg = intent.getStringExtra("package") ?: "com.unknown"
|
||||
val appName = intent.getStringExtra("app_name") ?: pkg
|
||||
val title = intent.getStringExtra("title") ?: ""
|
||||
val text = intent.getStringExtra("text") ?: ""
|
||||
val category = intent.getStringExtra("category")
|
||||
val priority = intent.getIntExtra("priority", 0)
|
||||
val now = System.currentTimeMillis()
|
||||
val timestamp = intent.getLongExtra("timestamp", now)
|
||||
|
||||
scope.launch {
|
||||
val notification = CapturedNotification(
|
||||
id = 0,
|
||||
packageName = pkg,
|
||||
appName = appName,
|
||||
title = title,
|
||||
text = text,
|
||||
bigText = null,
|
||||
category = category,
|
||||
priority = priority,
|
||||
timestamp = timestamp,
|
||||
notificationId = 0,
|
||||
notificationTag = null,
|
||||
isUpdate = false,
|
||||
previousVersionId = null,
|
||||
isRemoved = false,
|
||||
removedAt = null,
|
||||
removalDelayMs = null,
|
||||
isBookmarked = false,
|
||||
extrasJson = null,
|
||||
actionsJson = null,
|
||||
iconUri = null,
|
||||
createdAt = timestamp
|
||||
)
|
||||
val id = repository.insert(notification)
|
||||
Log.v(TAG, "EVENT inject_notification id=$id package=$pkg title=\"$title\"")
|
||||
}
|
||||
}
|
||||
ACTION_INJECT_BATCH -> {
|
||||
val jsonStr = intent.getStringExtra("json") ?: return
|
||||
scope.launch {
|
||||
try {
|
||||
val items = json.decodeFromString<List<BatchItem>>(jsonStr)
|
||||
val now = System.currentTimeMillis()
|
||||
val notifications = items.mapIndexed { index, item ->
|
||||
CapturedNotification(
|
||||
id = 0,
|
||||
packageName = item.packageName,
|
||||
appName = item.appName ?: item.packageName,
|
||||
title = item.title,
|
||||
text = item.text,
|
||||
bigText = null,
|
||||
category = item.category,
|
||||
priority = item.priority ?: 0,
|
||||
timestamp = item.timestamp ?: (now + index),
|
||||
notificationId = 0,
|
||||
notificationTag = null,
|
||||
isUpdate = false,
|
||||
previousVersionId = null,
|
||||
isRemoved = false,
|
||||
removedAt = null,
|
||||
removalDelayMs = null,
|
||||
isBookmarked = false,
|
||||
extrasJson = null,
|
||||
actionsJson = null,
|
||||
iconUri = null,
|
||||
createdAt = item.timestamp ?: now
|
||||
)
|
||||
}
|
||||
repository.insertAll(notifications)
|
||||
Log.v(TAG, "EVENT inject_batch count=${notifications.size}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "ERROR type=inject_batch message=\"${e.message}\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
ACTION_REMOVE_NOTIFICATION -> {
|
||||
val id = intent.getIntExtra("notification_id", -1).toLong()
|
||||
if (id > 0) {
|
||||
scope.launch {
|
||||
val now = System.currentTimeMillis()
|
||||
repository.markRemoved(id, now, 1000L)
|
||||
Log.v(TAG, "EVENT remove_notification id=$id")
|
||||
}
|
||||
}
|
||||
}
|
||||
ACTION_CLEAR_DB -> {
|
||||
scope.launch {
|
||||
repository.deleteAll()
|
||||
Log.v(TAG, "EVENT clear_db")
|
||||
}
|
||||
}
|
||||
ACTION_NAVIGATE -> {
|
||||
val screen = intent.getStringExtra("screen") ?: return
|
||||
Log.v(TAG, "EVENT navigate_request screen=$screen")
|
||||
instrumentation.reportEvent("navigate_request", mapOf("screen" to screen))
|
||||
}
|
||||
ACTION_SET_STATE -> {
|
||||
val key = intent.getStringExtra("key") ?: return
|
||||
val value = intent.getStringExtra("value") ?: return
|
||||
Log.v(TAG, "SET_STATE $key=$value")
|
||||
instrumentation.reportState(key, value)
|
||||
}
|
||||
ACTION_TRIGGER_CLEANUP -> {
|
||||
Log.v(TAG, "EVENT trigger_cleanup")
|
||||
instrumentation.reportEvent("trigger_cleanup_request")
|
||||
}
|
||||
ACTION_TRIGGER_DIGEST -> {
|
||||
Log.v(TAG, "EVENT trigger_digest")
|
||||
instrumentation.reportEvent("trigger_digest_request")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class BatchItem(
|
||||
@kotlinx.serialization.SerialName("package") val packageName: String,
|
||||
@kotlinx.serialization.SerialName("app_name") val appName: String? = null,
|
||||
val title: String? = null,
|
||||
val text: String? = null,
|
||||
val category: String? = null,
|
||||
val priority: Int? = null,
|
||||
val timestamp: Long? = null
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CLAUDE_TEST"
|
||||
private const val PREFIX = "com.roundingmobile.notisaver.test."
|
||||
const val ACTION_DUMP_UI = "${PREFIX}DUMP_UI"
|
||||
const val ACTION_DUMP_STATE = "${PREFIX}DUMP_STATE"
|
||||
const val ACTION_NAVIGATE = "${PREFIX}NAVIGATE"
|
||||
const val ACTION_SET_STATE = "${PREFIX}SET_STATE"
|
||||
const val ACTION_CLEAR_DB = "${PREFIX}CLEAR_DB"
|
||||
const val ACTION_INJECT_NOTIFICATION = "${PREFIX}INJECT_NOTIFICATION"
|
||||
const val ACTION_INJECT_BATCH = "${PREFIX}INJECT_BATCH"
|
||||
const val ACTION_REMOVE_NOTIFICATION = "${PREFIX}REMOVE_NOTIFICATION"
|
||||
const val ACTION_TRIGGER_CLEANUP = "${PREFIX}TRIGGER_CLEANUP"
|
||||
const val ACTION_TRIGGER_DIGEST = "${PREFIX}TRIGGER_DIGEST"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.roundingmobile.notisaver.di
|
||||
|
||||
import com.roundingmobile.notisaver.instrumentation.NoOpTestInstrumentation
|
||||
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object FlavorModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTestInstrumentation(): TestInstrumentation = NoOpTestInstrumentation()
|
||||
}
|
||||
37
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.NotificationSaver"
|
||||
tools:targetApi="35">
|
||||
|
||||
<service
|
||||
android:name=".service.NotificationCaptureService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name=".presentation.MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.NotificationSaver">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
7
app/src/main/java/com/roundingmobile/notisaver/App.kt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package com.roundingmobile.notisaver
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class App : Application()
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.roundingmobile.notisaver.data.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import com.roundingmobile.notisaver.data.local.db.AppDatabase
|
||||
import com.roundingmobile.notisaver.data.local.db.dao.AppFilterDao
|
||||
import com.roundingmobile.notisaver.data.local.db.dao.FilterRuleDao
|
||||
import com.roundingmobile.notisaver.data.local.db.dao.HiddenAppDao
|
||||
import com.roundingmobile.notisaver.data.local.db.dao.NotificationDao
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DatabaseModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
|
||||
return Room.databaseBuilder(
|
||||
context,
|
||||
AppDatabase::class.java,
|
||||
"notisaver.db"
|
||||
).fallbackToDestructiveMigration(dropAllTables = true).build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideNotificationDao(db: AppDatabase): NotificationDao = db.notificationDao()
|
||||
|
||||
@Provides
|
||||
fun provideHiddenAppDao(db: AppDatabase): HiddenAppDao = db.hiddenAppDao()
|
||||
|
||||
@Provides
|
||||
fun provideAppFilterDao(db: AppDatabase): AppFilterDao = db.appFilterDao()
|
||||
|
||||
@Provides
|
||||
fun provideFilterRuleDao(db: AppDatabase): FilterRuleDao = db.filterRuleDao()
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.roundingmobile.notisaver.data.di
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object PreferencesModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSharedPreferences(@ApplicationContext context: Context): SharedPreferences {
|
||||
return context.getSharedPreferences("notisaver_prefs", Context.MODE_PRIVATE)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.roundingmobile.notisaver.data.di
|
||||
|
||||
import com.roundingmobile.notisaver.data.repository.FilterRuleRepositoryImpl
|
||||
import com.roundingmobile.notisaver.data.repository.NotificationRepositoryImpl
|
||||
import com.roundingmobile.notisaver.domain.repository.FilterRuleRepository
|
||||
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class RepositoryModule {
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindNotificationRepository(impl: NotificationRepositoryImpl): NotificationRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindFilterRuleRepository(impl: FilterRuleRepositoryImpl): FilterRuleRepository
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.roundingmobile.notisaver.data.local.db
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import com.roundingmobile.notisaver.data.local.db.dao.AppFilterDao
|
||||
import com.roundingmobile.notisaver.data.local.db.dao.FilterRuleDao
|
||||
import com.roundingmobile.notisaver.data.local.db.dao.HiddenAppDao
|
||||
import com.roundingmobile.notisaver.data.local.db.dao.NotificationDao
|
||||
import com.roundingmobile.notisaver.data.local.db.entity.AppFilterEntity
|
||||
import com.roundingmobile.notisaver.data.local.db.entity.FilterRuleEntity
|
||||
import com.roundingmobile.notisaver.data.local.db.entity.HiddenAppEntity
|
||||
import com.roundingmobile.notisaver.data.local.db.entity.KeywordAlertEntity
|
||||
import com.roundingmobile.notisaver.data.local.db.entity.NotificationEntity
|
||||
import com.roundingmobile.notisaver.data.local.db.entity.NotificationFtsEntity
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
NotificationEntity::class,
|
||||
NotificationFtsEntity::class,
|
||||
HiddenAppEntity::class,
|
||||
KeywordAlertEntity::class,
|
||||
AppFilterEntity::class,
|
||||
FilterRuleEntity::class
|
||||
],
|
||||
version = 2,
|
||||
exportSchema = true
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun notificationDao(): NotificationDao
|
||||
abstract fun hiddenAppDao(): HiddenAppDao
|
||||
abstract fun appFilterDao(): AppFilterDao
|
||||
abstract fun filterRuleDao(): FilterRuleDao
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package com.roundingmobile.notisaver.data.local.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.roundingmobile.notisaver.data.local.db.entity.AppFilterEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface AppFilterDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(entity: AppFilterEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(entities: List<AppFilterEntity>)
|
||||
|
||||
@Query("SELECT * FROM app_filter_prefs")
|
||||
fun getAllFlow(): Flow<List<AppFilterEntity>>
|
||||
|
||||
@Query("SELECT package_name FROM app_filter_prefs WHERE is_visible = 0")
|
||||
suspend fun getHiddenFilterPackages(): List<String>
|
||||
|
||||
@Query("DELETE FROM app_filter_prefs")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.roundingmobile.notisaver.data.local.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import com.roundingmobile.notisaver.data.local.db.entity.FilterRuleEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface FilterRuleDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(entity: FilterRuleEntity): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(entity: FilterRuleEntity)
|
||||
|
||||
@Query("DELETE FROM filter_rules WHERE id = :id")
|
||||
suspend fun deleteById(id: Long)
|
||||
|
||||
@Query("SELECT * FROM filter_rules ORDER BY is_built_in DESC, created_at DESC")
|
||||
fun getAllFlow(): Flow<List<FilterRuleEntity>>
|
||||
|
||||
@Query("SELECT * FROM filter_rules WHERE is_enabled = 1")
|
||||
suspend fun getEnabledRules(): List<FilterRuleEntity>
|
||||
|
||||
@Query("SELECT * FROM filter_rules WHERE id = :id")
|
||||
suspend fun getById(id: Long): FilterRuleEntity?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM filter_rules")
|
||||
suspend fun getCount(): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM filter_rules WHERE is_enabled = 1")
|
||||
fun getEnabledCountFlow(): Flow<Int>
|
||||
|
||||
@Query("UPDATE filter_rules SET is_enabled = :enabled WHERE id = :id")
|
||||
suspend fun setEnabled(id: Long, enabled: Boolean)
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package com.roundingmobile.notisaver.data.local.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.roundingmobile.notisaver.data.local.db.entity.HiddenAppEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface HiddenAppDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(entity: HiddenAppEntity)
|
||||
|
||||
@Query("DELETE FROM hidden_apps WHERE package_name = :packageName")
|
||||
suspend fun delete(packageName: String)
|
||||
|
||||
@Query("SELECT * FROM hidden_apps ORDER BY hidden_at DESC")
|
||||
fun getAllFlow(): Flow<List<HiddenAppEntity>>
|
||||
|
||||
@Query("SELECT package_name FROM hidden_apps")
|
||||
suspend fun getAllPackageNames(): List<String>
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM hidden_apps WHERE package_name = :packageName)")
|
||||
suspend fun isHidden(packageName: String): Boolean
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package com.roundingmobile.notisaver.data.local.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import com.roundingmobile.notisaver.data.local.db.entity.NotificationEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface NotificationDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(entity: NotificationEntity): Long
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(entities: List<NotificationEntity>)
|
||||
|
||||
@Update
|
||||
suspend fun update(entity: NotificationEntity)
|
||||
|
||||
@Query("SELECT * FROM notifications ORDER BY timestamp DESC")
|
||||
fun getAllFlow(): Flow<List<NotificationEntity>>
|
||||
|
||||
@Query("SELECT * FROM notifications WHERE id = :id")
|
||||
suspend fun getById(id: Long): NotificationEntity?
|
||||
|
||||
@Query("SELECT * FROM notifications WHERE id = :id")
|
||||
fun getByIdFlow(id: Long): Flow<NotificationEntity?>
|
||||
|
||||
@Query(
|
||||
"""SELECT * FROM notifications
|
||||
WHERE package_name NOT IN (SELECT package_name FROM hidden_apps)
|
||||
ORDER BY timestamp DESC"""
|
||||
)
|
||||
fun getVisibleFlow(): Flow<List<NotificationEntity>>
|
||||
|
||||
@Query(
|
||||
"""SELECT * FROM notifications
|
||||
WHERE package_name NOT IN (SELECT package_name FROM hidden_apps)
|
||||
ORDER BY timestamp ASC"""
|
||||
)
|
||||
fun getVisibleOldestFirstFlow(): Flow<List<NotificationEntity>>
|
||||
|
||||
@Query(
|
||||
"""SELECT * FROM notifications
|
||||
WHERE package_name NOT IN (SELECT package_name FROM hidden_apps)
|
||||
ORDER BY package_name ASC, timestamp DESC"""
|
||||
)
|
||||
fun getVisibleByAppFlow(): Flow<List<NotificationEntity>>
|
||||
|
||||
@Query(
|
||||
"""SELECT * FROM notifications
|
||||
WHERE title LIKE '%' || :query || '%'
|
||||
OR text LIKE '%' || :query || '%'
|
||||
OR big_text LIKE '%' || :query || '%'
|
||||
OR app_name LIKE '%' || :query || '%'
|
||||
OR package_name LIKE '%' || :query || '%'
|
||||
ORDER BY timestamp DESC"""
|
||||
)
|
||||
fun search(query: String): Flow<List<NotificationEntity>>
|
||||
|
||||
@Query("SELECT * FROM notifications WHERE package_name = :packageName ORDER BY timestamp DESC")
|
||||
fun getByPackageFlow(packageName: String): Flow<List<NotificationEntity>>
|
||||
|
||||
@Query(
|
||||
"""SELECT * FROM notifications
|
||||
WHERE package_name = :packageName AND title = :sender
|
||||
ORDER BY timestamp ASC"""
|
||||
)
|
||||
fun getConversation(packageName: String, sender: String): Flow<List<NotificationEntity>>
|
||||
|
||||
@Query("UPDATE notifications SET is_bookmarked = :bookmarked WHERE id = :id")
|
||||
suspend fun setBookmarked(id: Long, bookmarked: Boolean)
|
||||
|
||||
@Query("UPDATE notifications SET is_removed = 1, removed_at = :removedAt, removal_delay_ms = :delayMs WHERE id = :id")
|
||||
suspend fun markRemoved(id: Long, removedAt: Long, delayMs: Long)
|
||||
|
||||
@Query(
|
||||
"""SELECT * FROM notifications
|
||||
WHERE notification_id = :notificationId AND notification_tag = :tag AND package_name = :packageName
|
||||
ORDER BY timestamp DESC LIMIT 1"""
|
||||
)
|
||||
suspend fun findPrevious(notificationId: Int, tag: String?, packageName: String): NotificationEntity?
|
||||
|
||||
@Query(
|
||||
"""SELECT * FROM notifications
|
||||
WHERE package_name = :packageName AND title = :title AND timestamp > :since
|
||||
ORDER BY timestamp DESC LIMIT 1"""
|
||||
)
|
||||
suspend fun findRecentByPackageAndTitle(packageName: String, title: String, since: Long): NotificationEntity?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM notifications")
|
||||
suspend fun getCount(): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM notifications")
|
||||
fun getCountFlow(): Flow<Int>
|
||||
|
||||
@Query("DELETE FROM notifications WHERE id = :id")
|
||||
suspend fun deleteById(id: Long)
|
||||
|
||||
@Query("DELETE FROM notifications WHERE timestamp < :before")
|
||||
suspend fun deleteOlderThan(before: Long): Int
|
||||
|
||||
@Query("DELETE FROM notifications")
|
||||
suspend fun deleteAll()
|
||||
|
||||
@Query("SELECT DISTINCT package_name, app_name FROM notifications ORDER BY app_name")
|
||||
fun getDistinctApps(): Flow<List<AppNameTuple>>
|
||||
|
||||
@Query("SELECT package_name, COUNT(*) as count FROM notifications GROUP BY package_name ORDER BY count DESC")
|
||||
fun getAppCounts(): Flow<List<AppCountTuple>>
|
||||
}
|
||||
|
||||
data class AppNameTuple(
|
||||
val package_name: String,
|
||||
val app_name: String
|
||||
)
|
||||
|
||||
data class AppCountTuple(
|
||||
val package_name: String,
|
||||
val count: Int
|
||||
)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.roundingmobile.notisaver.data.local.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "app_filter_prefs")
|
||||
data class AppFilterEntity(
|
||||
@PrimaryKey @ColumnInfo(name = "package_name") val packageName: String,
|
||||
@ColumnInfo(name = "is_visible") val isVisible: Boolean = true
|
||||
)
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.roundingmobile.notisaver.data.local.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "filter_rules",
|
||||
indices = [Index(value = ["is_enabled"])]
|
||||
)
|
||||
data class FilterRuleEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val name: String,
|
||||
val action: String,
|
||||
@ColumnInfo(name = "match_field") val matchField: String,
|
||||
@ColumnInfo(name = "match_type") val matchType: String,
|
||||
val pattern: String,
|
||||
@ColumnInfo(name = "package_name") val packageName: String? = null,
|
||||
@ColumnInfo(name = "app_name") val appName: String? = null,
|
||||
@ColumnInfo(name = "is_enabled") val isEnabled: Boolean = true,
|
||||
@ColumnInfo(name = "is_built_in") val isBuiltIn: Boolean = false,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long
|
||||
)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.roundingmobile.notisaver.data.local.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "hidden_apps")
|
||||
data class HiddenAppEntity(
|
||||
@PrimaryKey @ColumnInfo(name = "package_name") val packageName: String,
|
||||
@ColumnInfo(name = "hidden_at") val hiddenAt: Long
|
||||
)
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.roundingmobile.notisaver.data.local.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "keyword_alerts")
|
||||
data class KeywordAlertEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val keyword: String,
|
||||
@ColumnInfo(name = "is_regex") val isRegex: Boolean = false,
|
||||
@ColumnInfo(name = "is_active") val isActive: Boolean = true,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long
|
||||
)
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.roundingmobile.notisaver.data.local.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "notifications",
|
||||
indices = [
|
||||
Index(value = ["package_name"]),
|
||||
Index(value = ["timestamp"]),
|
||||
Index(value = ["category"]),
|
||||
Index(value = ["is_bookmarked"]),
|
||||
Index(value = ["package_name", "timestamp"])
|
||||
]
|
||||
)
|
||||
data class NotificationEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
@ColumnInfo(name = "package_name") val packageName: String,
|
||||
@ColumnInfo(name = "app_name") val appName: String,
|
||||
val title: String?,
|
||||
val text: String?,
|
||||
@ColumnInfo(name = "big_text") val bigText: String?,
|
||||
val category: String?,
|
||||
val priority: Int = 0,
|
||||
val timestamp: Long,
|
||||
@ColumnInfo(name = "notification_id") val notificationId: Int = 0,
|
||||
@ColumnInfo(name = "notification_tag") val notificationTag: String? = null,
|
||||
@ColumnInfo(name = "is_update") val isUpdate: Boolean = false,
|
||||
@ColumnInfo(name = "previous_version_id") val previousVersionId: Long? = null,
|
||||
@ColumnInfo(name = "is_removed") val isRemoved: Boolean = false,
|
||||
@ColumnInfo(name = "removed_at") val removedAt: Long? = null,
|
||||
@ColumnInfo(name = "removal_delay_ms") val removalDelayMs: Long? = null,
|
||||
@ColumnInfo(name = "is_bookmarked") val isBookmarked: Boolean = false,
|
||||
@ColumnInfo(name = "extras_json") val extrasJson: String? = null,
|
||||
@ColumnInfo(name = "actions_json") val actionsJson: String? = null,
|
||||
@ColumnInfo(name = "icon_uri") val iconUri: String? = null,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long
|
||||
)
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.roundingmobile.notisaver.data.local.db.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Fts4
|
||||
|
||||
@Fts4(contentEntity = NotificationEntity::class)
|
||||
@Entity(tableName = "notifications_fts")
|
||||
data class NotificationFtsEntity(
|
||||
val title: String?,
|
||||
val text: String?,
|
||||
@androidx.room.ColumnInfo(name = "big_text") val bigText: String?
|
||||
)
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.roundingmobile.notisaver.data.mapper
|
||||
|
||||
import com.roundingmobile.notisaver.data.local.db.entity.FilterRuleEntity
|
||||
import com.roundingmobile.notisaver.domain.model.FilterAction
|
||||
import com.roundingmobile.notisaver.domain.model.FilterRule
|
||||
import com.roundingmobile.notisaver.domain.model.MatchField
|
||||
import com.roundingmobile.notisaver.domain.model.MatchType
|
||||
|
||||
fun FilterRuleEntity.toDomain(): FilterRule = FilterRule(
|
||||
id = id,
|
||||
name = name,
|
||||
action = FilterAction.valueOf(action),
|
||||
matchField = MatchField.valueOf(matchField),
|
||||
matchType = MatchType.valueOf(matchType),
|
||||
pattern = pattern,
|
||||
packageName = packageName,
|
||||
appName = appName,
|
||||
isEnabled = isEnabled,
|
||||
isBuiltIn = isBuiltIn,
|
||||
createdAt = createdAt
|
||||
)
|
||||
|
||||
fun FilterRule.toEntity(): FilterRuleEntity = FilterRuleEntity(
|
||||
id = id,
|
||||
name = name,
|
||||
action = action.name,
|
||||
matchField = matchField.name,
|
||||
matchType = matchType.name,
|
||||
pattern = pattern,
|
||||
packageName = packageName,
|
||||
appName = appName,
|
||||
isEnabled = isEnabled,
|
||||
isBuiltIn = isBuiltIn,
|
||||
createdAt = createdAt
|
||||
)
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package com.roundingmobile.notisaver.data.mapper
|
||||
|
||||
import com.roundingmobile.notisaver.data.local.db.entity.NotificationEntity
|
||||
import com.roundingmobile.notisaver.domain.model.CapturedNotification
|
||||
|
||||
fun NotificationEntity.toDomain(): CapturedNotification = CapturedNotification(
|
||||
id = id,
|
||||
packageName = packageName,
|
||||
appName = appName,
|
||||
title = title,
|
||||
text = text,
|
||||
bigText = bigText,
|
||||
category = category,
|
||||
priority = priority,
|
||||
timestamp = timestamp,
|
||||
notificationId = notificationId,
|
||||
notificationTag = notificationTag,
|
||||
isUpdate = isUpdate,
|
||||
previousVersionId = previousVersionId,
|
||||
isRemoved = isRemoved,
|
||||
removedAt = removedAt,
|
||||
removalDelayMs = removalDelayMs,
|
||||
isBookmarked = isBookmarked,
|
||||
extrasJson = extrasJson,
|
||||
actionsJson = actionsJson,
|
||||
iconUri = iconUri,
|
||||
createdAt = createdAt
|
||||
)
|
||||
|
||||
fun CapturedNotification.toEntity(): NotificationEntity = NotificationEntity(
|
||||
id = id,
|
||||
packageName = packageName,
|
||||
appName = appName,
|
||||
title = title,
|
||||
text = text,
|
||||
bigText = bigText,
|
||||
category = category,
|
||||
priority = priority,
|
||||
timestamp = timestamp,
|
||||
notificationId = notificationId,
|
||||
notificationTag = notificationTag,
|
||||
isUpdate = isUpdate,
|
||||
previousVersionId = previousVersionId,
|
||||
isRemoved = isRemoved,
|
||||
removedAt = removedAt,
|
||||
removalDelayMs = removalDelayMs,
|
||||
isBookmarked = isBookmarked,
|
||||
extrasJson = extrasJson,
|
||||
actionsJson = actionsJson,
|
||||
iconUri = iconUri,
|
||||
createdAt = createdAt
|
||||
)
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package com.roundingmobile.notisaver.data.repository
|
||||
|
||||
import com.roundingmobile.notisaver.data.local.db.dao.FilterRuleDao
|
||||
import com.roundingmobile.notisaver.data.mapper.toDomain
|
||||
import com.roundingmobile.notisaver.data.mapper.toEntity
|
||||
import com.roundingmobile.notisaver.domain.model.FilterAction
|
||||
import com.roundingmobile.notisaver.domain.model.FilterRule
|
||||
import com.roundingmobile.notisaver.domain.model.MatchField
|
||||
import com.roundingmobile.notisaver.domain.model.MatchType
|
||||
import com.roundingmobile.notisaver.domain.repository.FilterRuleRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class FilterRuleRepositoryImpl @Inject constructor(
|
||||
private val dao: FilterRuleDao
|
||||
) : FilterRuleRepository {
|
||||
|
||||
override fun getAllRulesFlow(): Flow<List<FilterRule>> =
|
||||
dao.getAllFlow().map { list -> list.map { it.toDomain() } }
|
||||
|
||||
override fun getEnabledCountFlow(): Flow<Int> = dao.getEnabledCountFlow()
|
||||
|
||||
override suspend fun getEnabledRules(): List<FilterRule> =
|
||||
dao.getEnabledRules().map { it.toDomain() }
|
||||
|
||||
override suspend fun getById(id: Long): FilterRule? =
|
||||
dao.getById(id)?.toDomain()
|
||||
|
||||
override suspend fun insert(rule: FilterRule): Long =
|
||||
dao.insert(rule.toEntity())
|
||||
|
||||
override suspend fun update(rule: FilterRule) =
|
||||
dao.update(rule.toEntity())
|
||||
|
||||
override suspend fun deleteById(id: Long) =
|
||||
dao.deleteById(id)
|
||||
|
||||
override suspend fun setEnabled(id: Long, enabled: Boolean) =
|
||||
dao.setEnabled(id, enabled)
|
||||
|
||||
override suspend fun seedPresetsIfNeeded() {
|
||||
if (dao.getCount() > 0) return
|
||||
val now = System.currentTimeMillis()
|
||||
PRESETS.forEachIndexed { index, rule ->
|
||||
dao.insert(rule.copy(createdAt = now - index).toEntity())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val PRESETS = listOf(
|
||||
FilterRule(
|
||||
name = "Delivery tracking",
|
||||
action = FilterAction.SUPPRESS,
|
||||
matchField = MatchField.TITLE_OR_TEXT,
|
||||
matchType = MatchType.REGEX,
|
||||
pattern = "(?i)(out for delivery|has been delivered|your package|shipment|tracking)",
|
||||
isEnabled = false,
|
||||
isBuiltIn = true
|
||||
),
|
||||
FilterRule(
|
||||
name = "Promotional",
|
||||
action = FilterAction.SUPPRESS,
|
||||
matchField = MatchField.TITLE_OR_TEXT,
|
||||
matchType = MatchType.REGEX,
|
||||
pattern = "(?i)(% off|flash sale|limited time|promo code|special offer)",
|
||||
isEnabled = false,
|
||||
isBuiltIn = true
|
||||
),
|
||||
FilterRule(
|
||||
name = "OTP / Verification codes",
|
||||
action = FilterAction.LOW_PRIORITY,
|
||||
matchField = MatchField.TEXT,
|
||||
matchType = MatchType.REGEX,
|
||||
pattern = "(?i)\\b\\d{4,8}\\b.*(code|otp|verify|verification)",
|
||||
isEnabled = false,
|
||||
isBuiltIn = true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
package com.roundingmobile.notisaver.data.repository
|
||||
|
||||
import com.roundingmobile.notisaver.data.local.db.dao.NotificationDao
|
||||
import com.roundingmobile.notisaver.data.mapper.toDomain
|
||||
import com.roundingmobile.notisaver.data.mapper.toEntity
|
||||
import com.roundingmobile.notisaver.domain.model.AppInfo
|
||||
import com.roundingmobile.notisaver.domain.model.CapturedNotification
|
||||
import com.roundingmobile.notisaver.domain.model.SortOrder
|
||||
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NotificationRepositoryImpl @Inject constructor(
|
||||
private val notificationDao: NotificationDao
|
||||
) : NotificationRepository {
|
||||
|
||||
override fun getTimeline(sortOrder: SortOrder): Flow<List<CapturedNotification>> {
|
||||
return when (sortOrder) {
|
||||
SortOrder.NEWEST_FIRST -> notificationDao.getVisibleFlow()
|
||||
SortOrder.OLDEST_FIRST -> notificationDao.getVisibleOldestFirstFlow()
|
||||
SortOrder.BY_APP -> notificationDao.getVisibleByAppFlow()
|
||||
}.map { list -> list.map { it.toDomain() } }
|
||||
}
|
||||
|
||||
override fun getById(id: Long): Flow<CapturedNotification?> {
|
||||
return notificationDao.getByIdFlow(id).map { it?.toDomain() }
|
||||
}
|
||||
|
||||
override fun search(query: String): Flow<List<CapturedNotification>> {
|
||||
return notificationDao.search(query).map { list -> list.map { it.toDomain() } }
|
||||
}
|
||||
|
||||
override fun getByPackage(packageName: String): Flow<List<CapturedNotification>> {
|
||||
return notificationDao.getByPackageFlow(packageName).map { list -> list.map { it.toDomain() } }
|
||||
}
|
||||
|
||||
override fun getConversation(packageName: String, sender: String): Flow<List<CapturedNotification>> {
|
||||
return notificationDao.getConversation(packageName, sender).map { list -> list.map { it.toDomain() } }
|
||||
}
|
||||
|
||||
override fun getCountFlow(): Flow<Int> = notificationDao.getCountFlow()
|
||||
|
||||
override fun getDistinctApps(): Flow<List<AppInfo>> {
|
||||
return notificationDao.getAppCounts().map { list ->
|
||||
list.map { tuple ->
|
||||
AppInfo(
|
||||
packageName = tuple.package_name,
|
||||
appName = tuple.package_name, // Will be enriched when we have app_name from counts
|
||||
notificationCount = tuple.count
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun insert(notification: CapturedNotification): Long {
|
||||
return notificationDao.insert(notification.toEntity())
|
||||
}
|
||||
|
||||
override suspend fun insertAll(notifications: List<CapturedNotification>) {
|
||||
notificationDao.insertAll(notifications.map { it.toEntity() })
|
||||
}
|
||||
|
||||
override suspend fun setBookmarked(id: Long, bookmarked: Boolean) {
|
||||
notificationDao.setBookmarked(id, bookmarked)
|
||||
}
|
||||
|
||||
override suspend fun markRemoved(id: Long, removedAt: Long, delayMs: Long) {
|
||||
notificationDao.markRemoved(id, removedAt, delayMs)
|
||||
}
|
||||
|
||||
override suspend fun findPrevious(notificationId: Int, tag: String?, packageName: String): CapturedNotification? {
|
||||
return notificationDao.findPrevious(notificationId, tag, packageName)?.toDomain()
|
||||
}
|
||||
|
||||
override suspend fun findRecentByPackageAndTitle(packageName: String, title: String, sinceMs: Long): CapturedNotification? {
|
||||
return notificationDao.findRecentByPackageAndTitle(packageName, title, sinceMs)?.toDomain()
|
||||
}
|
||||
|
||||
override suspend fun update(notification: CapturedNotification) {
|
||||
notificationDao.update(notification.toEntity())
|
||||
}
|
||||
|
||||
override suspend fun getCount(): Int = notificationDao.getCount()
|
||||
|
||||
override suspend fun deleteById(id: Long) = notificationDao.deleteById(id)
|
||||
|
||||
override suspend fun deleteOlderThan(before: Long): Int = notificationDao.deleteOlderThan(before)
|
||||
|
||||
override suspend fun deleteAll() = notificationDao.deleteAll()
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.roundingmobile.notisaver.domain.model
|
||||
|
||||
data class AppInfo(
|
||||
val packageName: String,
|
||||
val appName: String,
|
||||
val notificationCount: Int = 0
|
||||
)
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.roundingmobile.notisaver.domain.model
|
||||
|
||||
data class CapturedNotification(
|
||||
val id: Long,
|
||||
val packageName: String,
|
||||
val appName: String,
|
||||
val title: String?,
|
||||
val text: String?,
|
||||
val bigText: String?,
|
||||
val category: String?,
|
||||
val priority: Int,
|
||||
val timestamp: Long,
|
||||
val notificationId: Int,
|
||||
val notificationTag: String?,
|
||||
val isUpdate: Boolean,
|
||||
val previousVersionId: Long?,
|
||||
val isRemoved: Boolean,
|
||||
val removedAt: Long?,
|
||||
val removalDelayMs: Long?,
|
||||
val isBookmarked: Boolean,
|
||||
val extrasJson: String?,
|
||||
val actionsJson: String?,
|
||||
val iconUri: String?,
|
||||
val createdAt: Long
|
||||
) {
|
||||
val isPossiblyDeleted: Boolean
|
||||
get() = isRemoved && removalDelayMs != null && removalDelayMs < DELETED_THRESHOLD_MS
|
||||
|
||||
companion object {
|
||||
const val DELETED_THRESHOLD_MS = 5000L
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.roundingmobile.notisaver.domain.model
|
||||
|
||||
enum class ExportFormat {
|
||||
CSV,
|
||||
JSON,
|
||||
TXT,
|
||||
PDF
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.roundingmobile.notisaver.domain.model
|
||||
|
||||
data class FilterRule(
|
||||
val id: Long = 0,
|
||||
val name: String,
|
||||
val action: FilterAction,
|
||||
val matchField: MatchField,
|
||||
val matchType: MatchType,
|
||||
val pattern: String,
|
||||
val packageName: String? = null,
|
||||
val appName: String? = null,
|
||||
val isEnabled: Boolean = true,
|
||||
val isBuiltIn: Boolean = false,
|
||||
val createdAt: Long = 0
|
||||
)
|
||||
|
||||
enum class FilterAction {
|
||||
SUPPRESS,
|
||||
LOW_PRIORITY
|
||||
}
|
||||
|
||||
enum class MatchField {
|
||||
TITLE,
|
||||
TEXT,
|
||||
TITLE_OR_TEXT,
|
||||
APP_NAME,
|
||||
PACKAGE_NAME
|
||||
}
|
||||
|
||||
enum class MatchType {
|
||||
CONTAINS,
|
||||
EXACT,
|
||||
REGEX
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.roundingmobile.notisaver.domain.model
|
||||
|
||||
enum class SortOrder {
|
||||
NEWEST_FIRST,
|
||||
OLDEST_FIRST,
|
||||
BY_APP
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.roundingmobile.notisaver.domain.repository
|
||||
|
||||
import com.roundingmobile.notisaver.domain.model.FilterRule
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface FilterRuleRepository {
|
||||
fun getAllRulesFlow(): Flow<List<FilterRule>>
|
||||
fun getEnabledCountFlow(): Flow<Int>
|
||||
suspend fun getEnabledRules(): List<FilterRule>
|
||||
suspend fun getById(id: Long): FilterRule?
|
||||
suspend fun insert(rule: FilterRule): Long
|
||||
suspend fun update(rule: FilterRule)
|
||||
suspend fun deleteById(id: Long)
|
||||
suspend fun setEnabled(id: Long, enabled: Boolean)
|
||||
suspend fun seedPresetsIfNeeded()
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.roundingmobile.notisaver.domain.repository
|
||||
|
||||
import com.roundingmobile.notisaver.domain.model.AppInfo
|
||||
import com.roundingmobile.notisaver.domain.model.CapturedNotification
|
||||
import com.roundingmobile.notisaver.domain.model.SortOrder
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface NotificationRepository {
|
||||
fun getTimeline(sortOrder: SortOrder): Flow<List<CapturedNotification>>
|
||||
fun getById(id: Long): Flow<CapturedNotification?>
|
||||
fun search(query: String): Flow<List<CapturedNotification>>
|
||||
fun getByPackage(packageName: String): Flow<List<CapturedNotification>>
|
||||
fun getConversation(packageName: String, sender: String): Flow<List<CapturedNotification>>
|
||||
fun getCountFlow(): Flow<Int>
|
||||
fun getDistinctApps(): Flow<List<AppInfo>>
|
||||
|
||||
suspend fun insert(notification: CapturedNotification): Long
|
||||
suspend fun insertAll(notifications: List<CapturedNotification>)
|
||||
suspend fun setBookmarked(id: Long, bookmarked: Boolean)
|
||||
suspend fun markRemoved(id: Long, removedAt: Long, delayMs: Long)
|
||||
suspend fun findPrevious(notificationId: Int, tag: String?, packageName: String): CapturedNotification?
|
||||
suspend fun findRecentByPackageAndTitle(packageName: String, title: String, sinceMs: Long): CapturedNotification?
|
||||
suspend fun update(notification: CapturedNotification)
|
||||
suspend fun getCount(): Int
|
||||
suspend fun deleteById(id: Long)
|
||||
suspend fun deleteOlderThan(before: Long): Int
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.roundingmobile.notisaver.domain.usecase
|
||||
|
||||
import com.roundingmobile.notisaver.domain.model.AppInfo
|
||||
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import javax.inject.Inject
|
||||
|
||||
class ComputeStatisticsUseCase @Inject constructor(
|
||||
private val repository: NotificationRepository
|
||||
) {
|
||||
operator fun invoke(): Flow<StatsData> {
|
||||
return combine(
|
||||
repository.getCountFlow(),
|
||||
repository.getDistinctApps()
|
||||
) { totalCount, apps ->
|
||||
StatsData(
|
||||
totalCount = totalCount,
|
||||
appStats = apps.sortedByDescending { it.notificationCount }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class StatsData(
|
||||
val totalCount: Int,
|
||||
val appStats: List<AppInfo>
|
||||
)
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.roundingmobile.notisaver.domain.usecase
|
||||
|
||||
import com.roundingmobile.notisaver.domain.model.CapturedNotification
|
||||
import com.roundingmobile.notisaver.domain.model.ExportFormat
|
||||
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
|
||||
import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
|
||||
class ExportNotificationsUseCase @Inject constructor(
|
||||
private val repository: NotificationRepository
|
||||
) {
|
||||
suspend operator fun invoke(format: ExportFormat): String {
|
||||
val notifications = repository.getTimeline(
|
||||
com.roundingmobile.notisaver.domain.model.SortOrder.NEWEST_FIRST
|
||||
).first()
|
||||
return when (format) {
|
||||
ExportFormat.CSV -> toCsv(notifications)
|
||||
ExportFormat.JSON -> toJson(notifications)
|
||||
ExportFormat.TXT -> toTxt(notifications)
|
||||
ExportFormat.PDF -> toTxt(notifications) // PDF uses text as content
|
||||
}
|
||||
}
|
||||
|
||||
private fun toCsv(notifications: List<CapturedNotification>): String = buildString {
|
||||
appendLine("id,package,app_name,title,text,timestamp,category,priority,is_bookmarked")
|
||||
notifications.forEach { n ->
|
||||
appendLine("${n.id},\"${n.packageName}\",\"${n.appName}\",\"${n.title ?: ""}\",\"${n.text ?: ""}\",${n.timestamp},\"${n.category ?: ""}\",${n.priority},${n.isBookmarked}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun toJson(notifications: List<CapturedNotification>): String = buildString {
|
||||
appendLine("[")
|
||||
notifications.forEachIndexed { index, n ->
|
||||
append(" {")
|
||||
append("\"id\":${n.id},")
|
||||
append("\"package\":\"${n.packageName}\",")
|
||||
append("\"app_name\":\"${n.appName}\",")
|
||||
append("\"title\":\"${n.title ?: ""}\",")
|
||||
append("\"text\":\"${n.text ?: ""}\",")
|
||||
append("\"timestamp\":${n.timestamp},")
|
||||
append("\"category\":\"${n.category ?: ""}\"")
|
||||
append("}")
|
||||
if (index < notifications.size - 1) appendLine(",") else appendLine()
|
||||
}
|
||||
appendLine("]")
|
||||
}
|
||||
|
||||
private fun toTxt(notifications: List<CapturedNotification>): String = buildString {
|
||||
notifications.forEach { n ->
|
||||
appendLine("[${n.appName}] ${n.title ?: ""}")
|
||||
n.text?.let { appendLine(it) }
|
||||
appendLine("---")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.roundingmobile.notisaver.domain.usecase
|
||||
|
||||
import com.roundingmobile.notisaver.domain.model.AppInfo
|
||||
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
|
||||
class FilterByAppUseCase @Inject constructor(
|
||||
private val repository: NotificationRepository
|
||||
) {
|
||||
fun getApps(): Flow<List<AppInfo>> = repository.getDistinctApps()
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.roundingmobile.notisaver.domain.usecase
|
||||
|
||||
import com.roundingmobile.notisaver.domain.model.FilterAction
|
||||
import com.roundingmobile.notisaver.domain.model.FilterRule
|
||||
import com.roundingmobile.notisaver.domain.model.MatchField
|
||||
import com.roundingmobile.notisaver.domain.model.MatchType
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class FilterRuleEngine @Inject constructor() {
|
||||
|
||||
fun evaluate(
|
||||
rules: List<FilterRule>,
|
||||
packageName: String,
|
||||
appName: String,
|
||||
title: String?,
|
||||
text: String?
|
||||
): FilterAction? {
|
||||
for (rule in rules) {
|
||||
if (!rule.isEnabled) continue
|
||||
if (rule.packageName != null && rule.packageName != packageName) continue
|
||||
if (matchesContent(rule, packageName, appName, title, text)) {
|
||||
return rule.action
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun matchesContent(
|
||||
rule: FilterRule,
|
||||
packageName: String,
|
||||
appName: String,
|
||||
title: String?,
|
||||
text: String?
|
||||
): Boolean {
|
||||
val fields = when (rule.matchField) {
|
||||
MatchField.TITLE -> listOfNotNull(title)
|
||||
MatchField.TEXT -> listOfNotNull(text)
|
||||
MatchField.TITLE_OR_TEXT -> listOfNotNull(title, text)
|
||||
MatchField.APP_NAME -> listOf(appName)
|
||||
MatchField.PACKAGE_NAME -> listOf(packageName)
|
||||
}
|
||||
return fields.any { matchesPattern(rule.matchType, rule.pattern, it) }
|
||||
}
|
||||
|
||||
private fun matchesPattern(type: MatchType, pattern: String, value: String): Boolean = when (type) {
|
||||
MatchType.CONTAINS -> value.contains(pattern, ignoreCase = true)
|
||||
MatchType.EXACT -> value.equals(pattern, ignoreCase = true)
|
||||
MatchType.REGEX -> try {
|
||||
Regex(pattern).containsMatchIn(value)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.roundingmobile.notisaver.domain.usecase
|
||||
|
||||
import com.roundingmobile.notisaver.domain.model.CapturedNotification
|
||||
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
|
||||
class GetNotificationDetailUseCase @Inject constructor(
|
||||
private val repository: NotificationRepository
|
||||
) {
|
||||
operator fun invoke(id: Long): Flow<CapturedNotification?> {
|
||||
return repository.getById(id)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.roundingmobile.notisaver.domain.usecase
|
||||
|
||||
import com.roundingmobile.notisaver.domain.model.CapturedNotification
|
||||
import com.roundingmobile.notisaver.domain.model.SortOrder
|
||||
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
|
||||
class GetTimelineUseCase @Inject constructor(
|
||||
private val repository: NotificationRepository
|
||||
) {
|
||||
operator fun invoke(sortOrder: SortOrder = SortOrder.NEWEST_FIRST): Flow<List<CapturedNotification>> {
|
||||
return repository.getTimeline(sortOrder)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.roundingmobile.notisaver.domain.usecase
|
||||
|
||||
import com.roundingmobile.notisaver.domain.model.CapturedNotification
|
||||
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
|
||||
class SearchNotificationsUseCase @Inject constructor(
|
||||
private val repository: NotificationRepository
|
||||
) {
|
||||
operator fun invoke(query: String): Flow<List<CapturedNotification>> {
|
||||
return repository.search(query)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.roundingmobile.notisaver.instrumentation
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
|
||||
interface TestInstrumentation {
|
||||
fun trackView(id: String, type: String, bounds: Rect, visible: Boolean, text: String)
|
||||
fun reportState(key: String, value: String)
|
||||
fun reportEvent(event: String, params: Map<String, String> = emptyMap())
|
||||
fun dumpUI()
|
||||
fun dumpState()
|
||||
}
|
||||
|
||||
class NoOpTestInstrumentation : TestInstrumentation {
|
||||
override fun trackView(id: String, type: String, bounds: Rect, visible: Boolean, text: String) {}
|
||||
override fun reportState(key: String, value: String) {}
|
||||
override fun reportEvent(event: String, params: Map<String, String>) {}
|
||||
override fun dumpUI() {}
|
||||
override fun dumpState() {}
|
||||
}
|
||||
|
||||
fun Modifier.testTrack(
|
||||
instrumentation: TestInstrumentation?,
|
||||
id: String,
|
||||
text: String = ""
|
||||
): Modifier {
|
||||
if (instrumentation == null || instrumentation is NoOpTestInstrumentation) return this
|
||||
return this.then(TestTrackModifier(instrumentation, id, text))
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.roundingmobile.notisaver.instrumentation
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.node.GlobalPositionAwareModifierNode
|
||||
import androidx.compose.ui.node.ModifierNodeElement
|
||||
|
||||
internal data class TestTrackModifier(
|
||||
private val instrumentation: TestInstrumentation,
|
||||
private val id: String,
|
||||
private val text: String
|
||||
) : ModifierNodeElement<TestTrackNode>() {
|
||||
override fun create() = TestTrackNode(instrumentation, id, text)
|
||||
override fun update(node: TestTrackNode) {
|
||||
node.instrumentation = instrumentation
|
||||
node.id = id
|
||||
node.text = text
|
||||
}
|
||||
}
|
||||
|
||||
internal class TestTrackNode(
|
||||
var instrumentation: TestInstrumentation,
|
||||
var id: String,
|
||||
var text: String
|
||||
) : Modifier.Node(), GlobalPositionAwareModifierNode {
|
||||
override fun onGloballyPositioned(coordinates: LayoutCoordinates) {
|
||||
if (coordinates.isAttached) {
|
||||
val bounds = coordinates.boundsInRoot()
|
||||
instrumentation.trackView(
|
||||
id = id,
|
||||
type = "Composable",
|
||||
bounds = bounds,
|
||||
visible = coordinates.size.width > 0 && coordinates.size.height > 0,
|
||||
text = text
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,310 @@
|
|||
package com.roundingmobile.notisaver.presentation
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BarChart
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Timeline
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.roundingmobile.notisaver.R
|
||||
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
|
||||
import com.roundingmobile.notisaver.instrumentation.testTrack
|
||||
import com.roundingmobile.notisaver.presentation.navigation.AppNavGraph
|
||||
import com.roundingmobile.notisaver.presentation.navigation.Route
|
||||
import com.roundingmobile.notisaver.presentation.onboarding.PermissionScreen
|
||||
import com.roundingmobile.notisaver.presentation.theme.AppThemeType
|
||||
import com.roundingmobile.notisaver.presentation.theme.NotiSaverTheme
|
||||
import com.roundingmobile.notisaver.security.AppLockManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : FragmentActivity() {
|
||||
|
||||
@Inject lateinit var instrumentation: TestInstrumentation
|
||||
@Inject lateinit var prefs: SharedPreferences
|
||||
@Inject lateinit var lockManager: AppLockManager
|
||||
|
||||
private var currentTheme by mutableStateOf(AppThemeType.SYSTEM)
|
||||
private var isLocked by mutableStateOf(false)
|
||||
private var hasNotificationAccess by mutableStateOf(false)
|
||||
private var everGranted: Boolean
|
||||
get() = prefs.getBoolean("permission_ever_granted", false)
|
||||
set(value) = prefs.edit().putBoolean("permission_ever_granted", value).apply()
|
||||
|
||||
private val prefsListener = SharedPreferences.OnSharedPreferenceChangeListener { sp, key ->
|
||||
if (key == "app_theme") {
|
||||
val name = sp.getString(key, AppThemeType.SYSTEM.name) ?: AppThemeType.SYSTEM.name
|
||||
currentTheme = runCatching { AppThemeType.valueOf(name) }.getOrDefault(AppThemeType.SYSTEM)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
val themeName = prefs.getString("app_theme", AppThemeType.SYSTEM.name) ?: AppThemeType.SYSTEM.name
|
||||
currentTheme = runCatching { AppThemeType.valueOf(themeName) }.getOrDefault(AppThemeType.SYSTEM)
|
||||
prefs.registerOnSharedPreferenceChangeListener(prefsListener)
|
||||
|
||||
hasNotificationAccess = checkNotificationAccess()
|
||||
if (hasNotificationAccess) everGranted = true
|
||||
if (lockManager.shouldLock()) isLocked = true
|
||||
|
||||
setContent {
|
||||
NotiSaverTheme(themeType = currentTheme) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
when {
|
||||
isLocked -> {
|
||||
// Trigger biometric prompt immediately
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
showBiometricPrompt()
|
||||
}
|
||||
// Show a blank locked surface while prompt is up
|
||||
LockSurface(onUnlock = { showBiometricPrompt() })
|
||||
}
|
||||
!hasNotificationAccess && !everGranted -> PermissionScreen()
|
||||
else -> MainScreen(
|
||||
instrumentation = instrumentation,
|
||||
prefs = prefs,
|
||||
permissionMissing = !hasNotificationAccess
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val access = checkNotificationAccess()
|
||||
hasNotificationAccess = access
|
||||
if (access) everGranted = true
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (lockManager.shouldLock()) {
|
||||
isLocked = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
lockManager.onBackground()
|
||||
prefs.edit().putLong("last_seen_timestamp", System.currentTimeMillis()).apply()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
prefs.unregisterOnSharedPreferenceChangeListener(prefsListener)
|
||||
}
|
||||
|
||||
private fun checkNotificationAccess(): Boolean {
|
||||
val packages = NotificationManagerCompat.getEnabledListenerPackages(this)
|
||||
return packages.contains(packageName)
|
||||
}
|
||||
|
||||
fun showBiometricPrompt() {
|
||||
val executor = ContextCompat.getMainExecutor(this)
|
||||
val prompt = BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
isLocked = false
|
||||
lockManager.onUnlocked()
|
||||
}
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
// User cancelled or no credentials — stay locked
|
||||
}
|
||||
override fun onAuthenticationFailed() {}
|
||||
})
|
||||
val info = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle("Unlock NotiSaver")
|
||||
.setSubtitle("Verify your identity to continue")
|
||||
.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
|
||||
.build()
|
||||
prompt.authenticate(info)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LockSurface(onUnlock: () -> Unit) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize().clickable { onUnlock() },
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
androidx.compose.foundation.layout.Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.padding(8.dp))
|
||||
Text(
|
||||
"Tap to unlock",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bottom nav items ---
|
||||
|
||||
private data class BottomNavItem(
|
||||
val route: Route,
|
||||
val testId: String,
|
||||
val labelRes: Int,
|
||||
val icon: ImageVector,
|
||||
val contentDescRes: Int
|
||||
)
|
||||
|
||||
private val bottomNavItems = listOf(
|
||||
BottomNavItem(Route.Timeline, "bottom_nav_timeline", R.string.nav_timeline, Icons.Default.Timeline, R.string.cd_timeline),
|
||||
BottomNavItem(Route.Stats, "bottom_nav_stats", R.string.nav_stats, Icons.Default.BarChart, R.string.cd_stats),
|
||||
BottomNavItem(Route.Settings, "bottom_nav_settings", R.string.nav_settings, Icons.Default.Settings, R.string.cd_settings)
|
||||
)
|
||||
|
||||
// --- Main screen with nav ---
|
||||
|
||||
@Composable
|
||||
private fun MainScreen(
|
||||
instrumentation: TestInstrumentation,
|
||||
prefs: SharedPreferences,
|
||||
permissionMissing: Boolean
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
val context = LocalContext.current
|
||||
|
||||
val showBottomBar = bottomNavItems.any { item ->
|
||||
currentDestination?.hasRoute(item.route::class) == true
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
bottomBar = {
|
||||
if (showBottomBar) {
|
||||
NavigationBar(
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars),
|
||||
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f)
|
||||
) {
|
||||
bottomNavItems.forEach { item ->
|
||||
val selected = currentDestination?.hasRoute(item.route::class) == true
|
||||
NavigationBarItem(
|
||||
modifier = Modifier.testTrack(instrumentation, item.testId, stringResource(item.labelRes)),
|
||||
selected = selected,
|
||||
onClick = {
|
||||
navController.navigate(item.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = { Icon(imageVector = item.icon, contentDescription = stringResource(item.contentDescRes)) },
|
||||
label = { Text(stringResource(item.labelRes)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding())
|
||||
) {
|
||||
// Permission revoked banner
|
||||
AnimatedVisibility(visible = permissionMissing) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
|
||||
},
|
||||
color = MaterialTheme.colorScheme.errorContainer
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
"Notification access disabled — tap to re-enable",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppNavGraph(
|
||||
navController = navController,
|
||||
instrumentation = instrumentation,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
package com.roundingmobile.notisaver.presentation.common
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
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.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun AppIcon(
|
||||
packageName: String,
|
||||
appName: String,
|
||||
size: Dp = 40.dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val imageBitmap = remember(packageName) {
|
||||
try {
|
||||
val drawable = context.packageManager.getApplicationIcon(packageName)
|
||||
drawableToBitmap(drawable)?.asImageBitmap()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (imageBitmap != null) {
|
||||
Image(
|
||||
bitmap = imageBitmap,
|
||||
contentDescription = "$appName icon",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
} else {
|
||||
LetterIcon(
|
||||
letter = appName.firstOrNull()?.uppercase() ?: "?",
|
||||
color = colorForPackage(packageName),
|
||||
size = size,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LetterIcon(
|
||||
letter: String,
|
||||
color: Color,
|
||||
size: Dp = 40.dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(color),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = letter,
|
||||
color = Color.White,
|
||||
fontSize = (size.value * 0.45f).sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawableToBitmap(drawable: Drawable): Bitmap? {
|
||||
val w = drawable.intrinsicWidth.coerceAtLeast(1)
|
||||
val h = drawable.intrinsicHeight.coerceAtLeast(1)
|
||||
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
drawable.setBounds(0, 0, w, h)
|
||||
drawable.draw(canvas)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun colorForPackage(packageName: String): Color {
|
||||
val colors = listOf(
|
||||
Color(0xFF4CAF50), Color(0xFF2196F3), Color(0xFFFF9800),
|
||||
Color(0xFF9C27B0), Color(0xFFE91E63), Color(0xFF00BCD4),
|
||||
Color(0xFF795548), Color(0xFF607D8B), Color(0xFFFF5722),
|
||||
Color(0xFF3F51B5)
|
||||
)
|
||||
return colors[kotlin.math.abs(packageName.hashCode()) % colors.size]
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.roundingmobile.notisaver.presentation.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.NotificationsNone
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.roundingmobile.notisaver.R
|
||||
|
||||
@Composable
|
||||
fun EmptyState(
|
||||
message: String = stringResource(R.string.empty_timeline),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.NotificationsNone,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
package com.roundingmobile.notisaver.presentation.common
|
||||
|
||||
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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bookmark
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.roundingmobile.notisaver.domain.model.CapturedNotification
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun NotificationItem(
|
||||
notification: CapturedNotification,
|
||||
onClick: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val categoryColor = categoryColor(notification.category)
|
||||
val cardModifier = modifier.fillMaxWidth()
|
||||
val cardShape = RoundedCornerShape(12.dp)
|
||||
val cardElevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
|
||||
if (onClick != null) {
|
||||
Card(
|
||||
modifier = cardModifier,
|
||||
shape = cardShape,
|
||||
elevation = cardElevation,
|
||||
onClick = onClick
|
||||
) { NotificationItemContent(notification, categoryColor) }
|
||||
} else {
|
||||
Card(
|
||||
modifier = cardModifier,
|
||||
shape = cardShape,
|
||||
elevation = cardElevation
|
||||
) { NotificationItemContent(notification, categoryColor) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationItemContent(
|
||||
notification: CapturedNotification,
|
||||
categoryColor: Color
|
||||
) {
|
||||
Row(modifier = Modifier.height(intrinsicSize = androidx.compose.foundation.layout.IntrinsicSize.Min)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(4.dp)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(topStart = 12.dp, bottomStart = 12.dp))
|
||||
.background(categoryColor)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AppIcon(
|
||||
packageName = notification.packageName,
|
||||
appName = notification.appName,
|
||||
size = 40.dp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = notification.appName,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = formatTime(notification.timestamp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
if (!notification.title.isNullOrBlank()) {
|
||||
Text(
|
||||
text = notification.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
if (!notification.text.isNullOrBlank()) {
|
||||
Text(
|
||||
text = notification.text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.isBookmarked || notification.isPossiblyDeleted) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
if (notification.isBookmarked) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bookmark,
|
||||
contentDescription = "Bookmarked",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
if (notification.isPossiblyDeleted) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.DeleteSweep,
|
||||
contentDescription = "Possibly deleted",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun categoryColor(category: String?): Color = when (category) {
|
||||
"msg", "message" -> Color(0xFF4CAF50)
|
||||
"email" -> Color(0xFF2196F3)
|
||||
"social" -> Color(0xFFFF9800)
|
||||
"call" -> Color(0xFF9C27B0)
|
||||
"promo" -> Color(0xFFFF5722)
|
||||
"sys", "system" -> Color(0xFF9E9E9E)
|
||||
else -> Color(0xFFBDBDBD)
|
||||
}
|
||||
|
||||
private val timeFormat = SimpleDateFormat("h:mm a", Locale.getDefault())
|
||||
private val dateFormat = SimpleDateFormat("MMM d", Locale.getDefault())
|
||||
|
||||
private fun formatTime(timestamp: Long): String {
|
||||
val now = System.currentTimeMillis()
|
||||
val diff = now - timestamp
|
||||
val oneDay = 24 * 60 * 60 * 1000L
|
||||
return if (diff < oneDay) timeFormat.format(Date(timestamp))
|
||||
else dateFormat.format(Date(timestamp))
|
||||
}
|
||||
|
||||
fun formatTimeShort(timestamp: Long): String = timeFormat.format(Date(timestamp))
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.roundingmobile.notisaver.presentation.common
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun SeenDivider(modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
Text(
|
||||
text = "Already seen",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
package com.roundingmobile.notisaver.presentation.common
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bookmark
|
||||
import androidx.compose.material.icons.filled.BookmarkBorder
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FilterAlt
|
||||
import androidx.compose.material.icons.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.roundingmobile.notisaver.R
|
||||
import com.roundingmobile.notisaver.domain.model.CapturedNotification
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun SwipeableNotificationItem(
|
||||
notification: CapturedNotification,
|
||||
onClick: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onBookmark: (Boolean) -> Unit,
|
||||
onCreateRule: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
val dismissState = rememberSwipeToDismissBoxState(
|
||||
confirmValueChange = { value ->
|
||||
if (value == SwipeToDismissBoxValue.EndToStart) {
|
||||
onDelete()
|
||||
true
|
||||
} else false
|
||||
}
|
||||
)
|
||||
|
||||
val swipeProgress by remember {
|
||||
derivedStateOf {
|
||||
val offset = dismissState.progress
|
||||
if (dismissState.targetValue == SwipeToDismissBoxValue.Settled) 0f
|
||||
else (1f - offset).coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
val iconAlpha by animateFloatAsState(
|
||||
targetValue = if (swipeProgress > 0.05f) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 150),
|
||||
label = "deleteIconAlpha"
|
||||
)
|
||||
val iconScale by animateFloatAsState(
|
||||
targetValue = if (swipeProgress > 0.05f) 0.8f + (swipeProgress * 0.2f).coerceAtMost(0.2f) else 0.6f,
|
||||
animationSpec = tween(durationMillis = 150),
|
||||
label = "deleteIconScale"
|
||||
)
|
||||
|
||||
val errorContainer = MaterialTheme.colorScheme.errorContainer
|
||||
val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant
|
||||
val bgColor = lerp(surfaceVariant, errorContainer, swipeProgress.coerceIn(0f, 1f))
|
||||
val iconColor = MaterialTheme.colorScheme.onErrorContainer
|
||||
|
||||
Box(modifier = modifier) {
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
backgroundContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(bgColor),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 20.dp)
|
||||
.alpha(iconAlpha)
|
||||
.scale(iconScale),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Delete",
|
||||
color = iconColor,
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = iconColor,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
enableDismissFromStartToEnd = false,
|
||||
enableDismissFromEndToStart = true
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = { showMenu = true }
|
||||
)
|
||||
) {
|
||||
NotificationItem(
|
||||
notification = notification,
|
||||
onClick = null
|
||||
)
|
||||
|
||||
// Context menu
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete") },
|
||||
leadingIcon = { Icon(Icons.Default.Delete, null) },
|
||||
onClick = { showMenu = false; onDelete() }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy text") },
|
||||
leadingIcon = { Icon(Icons.Default.ContentCopy, null) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
copyNotification(context, notification)
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Share") },
|
||||
leadingIcon = { Icon(Icons.Default.Share, null) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
shareNotification(context, notification)
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (notification.isBookmarked) "Remove bookmark" else "Bookmark") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
if (notification.isBookmarked) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
|
||||
null
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onBookmark(!notification.isBookmarked)
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Open in app") },
|
||||
leadingIcon = { Icon(Icons.Default.OpenInNew, null) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
openApp(context, notification.packageName)
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Create filter rule") },
|
||||
leadingIcon = { Icon(Icons.Default.FilterAlt, null) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onCreateRule()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyNotification(context: Context, n: CapturedNotification) {
|
||||
val text = buildString {
|
||||
n.title?.let { appendLine(it) }
|
||||
n.text?.let { appendLine(it) }
|
||||
}
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("notification", text))
|
||||
Toast.makeText(context, R.string.detail_copied, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun shareNotification(context: Context, n: CapturedNotification) {
|
||||
val text = buildString {
|
||||
appendLine("[${n.appName}]")
|
||||
n.title?.let { appendLine(it) }
|
||||
n.text?.let { appendLine(it) }
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, null))
|
||||
}
|
||||
|
||||
private fun openApp(context: Context, packageName: String) {
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(packageName)
|
||||
if (intent != null) context.startActivity(intent)
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.roundingmobile.notisaver.presentation.common
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun TimeBlockHeader(label: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
package com.roundingmobile.notisaver.presentation.detail
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Bookmark
|
||||
import androidx.compose.material.icons.filled.BookmarkBorder
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.roundingmobile.notisaver.R
|
||||
import com.roundingmobile.notisaver.domain.model.CapturedNotification
|
||||
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
|
||||
import com.roundingmobile.notisaver.instrumentation.testTrack
|
||||
import com.roundingmobile.notisaver.presentation.common.AppIcon
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DetailScreen(
|
||||
onBack: () -> Unit,
|
||||
instrumentation: TestInstrumentation,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: DetailViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.detail_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.cd_back))
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
modifier = modifier
|
||||
) { innerPadding ->
|
||||
when (val state = uiState) {
|
||||
is DetailUiState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) { CircularProgressIndicator() }
|
||||
}
|
||||
is DetailUiState.NotFound -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) { Text("Notification not found") }
|
||||
}
|
||||
is DetailUiState.Success -> {
|
||||
DetailContent(
|
||||
notification = state.notification,
|
||||
onToggleBookmark = { viewModel.toggleBookmark() },
|
||||
instrumentation = instrumentation,
|
||||
context = context,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun DetailContent(
|
||||
notification: CapturedNotification,
|
||||
onToggleBookmark: () -> Unit,
|
||||
instrumentation: TestInstrumentation,
|
||||
context: Context,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
.testTrack(instrumentation, "detail_content", "id=${notification.id}")
|
||||
) {
|
||||
// App header with large icon
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AppIcon(
|
||||
packageName = notification.packageName,
|
||||
appName = notification.appName,
|
||||
size = 56.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(notification.appName, style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
notification.packageName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Possibly deleted badge
|
||||
if (notification.isPossiblyDeleted) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.detail_possibly_deleted),
|
||||
modifier = Modifier.padding(12.dp),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
// Title
|
||||
if (!notification.title.isNullOrBlank()) {
|
||||
Text(notification.title, style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Text
|
||||
if (!notification.text.isNullOrBlank()) {
|
||||
Text(notification.text, style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Big text
|
||||
if (!notification.bigText.isNullOrBlank() && notification.bigText != notification.text) {
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(notification.bigText, style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Metadata chips
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
label = { Text(notification.category ?: "Unknown") },
|
||||
leadingIcon = null
|
||||
)
|
||||
AssistChip(onClick = {}, label = { Text("Priority: ${notification.priority}") })
|
||||
AssistChip(onClick = {}, label = { Text("ID: ${notification.notificationId}") })
|
||||
if (notification.isUpdate) {
|
||||
AssistChip(onClick = {}, label = { Text("Update of #${notification.previousVersionId}") })
|
||||
}
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = { copyToClipboard(context, notification) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.testTrack(instrumentation, "btn_copy", "Copy")
|
||||
) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
|
||||
Text("Copy")
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = { shareNotification(context, notification) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.testTrack(instrumentation, "btn_share", "Share")
|
||||
) {
|
||||
Icon(Icons.Default.Share, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
|
||||
Text("Share")
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = onToggleBookmark,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.testTrack(instrumentation, "btn_bookmark", "Bookmark")
|
||||
) {
|
||||
Icon(
|
||||
if (notification.isBookmarked) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
Text(if (notification.isBookmarked) "Bookmarked" else "Bookmark")
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = { openSourceApp(context, notification.packageName) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.testTrack(instrumentation, "btn_open_app", "Open")
|
||||
) {
|
||||
Icon(Icons.Default.OpenInNew, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
|
||||
Text("Open App")
|
||||
}
|
||||
}
|
||||
|
||||
// Actions from notification
|
||||
if (!notification.actionsJson.isNullOrBlank()) {
|
||||
val actions = remember(notification.actionsJson) {
|
||||
runCatching { Json.decodeFromString<List<String>>(notification.actionsJson) }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
if (actions.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Text(stringResource(R.string.detail_actions), style = MaterialTheme.typography.titleSmall)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
) {
|
||||
actions.forEach { action ->
|
||||
AssistChip(onClick = {}, label = { Text(action) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extras (collapsible)
|
||||
if (!notification.extrasJson.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
ExtrasSection(notification.extrasJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExtrasSection(extrasJson: String) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
TextButton(onClick = { expanded = !expanded }) {
|
||||
Text(stringResource(R.string.detail_extras))
|
||||
Icon(
|
||||
if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
val parsedExtras = remember(extrasJson) {
|
||||
runCatching { Json.decodeFromString<JsonObject>(extrasJson) }.getOrNull()
|
||||
}
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
if (parsedExtras != null) {
|
||||
Column(modifier = Modifier.padding(start = 8.dp)) {
|
||||
parsedExtras.forEach { (key, value) ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(key, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
runCatching { value.jsonPrimitive.content }.getOrDefault(value.toString()),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(extrasJson, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyToClipboard(context: Context, notification: CapturedNotification) {
|
||||
val text = buildString {
|
||||
notification.title?.let { appendLine(it) }
|
||||
notification.text?.let { appendLine(it) }
|
||||
notification.bigText?.takeIf { it != notification.text }?.let { appendLine(it) }
|
||||
}
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("notification", text))
|
||||
Toast.makeText(context, R.string.detail_copied, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun shareNotification(context: Context, notification: CapturedNotification) {
|
||||
val text = buildString {
|
||||
appendLine("[${notification.appName}]")
|
||||
notification.title?.let { appendLine(it) }
|
||||
notification.text?.let { appendLine(it) }
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, null))
|
||||
}
|
||||
|
||||
private fun openSourceApp(context: Context, packageName: String) {
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(packageName)
|
||||
if (intent != null) context.startActivity(intent)
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package com.roundingmobile.notisaver.presentation.detail
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.toRoute
|
||||
import com.roundingmobile.notisaver.domain.model.CapturedNotification
|
||||
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
|
||||
import com.roundingmobile.notisaver.domain.usecase.GetNotificationDetailUseCase
|
||||
import com.roundingmobile.notisaver.presentation.navigation.Route
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class DetailViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
getNotificationDetailUseCase: GetNotificationDetailUseCase,
|
||||
private val repository: NotificationRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val route = savedStateHandle.toRoute<Route.Detail>()
|
||||
|
||||
val uiState: StateFlow<DetailUiState> = getNotificationDetailUseCase(route.notificationId)
|
||||
.map { notification ->
|
||||
if (notification != null) DetailUiState.Success(notification)
|
||||
else DetailUiState.NotFound
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), DetailUiState.Loading)
|
||||
|
||||
fun toggleBookmark() {
|
||||
val state = uiState.value
|
||||
if (state is DetailUiState.Success) {
|
||||
viewModelScope.launch {
|
||||
repository.setBookmarked(state.notification.id, !state.notification.isBookmarked)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface DetailUiState {
|
||||
data object Loading : DetailUiState
|
||||
data object NotFound : DetailUiState
|
||||
data class Success(val notification: CapturedNotification) : DetailUiState
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
package com.roundingmobile.notisaver.presentation.export
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.roundingmobile.notisaver.R
|
||||
import com.roundingmobile.notisaver.domain.model.ExportFormat
|
||||
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
|
||||
import com.roundingmobile.notisaver.instrumentation.testTrack
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ExportScreen(
|
||||
onBack: () -> Unit,
|
||||
instrumentation: TestInstrumentation,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ExportViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.export_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.cd_back))
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
modifier = modifier
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text("Export notifications to Downloads folder", style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.export(ExportFormat.CSV) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTrack(instrumentation, "btn_export_csv", "CSV"),
|
||||
enabled = state !is ExportState.Exporting
|
||||
) { Text("Export as CSV") }
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.export(ExportFormat.JSON) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTrack(instrumentation, "btn_export_json", "JSON"),
|
||||
enabled = state !is ExportState.Exporting
|
||||
) { Text("Export as JSON") }
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.export(ExportFormat.TXT) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTrack(instrumentation, "btn_export_txt", "TXT"),
|
||||
enabled = state !is ExportState.Exporting
|
||||
) { Text("Export as TXT") }
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
when (val s = state) {
|
||||
is ExportState.Exporting -> Text("Exporting…", color = MaterialTheme.colorScheme.primary)
|
||||
is ExportState.Success -> {
|
||||
Text(
|
||||
"Saved to Downloads/${s.fileName}",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.testTrack(instrumentation, "export_success")
|
||||
)
|
||||
}
|
||||
is ExportState.Error -> {
|
||||
Text(s.message, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package com.roundingmobile.notisaver.presentation.export
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.roundingmobile.notisaver.domain.model.ExportFormat
|
||||
import com.roundingmobile.notisaver.domain.usecase.ExportNotificationsUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ExportViewModel @Inject constructor(
|
||||
private val exportUseCase: ExportNotificationsUseCase,
|
||||
@ApplicationContext private val context: Context
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<ExportState>(ExportState.Idle)
|
||||
val state: StateFlow<ExportState> = _state.asStateFlow()
|
||||
|
||||
fun export(format: ExportFormat) {
|
||||
_state.value = ExportState.Exporting
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val content = exportUseCase(format)
|
||||
val extension = when (format) {
|
||||
ExportFormat.CSV -> "csv"
|
||||
ExportFormat.JSON -> "json"
|
||||
ExportFormat.TXT -> "txt"
|
||||
ExportFormat.PDF -> "txt"
|
||||
}
|
||||
val mimeType = when (format) {
|
||||
ExportFormat.CSV -> "text/csv"
|
||||
ExportFormat.JSON -> "application/json"
|
||||
ExportFormat.TXT, ExportFormat.PDF -> "text/plain"
|
||||
}
|
||||
val fileName = "notisaver_export_${System.currentTimeMillis()}.$extension"
|
||||
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Downloads.DISPLAY_NAME, fileName)
|
||||
put(MediaStore.Downloads.MIME_TYPE, mimeType)
|
||||
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||
}
|
||||
|
||||
val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
|
||||
if (uri != null) {
|
||||
context.contentResolver.openOutputStream(uri)?.use { it.write(content.toByteArray()) }
|
||||
_state.value = ExportState.Success(fileName)
|
||||
} else {
|
||||
_state.value = ExportState.Error("Failed to create file")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.value = ExportState.Error(e.message ?: "Export failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetState() { _state.value = ExportState.Idle }
|
||||
}
|
||||
|
||||
sealed interface ExportState {
|
||||
data object Idle : ExportState
|
||||
data object Exporting : ExportState
|
||||
data class Success(val fileName: String) : ExportState
|
||||
data class Error(val message: String) : ExportState
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
package com.roundingmobile.notisaver.presentation.lock
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Backspace
|
||||
import androidx.compose.material.icons.filled.Fingerprint
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun LockScreen(
|
||||
onPinEntered: (String) -> Boolean,
|
||||
onBiometricClick: (() -> Unit)?,
|
||||
isCooldown: Boolean,
|
||||
cooldownSeconds: Int,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var pin by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf(false) }
|
||||
val shakeOffset = remember { Animatable(0f) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Auto-submit when 4 digits entered
|
||||
LaunchedEffect(pin) {
|
||||
if (pin.length == 4) {
|
||||
val correct = onPinEntered(pin)
|
||||
if (!correct) {
|
||||
error = true
|
||||
// Shake animation
|
||||
scope.launch {
|
||||
for (i in 0..5) {
|
||||
shakeOffset.animateTo(if (i % 2 == 0) 20f else -20f, spring(stiffness = 2000f))
|
||||
}
|
||||
shakeOffset.animateTo(0f)
|
||||
}
|
||||
delay(200)
|
||||
pin = ""
|
||||
delay(1000)
|
||||
error = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
// App icon
|
||||
Icon(
|
||||
imageVector = Icons.Default.Fingerprint,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Unlock NotiSaver",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = if (isCooldown) "Too many attempts. Wait ${cooldownSeconds}s"
|
||||
else if (error) "Incorrect PIN"
|
||||
else "Enter your 4-digit PIN",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (error || isCooldown) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// PIN dots
|
||||
Row(
|
||||
modifier = Modifier.offset { IntOffset(shakeOffset.value.toInt(), 0) },
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
repeat(4) { i ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (i < pin.length) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
|
||||
// Keypad
|
||||
val keys = listOf(
|
||||
listOf("1", "2", "3"),
|
||||
listOf("4", "5", "6"),
|
||||
listOf("7", "8", "9"),
|
||||
listOf("bio", "0", "del")
|
||||
)
|
||||
|
||||
keys.forEach { row ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
row.forEach { key ->
|
||||
when (key) {
|
||||
"bio" -> {
|
||||
if (onBiometricClick != null) {
|
||||
IconButton(
|
||||
onClick = onBiometricClick,
|
||||
modifier = Modifier.size(72.dp),
|
||||
enabled = !isCooldown
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Fingerprint,
|
||||
contentDescription = "Use biometrics",
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.size(72.dp))
|
||||
}
|
||||
}
|
||||
"del" -> {
|
||||
IconButton(
|
||||
onClick = { if (pin.isNotEmpty()) pin = pin.dropLast(1) },
|
||||
modifier = Modifier.size(72.dp),
|
||||
enabled = !isCooldown
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Backspace,
|
||||
contentDescription = "Delete",
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable(enabled = pin.length < 4 && !isCooldown) {
|
||||
pin += key
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = key,
|
||||
fontSize = 24.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package com.roundingmobile.notisaver.presentation.navigation
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.toRoute
|
||||
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
|
||||
import com.roundingmobile.notisaver.presentation.detail.DetailScreen
|
||||
import com.roundingmobile.notisaver.presentation.export.ExportScreen
|
||||
import com.roundingmobile.notisaver.presentation.settings.FilterRulesScreen
|
||||
import com.roundingmobile.notisaver.presentation.settings.SettingsScreen
|
||||
import com.roundingmobile.notisaver.presentation.stats.StatsScreen
|
||||
import com.roundingmobile.notisaver.presentation.timeline.TimelineScreen
|
||||
|
||||
@Composable
|
||||
fun AppNavGraph(
|
||||
navController: NavHostController,
|
||||
instrumentation: TestInstrumentation,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Route.Timeline,
|
||||
modifier = modifier
|
||||
) {
|
||||
composable<Route.Timeline> {
|
||||
TimelineScreen(
|
||||
onNotificationClick = { id -> navController.navigate(Route.Detail(id)) },
|
||||
instrumentation = instrumentation
|
||||
)
|
||||
}
|
||||
composable<Route.Stats> {
|
||||
StatsScreen(instrumentation = instrumentation)
|
||||
}
|
||||
composable<Route.Settings> {
|
||||
SettingsScreen(
|
||||
instrumentation = instrumentation,
|
||||
onNavigateToFilterRules = { navController.navigate(Route.FilterRules) }
|
||||
)
|
||||
}
|
||||
composable<Route.FilterRules> {
|
||||
FilterRulesScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
instrumentation = instrumentation
|
||||
)
|
||||
}
|
||||
composable<Route.Detail> {
|
||||
DetailScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
instrumentation = instrumentation
|
||||
)
|
||||
}
|
||||
composable<Route.Export> {
|
||||
ExportScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
instrumentation = instrumentation
|
||||
)
|
||||
}
|
||||
composable<Route.Conversation> { backStackEntry ->
|
||||
val conv = backStackEntry.toRoute<Route.Conversation>()
|
||||
PlaceholderScreen("Conversation: ${conv.sender}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceholderScreen(name: String) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = name)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.roundingmobile.notisaver.presentation.navigation
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
sealed interface Route {
|
||||
@Serializable data object Timeline : Route
|
||||
@Serializable data object Stats : Route
|
||||
@Serializable data object Settings : Route
|
||||
@Serializable data class Detail(val notificationId: Long) : Route
|
||||
@Serializable data object Export : Route
|
||||
@Serializable data object FilterRules : Route
|
||||
@Serializable data class Conversation(val packageName: String, val sender: String) : Route
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
package com.roundingmobile.notisaver.presentation.onboarding
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import android.text.TextUtils
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import com.roundingmobile.notisaver.service.NotificationCaptureService
|
||||
|
||||
@Composable
|
||||
fun OnboardingScreen(
|
||||
onComplete: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var step by rememberSaveable { mutableIntStateOf(0) }
|
||||
val context = LocalContext.current
|
||||
var permissionGranted by remember { mutableStateOf(isNotificationListenerEnabled(context)) }
|
||||
|
||||
// Re-check permission when returning from settings
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
androidx.compose.runtime.DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
permissionGranted = isNotificationListenerEnabled(context)
|
||||
if (permissionGranted && step == 1) step = 2
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
Scaffold(modifier = modifier) { innerPadding ->
|
||||
AnimatedContent(targetState = step, label = "onboarding") { currentStep ->
|
||||
when (currentStep) {
|
||||
0 -> OnboardingPage(
|
||||
icon = Icons.Default.Notifications,
|
||||
title = "Welcome to NotiSaver",
|
||||
description = "Capture and save all your notifications. Never miss a deleted message again.",
|
||||
buttonText = "Get Started",
|
||||
onAction = { step = 1 },
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
1 -> OnboardingPage(
|
||||
icon = Icons.Default.Security,
|
||||
title = "Grant Permission",
|
||||
description = if (permissionGranted)
|
||||
"Permission granted! Tap Continue."
|
||||
else
|
||||
"NotiSaver needs notification access to capture your notifications. Tap below to open system settings and enable NotiSaver.",
|
||||
buttonText = if (permissionGranted) "Continue" else "Open Settings",
|
||||
onAction = {
|
||||
if (permissionGranted) {
|
||||
step = 2
|
||||
} else {
|
||||
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
|
||||
}
|
||||
},
|
||||
secondaryButtonText = if (!permissionGranted) "Skip for now" else null,
|
||||
onSecondaryAction = if (!permissionGranted) { { step = 2 } } else null,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
2 -> OnboardingPage(
|
||||
icon = Icons.Default.CheckCircle,
|
||||
title = if (permissionGranted) "You're All Set!" else "Almost Done",
|
||||
description = if (permissionGranted)
|
||||
"Notifications will be captured automatically. Browse your timeline, search, and filter anytime."
|
||||
else
|
||||
"You can grant notification access later in Settings. Without it, NotiSaver can't capture notifications.",
|
||||
buttonText = "Open NotiSaver",
|
||||
onAction = onComplete,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isNotificationListenerEnabled(context: Context): Boolean {
|
||||
val pkgName = context.packageName
|
||||
val flat = Settings.Secure.getString(context.contentResolver, "enabled_notification_listeners")
|
||||
if (!TextUtils.isEmpty(flat)) {
|
||||
val names = flat.split(":")
|
||||
for (name in names) {
|
||||
val cn = ComponentName.unflattenFromString(name)
|
||||
if (cn != null && cn.packageName == pkgName) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnboardingPage(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
description: String,
|
||||
buttonText: String,
|
||||
onAction: () -> Unit,
|
||||
secondaryButtonText: String? = null,
|
||||
onSecondaryAction: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(96.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
Button(
|
||||
onClick = onAction,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(buttonText)
|
||||
}
|
||||
if (secondaryButtonText != null && onSecondaryAction != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedButton(
|
||||
onClick = onSecondaryAction,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(secondaryButtonText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package com.roundingmobile.notisaver.presentation.onboarding
|
||||
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun PermissionScreen(modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Surface(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Notifications,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(96.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Text(
|
||||
text = "One more step to get started",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "NotiSaver needs notification access to capture and save your notifications. Your data stays 100% on this device — nothing is sent anywhere.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Grant notification access")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = "You can revoke this anytime in Settings",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
package com.roundingmobile.notisaver.presentation.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.roundingmobile.notisaver.domain.model.FilterAction
|
||||
import com.roundingmobile.notisaver.domain.model.FilterRule
|
||||
import com.roundingmobile.notisaver.domain.model.MatchField
|
||||
import com.roundingmobile.notisaver.domain.model.MatchType
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FilterRuleEditorSheet(
|
||||
initialRule: FilterRule,
|
||||
onSave: (FilterRule) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
var name by remember { mutableStateOf(initialRule.name) }
|
||||
var pattern by remember { mutableStateOf(initialRule.pattern) }
|
||||
var matchField by remember { mutableStateOf(initialRule.matchField) }
|
||||
var matchType by remember { mutableStateOf(initialRule.matchType) }
|
||||
var action by remember { mutableStateOf(initialRule.action) }
|
||||
var scopeAllApps by remember { mutableStateOf(initialRule.packageName == null) }
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.navigationBarsPadding(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
if (initialRule.id == 0L) "Create filter rule" else "Edit filter rule",
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Rule name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = pattern,
|
||||
onValueChange = { pattern = it },
|
||||
label = { Text("Match pattern") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Match field
|
||||
Text("Match in", style = MaterialTheme.typography.labelLarge)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
val fields = listOf(
|
||||
MatchField.TITLE to "Title",
|
||||
MatchField.TEXT to "Text",
|
||||
MatchField.TITLE_OR_TEXT to "Both"
|
||||
)
|
||||
fields.forEach { (field, label) ->
|
||||
FilterChip(
|
||||
selected = matchField == field,
|
||||
onClick = { matchField = field },
|
||||
label = { Text(label, style = MaterialTheme.typography.labelSmall) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Match type
|
||||
Text("Match mode", style = MaterialTheme.typography.labelLarge)
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
val types = listOf(
|
||||
MatchType.CONTAINS to "Contains",
|
||||
MatchType.EXACT to "Exact",
|
||||
MatchType.REGEX to "Regex"
|
||||
)
|
||||
types.forEachIndexed { index, (type, label) ->
|
||||
SegmentedButton(
|
||||
selected = matchType == type,
|
||||
onClick = { matchType = type },
|
||||
shape = SegmentedButtonDefaults.itemShape(index, types.size)
|
||||
) { Text(label, style = MaterialTheme.typography.labelSmall) }
|
||||
}
|
||||
}
|
||||
|
||||
// Action
|
||||
Text("Action", style = MaterialTheme.typography.labelLarge)
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
val actions = listOf(
|
||||
FilterAction.SUPPRESS to "Don't save",
|
||||
FilterAction.LOW_PRIORITY to "Low priority"
|
||||
)
|
||||
actions.forEachIndexed { index, (a, label) ->
|
||||
SegmentedButton(
|
||||
selected = action == a,
|
||||
onClick = { action = a },
|
||||
shape = SegmentedButtonDefaults.itemShape(index, actions.size)
|
||||
) { Text(label, style = MaterialTheme.typography.labelSmall) }
|
||||
}
|
||||
}
|
||||
|
||||
// Scope
|
||||
if (initialRule.packageName != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text("Apply to all apps", style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
if (scopeAllApps) "Matches from any app"
|
||||
else "Only ${initialRule.appName ?: initialRule.packageName}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(checked = scopeAllApps, onCheckedChange = { scopeAllApps = it })
|
||||
}
|
||||
}
|
||||
|
||||
// Preview
|
||||
if (pattern.isNotBlank()) {
|
||||
val fieldLabel = when (matchField) {
|
||||
MatchField.TITLE -> "title"
|
||||
MatchField.TEXT -> "text"
|
||||
MatchField.TITLE_OR_TEXT -> "title or text"
|
||||
MatchField.APP_NAME -> "app name"
|
||||
MatchField.PACKAGE_NAME -> "package"
|
||||
}
|
||||
val typeLabel = when (matchType) {
|
||||
MatchType.CONTAINS -> "contains"
|
||||
MatchType.EXACT -> "equals"
|
||||
MatchType.REGEX -> "matches"
|
||||
}
|
||||
val actionLabel = when (action) {
|
||||
FilterAction.SUPPRESS -> "won't be saved"
|
||||
FilterAction.LOW_PRIORITY -> "will be marked low priority"
|
||||
}
|
||||
Text(
|
||||
"Notifications where $fieldLabel $typeLabel \"$pattern\" $actionLabel",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
Button(
|
||||
onClick = {
|
||||
onSave(
|
||||
initialRule.copy(
|
||||
name = name.ifBlank { "Filter rule" },
|
||||
pattern = pattern,
|
||||
matchField = matchField,
|
||||
matchType = matchType,
|
||||
action = action,
|
||||
packageName = if (scopeAllApps) null else initialRule.packageName,
|
||||
appName = if (scopeAllApps) null else initialRule.appName,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
},
|
||||
enabled = pattern.isNotBlank()
|
||||
) { Text("Save") }
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
package com.roundingmobile.notisaver.presentation.settings
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FilterAlt
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.roundingmobile.notisaver.domain.model.FilterAction
|
||||
import com.roundingmobile.notisaver.domain.model.FilterRule
|
||||
import com.roundingmobile.notisaver.domain.model.MatchField
|
||||
import com.roundingmobile.notisaver.domain.model.MatchType
|
||||
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
|
||||
import com.roundingmobile.notisaver.presentation.common.AppIcon
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FilterRulesScreen(
|
||||
onBack: () -> Unit,
|
||||
instrumentation: TestInstrumentation,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: FilterRulesViewModel = hiltViewModel()
|
||||
) {
|
||||
val rules by viewModel.rules.collectAsStateWithLifecycle()
|
||||
var showEditor by remember { mutableStateOf(false) }
|
||||
var editingRule by remember { mutableStateOf<FilterRule?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Filter Rules") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { showEditor = true }) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add filter rule")
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
) { innerPadding ->
|
||||
if (rules.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
Icons.Default.FilterAlt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
"No filter rules",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
Text(
|
||||
"Tap + to create one, or long-press a notification",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
start = 8.dp, end = 8.dp, top = 4.dp, bottom = 80.dp
|
||||
)
|
||||
) {
|
||||
items(rules, key = { it.id }) { rule ->
|
||||
FilterRuleItem(
|
||||
rule = rule,
|
||||
onToggle = { enabled -> viewModel.toggleRule(rule.id, enabled) },
|
||||
onDelete = { viewModel.deleteRule(rule.id) },
|
||||
onClick = { editingRule = rule },
|
||||
modifier = Modifier.animateItem()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showEditor) {
|
||||
FilterRuleEditorSheet(
|
||||
initialRule = FilterRule(
|
||||
name = "",
|
||||
action = FilterAction.SUPPRESS,
|
||||
matchField = MatchField.TITLE_OR_TEXT,
|
||||
matchType = MatchType.CONTAINS,
|
||||
pattern = "",
|
||||
createdAt = System.currentTimeMillis()
|
||||
),
|
||||
onSave = { rule ->
|
||||
viewModel.createRule(rule)
|
||||
showEditor = false
|
||||
},
|
||||
onDismiss = { showEditor = false }
|
||||
)
|
||||
}
|
||||
|
||||
editingRule?.let { rule ->
|
||||
FilterRuleEditorSheet(
|
||||
initialRule = rule,
|
||||
onSave = { updated ->
|
||||
viewModel.updateRule(updated)
|
||||
editingRule = null
|
||||
},
|
||||
onDismiss = { editingRule = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun FilterRuleItem(
|
||||
rule: FilterRule,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (rule.isBuiltIn) {
|
||||
FilterRuleCard(rule = rule, onToggle = onToggle, onClick = onClick, modifier = modifier)
|
||||
} else {
|
||||
val dismissState = rememberSwipeToDismissBoxState(
|
||||
confirmValueChange = { value ->
|
||||
if (value == SwipeToDismissBoxValue.EndToStart) {
|
||||
onDelete()
|
||||
true
|
||||
} else false
|
||||
}
|
||||
)
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
backgroundContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(vertical = 2.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.errorContainer),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(horizontal = 20.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
enableDismissFromStartToEnd = false,
|
||||
enableDismissFromEndToStart = true,
|
||||
modifier = modifier
|
||||
) {
|
||||
FilterRuleCard(rule = rule, onToggle = onToggle, onClick = onClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterRuleCard(
|
||||
rule: FilterRule,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||
onClick = onClick
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (rule.packageName != null) {
|
||||
AppIcon(
|
||||
packageName = rule.packageName,
|
||||
appName = rule.appName ?: rule.packageName,
|
||||
size = 36.dp
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Default.FilterAlt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(36.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
rule.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
if (rule.isBuiltIn) {
|
||||
Text(
|
||||
"Preset",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
buildRuleSummary(rule),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
|
||||
Switch(
|
||||
checked = rule.isEnabled,
|
||||
onCheckedChange = onToggle
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildRuleSummary(rule: FilterRule): String {
|
||||
val actionLabel = when (rule.action) {
|
||||
FilterAction.SUPPRESS -> "Don't save"
|
||||
FilterAction.LOW_PRIORITY -> "Low priority"
|
||||
}
|
||||
val fieldLabel = when (rule.matchField) {
|
||||
MatchField.TITLE -> "title"
|
||||
MatchField.TEXT -> "text"
|
||||
MatchField.TITLE_OR_TEXT -> "title/text"
|
||||
MatchField.APP_NAME -> "app name"
|
||||
MatchField.PACKAGE_NAME -> "package"
|
||||
}
|
||||
val typeLabel = when (rule.matchType) {
|
||||
MatchType.CONTAINS -> "contains"
|
||||
MatchType.EXACT -> "equals"
|
||||
MatchType.REGEX -> "matches"
|
||||
}
|
||||
val scope = if (rule.appName != null) " from ${rule.appName}" else ""
|
||||
return "$actionLabel when $fieldLabel $typeLabel \"${rule.pattern}\"$scope"
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
package com.roundingmobile.notisaver.presentation.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.roundingmobile.notisaver.domain.model.CapturedNotification
|
||||
import com.roundingmobile.notisaver.domain.model.FilterAction
|
||||
import com.roundingmobile.notisaver.domain.model.FilterRule
|
||||
import com.roundingmobile.notisaver.domain.model.MatchField
|
||||
import com.roundingmobile.notisaver.domain.model.MatchType
|
||||
import com.roundingmobile.notisaver.domain.repository.FilterRuleRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class FilterRulesViewModel @Inject constructor(
|
||||
private val repository: FilterRuleRepository
|
||||
) : ViewModel() {
|
||||
|
||||
val rules: StateFlow<List<FilterRule>> = repository.getAllRulesFlow()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
init {
|
||||
viewModelScope.launch { repository.seedPresetsIfNeeded() }
|
||||
}
|
||||
|
||||
fun createRule(rule: FilterRule) {
|
||||
viewModelScope.launch { repository.insert(rule) }
|
||||
}
|
||||
|
||||
fun updateRule(rule: FilterRule) {
|
||||
viewModelScope.launch { repository.update(rule) }
|
||||
}
|
||||
|
||||
fun deleteRule(id: Long) {
|
||||
viewModelScope.launch { repository.deleteById(id) }
|
||||
}
|
||||
|
||||
fun toggleRule(id: Long, enabled: Boolean) {
|
||||
viewModelScope.launch { repository.setEnabled(id, enabled) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val SUMMARY_PATTERN = Regex("\\d+\\s+(new\\s+)?(messages?|notifications?|emails?|chats?|updates?)")
|
||||
private val NUMBER_HEAVY = Regex("\\d{2,}")
|
||||
|
||||
fun prefillFromNotification(notification: CapturedNotification): FilterRule {
|
||||
val title = notification.title
|
||||
val text = notification.text
|
||||
val appName = notification.appName
|
||||
|
||||
// Determine best pattern and field
|
||||
val (pattern, field) = when {
|
||||
// Summary notification: "3 messages from 2 chats" → match field=TEXT
|
||||
text != null && SUMMARY_PATTERN.containsMatchIn(text) -> {
|
||||
val generalized = SUMMARY_PATTERN.replace(text) { match ->
|
||||
match.value.replace(Regex("^\\d+"), "\\\\d+")
|
||||
}
|
||||
generalized to MatchField.TEXT
|
||||
}
|
||||
// Title is same as app name → it's generic, use text
|
||||
title != null && title.equals(appName, ignoreCase = true) -> {
|
||||
(text ?: "") to MatchField.TEXT
|
||||
}
|
||||
// Title looks like a person/sender name → offer title filter
|
||||
title != null && title.isNotBlank() -> {
|
||||
title to MatchField.TITLE
|
||||
}
|
||||
// Fallback to text
|
||||
else -> {
|
||||
(text ?: "") to MatchField.TITLE_OR_TEXT
|
||||
}
|
||||
}
|
||||
|
||||
// Choose match type
|
||||
val matchType = if (pattern.contains("\\d+") || pattern.contains(".*")) MatchType.REGEX
|
||||
else MatchType.CONTAINS
|
||||
|
||||
return FilterRule(
|
||||
name = "Filter ${title ?: appName}",
|
||||
action = FilterAction.SUPPRESS,
|
||||
matchField = field,
|
||||
matchType = matchType,
|
||||
pattern = pattern,
|
||||
packageName = notification.packageName,
|
||||
appName = appName,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
package com.roundingmobile.notisaver.presentation.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun PinSetupDialog(
|
||||
title: String = "Set PIN",
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var pin1 by remember { mutableStateOf("") }
|
||||
var pin2 by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Enter a 4-digit PIN", style = MaterialTheme.typography.bodyMedium)
|
||||
OutlinedTextField(
|
||||
value = pin1,
|
||||
onValueChange = { if (it.length <= 4 && it.all(Char::isDigit)) pin1 = it },
|
||||
label = { Text("PIN") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = pin2,
|
||||
onValueChange = { if (it.length <= 4 && it.all(Char::isDigit)) pin2 = it },
|
||||
label = { Text("Confirm PIN") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (error != null) {
|
||||
Text(error!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
when {
|
||||
pin1.length != 4 -> error = "PIN must be 4 digits"
|
||||
pin1 != pin2 -> error = "PINs don't match"
|
||||
else -> onConfirm(pin1)
|
||||
}
|
||||
}) { Text("Set PIN") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PinVerifyDialog(
|
||||
title: String = "Enter current PIN",
|
||||
onVerify: (String) -> Boolean,
|
||||
onSuccess: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var pin by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = pin,
|
||||
onValueChange = { if (it.length <= 4 && it.all(Char::isDigit)) { pin = it; error = false } },
|
||||
label = { Text("PIN") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (error) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text("Incorrect PIN", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
if (onVerify(pin)) onSuccess()
|
||||
else { error = true; pin = "" }
|
||||
}) { Text("Verify") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,541 @@
|
|||
package com.roundingmobile.notisaver.presentation.settings
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.FilterAlt
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.roundingmobile.notisaver.R
|
||||
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
|
||||
import com.roundingmobile.notisaver.instrumentation.testTrack
|
||||
import com.roundingmobile.notisaver.presentation.common.AppIcon
|
||||
import com.roundingmobile.notisaver.presentation.theme.AppThemeType
|
||||
import com.roundingmobile.notisaver.presentation.theme.AppThemes
|
||||
import com.roundingmobile.notisaver.util.AppDateFormatter
|
||||
import com.roundingmobile.notisaver.util.DateFormatOption
|
||||
import com.roundingmobile.notisaver.util.TimeFormatOption
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
instrumentation: TestInstrumentation,
|
||||
onNavigateToFilterRules: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SettingsViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val hiddenApps by viewModel.hiddenAppsState.collectAsStateWithLifecycle()
|
||||
val clearDone by viewModel.clearDone.collectAsStateWithLifecycle()
|
||||
val activeRuleCount by viewModel.activeRuleCount.collectAsStateWithLifecycle()
|
||||
|
||||
var showRetentionDialog by remember { mutableStateOf(false) }
|
||||
var showClearDialog by remember { mutableStateOf(false) }
|
||||
var showHiddenAppsSheet by remember { mutableStateOf(false) }
|
||||
var showCollapseDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// Navigate after clear
|
||||
LaunchedEffect(clearDone) {
|
||||
if (clearDone) viewModel.onClearDoneHandled()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(expandedHeight = 48.dp, title = { Text(stringResource(R.string.settings_title)) })
|
||||
},
|
||||
modifier = modifier
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(bottom = 80.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// --- Appearance ---
|
||||
SectionHeader("Appearance")
|
||||
Text(
|
||||
text = "Theme",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(AppThemeType.entries) { type ->
|
||||
ThemePreviewCard(
|
||||
type = type,
|
||||
isSelected = state.themeType == type,
|
||||
onClick = { viewModel.setTheme(type) },
|
||||
modifier = Modifier.testTrack(instrumentation, "theme_${type.name}")
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
// --- Privacy ---
|
||||
SectionHeader("Privacy")
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Lock,
|
||||
title = stringResource(R.string.settings_app_lock),
|
||||
subtitle = if (!state.deviceSecure) "Set up device lock screen first"
|
||||
else if (state.lockEnabled) "Uses device PIN / fingerprint / face"
|
||||
else "Disabled",
|
||||
modifier = Modifier.testTrack(instrumentation, "settings_lock", "${state.lockEnabled}")
|
||||
) {
|
||||
Switch(
|
||||
checked = state.lockEnabled,
|
||||
onCheckedChange = { enabled -> viewModel.setLockEnabled(enabled) },
|
||||
enabled = state.deviceSecure
|
||||
)
|
||||
}
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
val hiddenCount = hiddenApps.count { it.isHidden }
|
||||
SettingsRow(
|
||||
icon = Icons.Default.VisibilityOff,
|
||||
title = stringResource(R.string.settings_hidden_apps),
|
||||
subtitle = if (hiddenCount > 0) "$hiddenCount app${if (hiddenCount > 1) "s" else ""} hidden" else "None hidden",
|
||||
onClick = { showHiddenAppsSheet = true },
|
||||
modifier = Modifier.testTrack(instrumentation, "settings_hidden_apps")
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
// --- Filtering ---
|
||||
SectionHeader("Notification Filtering")
|
||||
SettingsRow(
|
||||
icon = Icons.Default.VisibilityOff,
|
||||
title = "Filter system notifications",
|
||||
subtitle = "Hide charging, USB, VPN, and other ongoing system notifications"
|
||||
) {
|
||||
Switch(checked = state.filterSystem, onCheckedChange = { viewModel.setFilterSystem(it) })
|
||||
}
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Schedule,
|
||||
title = "Collapse rapid updates",
|
||||
subtitle = collapseWindowLabel(state.collapseWindowMs),
|
||||
onClick = { showCollapseDialog = true }
|
||||
) {
|
||||
Switch(checked = state.collapseRapid, onCheckedChange = { viewModel.setCollapseRapid(it) })
|
||||
}
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.FilterAlt,
|
||||
title = "Filter rules",
|
||||
subtitle = if (activeRuleCount > 0) "$activeRuleCount active rule${if (activeRuleCount != 1) "s" else ""}"
|
||||
else "No active rules",
|
||||
onClick = onNavigateToFilterRules
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
// --- Date & Time Format ---
|
||||
Text(
|
||||
text = "Date format",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
DateFormatSelector(
|
||||
selected = state.dateFormat,
|
||||
onSelect = { viewModel.setDateFormat(it) }
|
||||
)
|
||||
Text(
|
||||
text = "Time format",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
TimeFormatSelector(
|
||||
selected = state.timeFormat,
|
||||
onSelect = { viewModel.setTimeFormat(it) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
// --- Storage ---
|
||||
SectionHeader("Storage")
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Schedule,
|
||||
title = stringResource(R.string.settings_retention),
|
||||
subtitle = if (state.retentionDays == 0) "Unlimited" else "${state.retentionDays} days",
|
||||
onClick = { showRetentionDialog = true },
|
||||
modifier = Modifier.testTrack(instrumentation, "settings_retention", "${state.retentionDays}")
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.DeleteSweep,
|
||||
title = "Clear all data",
|
||||
subtitle = "Delete all captured notifications",
|
||||
onClick = { showClearDialog = true },
|
||||
modifier = Modifier.testTrack(instrumentation, "settings_clear_data")
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
// --- About ---
|
||||
SectionHeader("About")
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Info,
|
||||
title = stringResource(R.string.settings_about),
|
||||
subtitle = "NotiSaver v1.0.0",
|
||||
modifier = Modifier.testTrack(instrumentation, "settings_about")
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse window dialog
|
||||
if (showCollapseDialog) {
|
||||
CollapseWindowDialog(
|
||||
currentMs = state.collapseWindowMs,
|
||||
onSelect = { viewModel.setCollapseWindow(it); showCollapseDialog = false },
|
||||
onDismiss = { showCollapseDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
// Retention dialog
|
||||
if (showRetentionDialog) {
|
||||
RetentionDialog(
|
||||
currentDays = state.retentionDays,
|
||||
onSelect = { viewModel.setRetentionDays(it); showRetentionDialog = false },
|
||||
onDismiss = { showRetentionDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
// Clear data confirmation dialog
|
||||
if (showClearDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showClearDialog = false },
|
||||
title = { Text("Clear all data?") },
|
||||
text = { Text("This will delete all saved notifications. This cannot be undone.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
viewModel.clearAllData()
|
||||
showClearDialog = false
|
||||
}) {
|
||||
Text("Delete all", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showClearDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Hidden apps bottom sheet
|
||||
if (showHiddenAppsSheet) {
|
||||
HiddenAppsSheet(
|
||||
apps = hiddenApps,
|
||||
onToggle = { pkg, hidden -> viewModel.toggleHiddenApp(pkg, hidden) },
|
||||
onDismiss = { showHiddenAppsSheet = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun HiddenAppsSheet(
|
||||
apps: List<HiddenAppItem>,
|
||||
onToggle: (String, Boolean) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
|
||||
Column(modifier = Modifier.padding(bottom = 32.dp)) {
|
||||
Text(
|
||||
"Hidden Apps",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
Text(
|
||||
"Hidden apps will not be captured or shown in the timeline.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 0.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
if (apps.isEmpty()) {
|
||||
Text(
|
||||
"No apps have sent notifications yet.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
} else {
|
||||
apps.forEach { app ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onToggle(app.packageName, app.isHidden) }
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AppIcon(
|
||||
packageName = app.packageName,
|
||||
appName = app.appName,
|
||||
size = 36.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(app.appName, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"${app.count} notification${if (app.count != 1) "s" else ""}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = app.isHidden,
|
||||
onCheckedChange = { onToggle(app.packageName, app.isHidden) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
trailing: @Composable (() -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||
if (subtitle != null) {
|
||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
if (trailing != null) trailing()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemePreviewCard(
|
||||
type: AppThemeType,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colors = if (type == AppThemeType.SYSTEM &&
|
||||
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
|
||||
) {
|
||||
androidx.compose.material3.dynamicLightColorScheme(
|
||||
androidx.compose.ui.platform.LocalContext.current
|
||||
)
|
||||
} else {
|
||||
AppThemes.get(type).light
|
||||
}
|
||||
Card(
|
||||
modifier = modifier.width(90.dp).clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||
elevation = CardDefaults.cardElevation(if (isSelected) 4.dp else 1.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Box(Modifier.size(16.dp).clip(CircleShape).background(colors.primary))
|
||||
Box(Modifier.size(16.dp).clip(CircleShape).background(colors.secondary))
|
||||
Box(Modifier.size(16.dp).clip(CircleShape).background(colors.tertiary))
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(4.dp).clip(RoundedCornerShape(2.dp)).background(colors.primary))
|
||||
Box(Modifier.fillMaxWidth(0.7f).height(4.dp).clip(RoundedCornerShape(2.dp)).background(colors.secondary))
|
||||
Text(type.displayName, style = MaterialTheme.typography.labelSmall, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun collapseWindowLabel(ms: Long): String = when (ms) {
|
||||
30_000L -> "Within 30 seconds"
|
||||
60_000L -> "Within 1 minute"
|
||||
300_000L -> "Within 5 minutes"
|
||||
else -> "Within ${ms / 1000}s"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CollapseWindowDialog(currentMs: Long, onSelect: (Long) -> Unit, onDismiss: () -> Unit) {
|
||||
val options = listOf(30_000L, 60_000L, 300_000L)
|
||||
val labels = listOf("30 seconds", "1 minute", "5 minutes")
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Collapse window") },
|
||||
text = {
|
||||
Column {
|
||||
options.forEachIndexed { index, ms ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable { onSelect(ms) }.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(selected = currentMs == ms, onClick = { onSelect(ms) })
|
||||
Text(labels[index], modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RetentionDialog(currentDays: Int, onSelect: (Int) -> Unit, onDismiss: () -> Unit) {
|
||||
val options = listOf(1, 7, 14, 30, 90, 0)
|
||||
val labels = listOf("1 day", "7 days", "14 days", "30 days", "90 days", "Unlimited")
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.settings_retention)) },
|
||||
text = {
|
||||
Column {
|
||||
options.forEachIndexed { index, days ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable { onSelect(days) }.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(selected = currentDays == days, onClick = { onSelect(days) })
|
||||
Text(labels[index], modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DateFormatSelector(selected: DateFormatOption, onSelect: (DateFormatOption) -> Unit) {
|
||||
val now = System.currentTimeMillis()
|
||||
LazyRow(contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(DateFormatOption.entries.toList()) { option ->
|
||||
val isSelected = selected == option
|
||||
val preview = if (option.pattern != null)
|
||||
java.text.SimpleDateFormat(option.pattern, java.util.Locale.getDefault()).format(java.util.Date(now))
|
||||
else java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).format(java.util.Date(now))
|
||||
Card(
|
||||
modifier = Modifier.width(100.dp).clickable { onSelect(option) },
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||
elevation = CardDefaults.cardElevation(if (isSelected) 4.dp else 1.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(option.label, style = MaterialTheme.typography.labelSmall, maxLines = 1)
|
||||
Text(preview, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimeFormatSelector(selected: TimeFormatOption, onSelect: (TimeFormatOption) -> Unit) {
|
||||
val now = System.currentTimeMillis()
|
||||
LazyRow(contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(TimeFormatOption.entries.toList()) { option ->
|
||||
val isSelected = selected == option
|
||||
val preview = if (option.pattern != null)
|
||||
java.text.SimpleDateFormat(option.pattern, java.util.Locale.getDefault()).format(java.util.Date(now))
|
||||
else java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date(now))
|
||||
Card(
|
||||
modifier = Modifier.width(100.dp).clickable { onSelect(option) },
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||
elevation = CardDefaults.cardElevation(if (isSelected) 4.dp else 1.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(option.label, style = MaterialTheme.typography.labelSmall, maxLines = 1)
|
||||
Text(preview, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
package com.roundingmobile.notisaver.presentation.settings
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.roundingmobile.notisaver.data.local.db.dao.HiddenAppDao
|
||||
import com.roundingmobile.notisaver.data.local.db.entity.HiddenAppEntity
|
||||
import com.roundingmobile.notisaver.domain.repository.FilterRuleRepository
|
||||
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
|
||||
import com.roundingmobile.notisaver.presentation.theme.AppThemeType
|
||||
import com.roundingmobile.notisaver.security.AppLockManager
|
||||
import com.roundingmobile.notisaver.security.NotificationFilter
|
||||
import com.roundingmobile.notisaver.util.AppDateFormatter
|
||||
import com.roundingmobile.notisaver.util.DateFormatOption
|
||||
import com.roundingmobile.notisaver.util.TimeFormatOption
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val prefs: SharedPreferences,
|
||||
private val repository: NotificationRepository,
|
||||
private val hiddenAppDao: HiddenAppDao,
|
||||
private val filterRuleRepository: FilterRuleRepository,
|
||||
val lockManager: AppLockManager,
|
||||
val notificationFilter: NotificationFilter,
|
||||
val dateFormatter: AppDateFormatter
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(loadState())
|
||||
val state: StateFlow<SettingsState> = _state.asStateFlow()
|
||||
|
||||
private val _clearDone = MutableStateFlow(false)
|
||||
val clearDone: StateFlow<Boolean> = _clearDone.asStateFlow()
|
||||
|
||||
val activeRuleCount: StateFlow<Int> = filterRuleRepository.getEnabledCountFlow()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||
|
||||
val hiddenAppsState: StateFlow<List<HiddenAppItem>> =
|
||||
combine(
|
||||
repository.getDistinctApps(),
|
||||
hiddenAppDao.getAllFlow()
|
||||
) { apps, hiddenEntities ->
|
||||
val hiddenSet = hiddenEntities.map { it.packageName }.toSet()
|
||||
apps.map { app ->
|
||||
HiddenAppItem(
|
||||
packageName = app.packageName,
|
||||
appName = app.appName,
|
||||
count = app.notificationCount,
|
||||
isHidden = app.packageName in hiddenSet
|
||||
)
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
private fun loadState(): SettingsState {
|
||||
val themeName = prefs.getString(KEY_THEME, AppThemeType.SYSTEM.name) ?: AppThemeType.SYSTEM.name
|
||||
return SettingsState(
|
||||
retentionDays = prefs.getInt(KEY_RETENTION_DAYS, 30),
|
||||
lockEnabled = lockManager.isLockEnabled,
|
||||
deviceSecure = lockManager.isDeviceSecure,
|
||||
themeType = runCatching { AppThemeType.valueOf(themeName) }.getOrDefault(AppThemeType.SYSTEM),
|
||||
filterSystem = notificationFilter.filterSystemEnabled,
|
||||
collapseRapid = notificationFilter.collapseRapidEnabled,
|
||||
collapseWindowMs = notificationFilter.collapseWindowMs,
|
||||
dateFormat = dateFormatter.dateFormat,
|
||||
timeFormat = dateFormatter.timeFormat
|
||||
)
|
||||
}
|
||||
|
||||
fun setFilterSystem(enabled: Boolean) {
|
||||
notificationFilter.setFilterSystem(enabled)
|
||||
_state.value = _state.value.copy(filterSystem = enabled)
|
||||
}
|
||||
|
||||
fun setCollapseRapid(enabled: Boolean) {
|
||||
notificationFilter.setCollapseRapid(enabled)
|
||||
_state.value = _state.value.copy(collapseRapid = enabled)
|
||||
}
|
||||
|
||||
fun setCollapseWindow(ms: Long) {
|
||||
notificationFilter.setCollapseWindow(ms)
|
||||
_state.value = _state.value.copy(collapseWindowMs = ms)
|
||||
}
|
||||
|
||||
fun setLockEnabled(enabled: Boolean) {
|
||||
if (enabled) lockManager.enableLock() else lockManager.disableLock()
|
||||
_state.value = _state.value.copy(lockEnabled = enabled)
|
||||
}
|
||||
|
||||
fun setRetentionDays(days: Int) {
|
||||
prefs.edit().putInt(KEY_RETENTION_DAYS, days).apply()
|
||||
_state.value = _state.value.copy(retentionDays = days)
|
||||
}
|
||||
|
||||
fun setDateFormat(option: DateFormatOption) {
|
||||
dateFormatter.setDateFormat(option)
|
||||
_state.value = _state.value.copy(dateFormat = option)
|
||||
}
|
||||
|
||||
fun setTimeFormat(option: TimeFormatOption) {
|
||||
dateFormatter.setTimeFormat(option)
|
||||
_state.value = _state.value.copy(timeFormat = option)
|
||||
}
|
||||
|
||||
fun setTheme(type: AppThemeType) {
|
||||
prefs.edit().putString(KEY_THEME, type.name).apply()
|
||||
_state.value = _state.value.copy(themeType = type)
|
||||
}
|
||||
|
||||
fun toggleHiddenApp(packageName: String, currentlyHidden: Boolean) {
|
||||
viewModelScope.launch {
|
||||
if (currentlyHidden) hiddenAppDao.delete(packageName)
|
||||
else hiddenAppDao.insert(HiddenAppEntity(packageName, System.currentTimeMillis()))
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAllData() {
|
||||
viewModelScope.launch {
|
||||
repository.deleteAll()
|
||||
_clearDone.value = true
|
||||
}
|
||||
}
|
||||
|
||||
fun onClearDoneHandled() { _clearDone.value = false }
|
||||
|
||||
companion object {
|
||||
const val KEY_RETENTION_DAYS = "retention_days"
|
||||
const val KEY_THEME = "app_theme"
|
||||
const val KEY_GROUP_BY_APP = "group_by_app"
|
||||
}
|
||||
}
|
||||
|
||||
data class SettingsState(
|
||||
val retentionDays: Int = 30,
|
||||
val lockEnabled: Boolean = false,
|
||||
val deviceSecure: Boolean = true,
|
||||
val themeType: AppThemeType = AppThemeType.SYSTEM,
|
||||
val filterSystem: Boolean = true,
|
||||
val collapseRapid: Boolean = true,
|
||||
val collapseWindowMs: Long = 60_000L,
|
||||
val dateFormat: DateFormatOption = DateFormatOption.SYSTEM,
|
||||
val timeFormat: TimeFormatOption = TimeFormatOption.SYSTEM
|
||||
)
|
||||
|
||||
data class HiddenAppItem(
|
||||
val packageName: String,
|
||||
val appName: String,
|
||||
val count: Int,
|
||||
val isHidden: Boolean
|
||||
)
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
package com.roundingmobile.notisaver.presentation.stats
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.roundingmobile.notisaver.R
|
||||
import com.roundingmobile.notisaver.domain.model.AppInfo
|
||||
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
|
||||
import com.roundingmobile.notisaver.instrumentation.testTrack
|
||||
import com.roundingmobile.notisaver.presentation.common.EmptyState
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StatsScreen(
|
||||
instrumentation: TestInstrumentation,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: StatsViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text(stringResource(R.string.nav_stats)) }) },
|
||||
modifier = modifier
|
||||
) { innerPadding ->
|
||||
when (val state = uiState) {
|
||||
is StatsUiState.Loading -> {}
|
||||
is StatsUiState.Empty -> {
|
||||
EmptyState(
|
||||
message = "No data yet",
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
}
|
||||
is StatsUiState.Success -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp)
|
||||
.testTrack(instrumentation, "stats_content", "total=${state.totalCount}")
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Total Notifications", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"${state.totalCount}",
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text("Top Apps", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
itemsIndexed(state.topApps) { index, app ->
|
||||
AppStatRow(
|
||||
app = app,
|
||||
maxCount = state.topApps.first().notificationCount,
|
||||
modifier = Modifier.testTrack(
|
||||
instrumentation,
|
||||
"stat_app_$index",
|
||||
"${app.packageName}=${app.notificationCount}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppStatRow(
|
||||
app: AppInfo,
|
||||
maxCount: Int,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(app.appName, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
Text("${app.notificationCount}", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { if (maxCount > 0) app.notificationCount.toFloat() / maxCount else 0f },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.roundingmobile.notisaver.presentation.stats
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.roundingmobile.notisaver.domain.model.AppInfo
|
||||
import com.roundingmobile.notisaver.domain.usecase.ComputeStatisticsUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class StatsViewModel @Inject constructor(
|
||||
computeStatisticsUseCase: ComputeStatisticsUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
val uiState: StateFlow<StatsUiState> = computeStatisticsUseCase()
|
||||
.map { data ->
|
||||
if (data.totalCount == 0) StatsUiState.Empty
|
||||
else StatsUiState.Success(
|
||||
totalCount = data.totalCount,
|
||||
topApps = data.appStats.take(10)
|
||||
)
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), StatsUiState.Loading)
|
||||
}
|
||||
|
||||
sealed interface StatsUiState {
|
||||
data object Loading : StatsUiState
|
||||
data object Empty : StatsUiState
|
||||
data class Success(
|
||||
val totalCount: Int,
|
||||
val topApps: List<AppInfo>
|
||||
) : StatsUiState
|
||||
}
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
package com.roundingmobile.notisaver.presentation.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
enum class AppThemeType(val displayName: String) {
|
||||
SYSTEM("System default"),
|
||||
WARM_GOLD("Warm Gold"),
|
||||
OCEAN_BLUE("Ocean Blue"),
|
||||
FOREST_GREEN("Forest Green"),
|
||||
ROSE("Rose"),
|
||||
MIDNIGHT("Midnight"),
|
||||
CLASSIC("Classic");
|
||||
}
|
||||
|
||||
data class AppThemeColors(
|
||||
val light: ColorScheme,
|
||||
val dark: ColorScheme
|
||||
)
|
||||
|
||||
object AppThemes {
|
||||
fun get(type: AppThemeType): AppThemeColors = when (type) {
|
||||
AppThemeType.SYSTEM -> systemTheme
|
||||
AppThemeType.WARM_GOLD -> warmGoldTheme
|
||||
AppThemeType.OCEAN_BLUE -> oceanBlueTheme
|
||||
AppThemeType.FOREST_GREEN -> forestGreenTheme
|
||||
AppThemeType.ROSE -> roseTheme
|
||||
AppThemeType.MIDNIGHT -> midnightTheme
|
||||
AppThemeType.CLASSIC -> classicTheme
|
||||
}
|
||||
|
||||
private val warmGoldTheme = AppThemeColors(
|
||||
light = lightColorScheme(
|
||||
primary = Color(0xFF8B6914),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFFFF0C7),
|
||||
onPrimaryContainer = Color(0xFF2A1F00),
|
||||
secondary = Color(0xFF6D5D3F),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFF8E1BB),
|
||||
onSecondaryContainer = Color(0xFF261A04),
|
||||
tertiary = Color(0xFF4E6544),
|
||||
onTertiary = Color.White,
|
||||
tertiaryContainer = Color(0xFFD0EBC1),
|
||||
onTertiaryContainer = Color(0xFF0C2006),
|
||||
background = Color(0xFFFFF9F0),
|
||||
onBackground = Color(0xFF1E1B16),
|
||||
surface = Color(0xFFFFF9F0),
|
||||
onSurface = Color(0xFF1E1B16),
|
||||
surfaceVariant = Color(0xFFEDE1CF),
|
||||
onSurfaceVariant = Color(0xFF4D4639),
|
||||
error = Color(0xFFBA1A1A),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFF5DDDA),
|
||||
onErrorContainer = Color(0xFF410002)
|
||||
),
|
||||
dark = darkColorScheme(
|
||||
primary = Color(0xFFE8C44D),
|
||||
onPrimary = Color(0xFF3F2E00),
|
||||
primaryContainer = Color(0xFF5B4300),
|
||||
onPrimaryContainer = Color(0xFFFFF0C7),
|
||||
secondary = Color(0xFFDBC5A0),
|
||||
onSecondary = Color(0xFF3C2F15),
|
||||
secondaryContainer = Color(0xFF54452A),
|
||||
onSecondaryContainer = Color(0xFFF8E1BB),
|
||||
tertiary = Color(0xFFB5CFA7),
|
||||
onTertiary = Color(0xFF213519),
|
||||
tertiaryContainer = Color(0xFF374D2E),
|
||||
onTertiaryContainer = Color(0xFFD0EBC1),
|
||||
background = Color(0xFF16130E),
|
||||
onBackground = Color(0xFFEAE1D5),
|
||||
surface = Color(0xFF16130E),
|
||||
onSurface = Color(0xFFEAE1D5),
|
||||
surfaceVariant = Color(0xFF4D4639),
|
||||
onSurfaceVariant = Color(0xFFD0C5B4),
|
||||
error = Color(0xFFFFB4AB),
|
||||
onError = Color(0xFF690005),
|
||||
errorContainer = Color(0xFF5C1617),
|
||||
onErrorContainer = Color(0xFFFFDAD6)
|
||||
)
|
||||
)
|
||||
|
||||
private val oceanBlueTheme = AppThemeColors(
|
||||
light = lightColorScheme(
|
||||
primary = Color(0xFF0061A4),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFD1E4FF),
|
||||
onPrimaryContainer = Color(0xFF001D36),
|
||||
secondary = Color(0xFF535F70),
|
||||
onSecondary = Color.White,
|
||||
tertiary = Color(0xFF6B5778),
|
||||
background = Color(0xFFF8FDFF),
|
||||
onBackground = Color(0xFF1A1C1E),
|
||||
surface = Color(0xFFF8FDFF),
|
||||
onSurface = Color(0xFF1A1C1E),
|
||||
surfaceVariant = Color(0xFFDEE3EB),
|
||||
onSurfaceVariant = Color(0xFF42474E),
|
||||
error = Color(0xFFBA1A1A),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFE8D6D8),
|
||||
onErrorContainer = Color(0xFF410002)
|
||||
),
|
||||
dark = darkColorScheme(
|
||||
primary = Color(0xFF9ECAFF),
|
||||
onPrimary = Color(0xFF003258),
|
||||
primaryContainer = Color(0xFF00497D),
|
||||
onPrimaryContainer = Color(0xFFD1E4FF),
|
||||
secondary = Color(0xFFBBC7DB),
|
||||
onSecondary = Color(0xFF253140),
|
||||
tertiary = Color(0xFFD7BDE4),
|
||||
background = Color(0xFF0E1418),
|
||||
onBackground = Color(0xFFE2E2E6),
|
||||
surface = Color(0xFF0E1418),
|
||||
onSurface = Color(0xFFE2E2E6),
|
||||
surfaceVariant = Color(0xFF42474E),
|
||||
onSurfaceVariant = Color(0xFFC2C7CF),
|
||||
error = Color(0xFFFFB4AB),
|
||||
onError = Color(0xFF690005),
|
||||
errorContainer = Color(0xFF4F2022),
|
||||
onErrorContainer = Color(0xFFFFDAD6)
|
||||
)
|
||||
)
|
||||
|
||||
private val forestGreenTheme = AppThemeColors(
|
||||
light = lightColorScheme(
|
||||
primary = Color(0xFF3A6A1E),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFBBF396),
|
||||
onPrimaryContainer = Color(0xFF072100),
|
||||
secondary = Color(0xFF55624C),
|
||||
onSecondary = Color.White,
|
||||
tertiary = Color(0xFF386667),
|
||||
background = Color(0xFFF8FBF0),
|
||||
onBackground = Color(0xFF1A1C18),
|
||||
surface = Color(0xFFF8FBF0),
|
||||
onSurface = Color(0xFF1A1C18),
|
||||
surfaceVariant = Color(0xFFE0E4D6),
|
||||
onSurfaceVariant = Color(0xFF44483E),
|
||||
error = Color(0xFFBA1A1A),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFE2D9D3),
|
||||
onErrorContainer = Color(0xFF410002)
|
||||
),
|
||||
dark = darkColorScheme(
|
||||
primary = Color(0xFFA0D77D),
|
||||
onPrimary = Color(0xFF143800),
|
||||
primaryContainer = Color(0xFF235106),
|
||||
onPrimaryContainer = Color(0xFFBBF396),
|
||||
secondary = Color(0xFFBECBB0),
|
||||
onSecondary = Color(0xFF283421),
|
||||
tertiary = Color(0xFFA0D0D1),
|
||||
background = Color(0xFF0F1410),
|
||||
onBackground = Color(0xFFE2E3DB),
|
||||
surface = Color(0xFF0F1410),
|
||||
onSurface = Color(0xFFE2E3DB),
|
||||
surfaceVariant = Color(0xFF44483E),
|
||||
onSurfaceVariant = Color(0xFFC4C8BA),
|
||||
error = Color(0xFFFFB4AB),
|
||||
onError = Color(0xFF690005),
|
||||
errorContainer = Color(0xFF4A2524),
|
||||
onErrorContainer = Color(0xFFFFDAD6)
|
||||
)
|
||||
)
|
||||
|
||||
private val roseTheme = AppThemeColors(
|
||||
light = lightColorScheme(
|
||||
primary = Color(0xFFB5166B),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFFFD9E6),
|
||||
onPrimaryContainer = Color(0xFF3E0021),
|
||||
secondary = Color(0xFF74565E),
|
||||
onSecondary = Color.White,
|
||||
tertiary = Color(0xFF7C5635),
|
||||
background = Color(0xFFFFF8F8),
|
||||
onBackground = Color(0xFF201A1B),
|
||||
surface = Color(0xFFFFF8F8),
|
||||
onSurface = Color(0xFF201A1B),
|
||||
surfaceVariant = Color(0xFFF3DDE1),
|
||||
onSurfaceVariant = Color(0xFF524346),
|
||||
error = Color(0xFF904A43),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFF5D8D4),
|
||||
onErrorContainer = Color(0xFF3B0907)
|
||||
),
|
||||
dark = darkColorScheme(
|
||||
primary = Color(0xFFFFB0CC),
|
||||
onPrimary = Color(0xFF650038),
|
||||
primaryContainer = Color(0xFF8E0052),
|
||||
onPrimaryContainer = Color(0xFFFFD9E6),
|
||||
secondary = Color(0xFFE3BDC6),
|
||||
onSecondary = Color(0xFF422931),
|
||||
tertiary = Color(0xFFECBD94),
|
||||
background = Color(0xFF1A1113),
|
||||
onBackground = Color(0xFFEDE0E1),
|
||||
surface = Color(0xFF1A1113),
|
||||
onSurface = Color(0xFFEDE0E1),
|
||||
surfaceVariant = Color(0xFF524346),
|
||||
onSurfaceVariant = Color(0xFFD6C2C5),
|
||||
error = Color(0xFFFFB4AB),
|
||||
onError = Color(0xFF561E19),
|
||||
errorContainer = Color(0xFF5C2D28),
|
||||
onErrorContainer = Color(0xFFFFDAD6)
|
||||
)
|
||||
)
|
||||
|
||||
private val midnightTheme = AppThemeColors(
|
||||
light = lightColorScheme(
|
||||
primary = Color(0xFF4A5568),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFD5DADF),
|
||||
secondary = Color(0xFF5A6270),
|
||||
onSecondary = Color.White,
|
||||
background = Color(0xFFF7F8FA),
|
||||
onBackground = Color(0xFF1A1B1E),
|
||||
surface = Color(0xFFF7F8FA),
|
||||
onSurface = Color(0xFF1A1B1E),
|
||||
surfaceVariant = Color(0xFFE1E2E5),
|
||||
onSurfaceVariant = Color(0xFF44474B),
|
||||
error = Color(0xFF8C4A4A),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFDDD5D5),
|
||||
onErrorContainer = Color(0xFF3B0A0A)
|
||||
),
|
||||
dark = darkColorScheme(
|
||||
primary = Color(0xFFA8B5C8),
|
||||
onPrimary = Color(0xFF1C2636),
|
||||
primaryContainer = Color(0xFF323D4D),
|
||||
secondary = Color(0xFFBEC6D4),
|
||||
onSecondary = Color(0xFF293340),
|
||||
background = Color(0xFF000000),
|
||||
onBackground = Color(0xFFE3E3E6),
|
||||
surface = Color(0xFF000000),
|
||||
onSurface = Color(0xFFE3E3E6),
|
||||
surfaceVariant = Color(0xFF44474B),
|
||||
onSurfaceVariant = Color(0xFFC4C6CB),
|
||||
error = Color(0xFFCFA8A8),
|
||||
onError = Color(0xFF3B0A0A),
|
||||
errorContainer = Color(0xFF3D2626),
|
||||
onErrorContainer = Color(0xFFE8D0D0)
|
||||
)
|
||||
)
|
||||
|
||||
private val classicTheme = AppThemeColors(
|
||||
light = lightColorScheme(
|
||||
primary = Color(0xFF555555),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFE0E0E0),
|
||||
onPrimaryContainer = Color(0xFF1A1A1A),
|
||||
secondary = Color(0xFF666666),
|
||||
onSecondary = Color.White,
|
||||
background = Color(0xFFFFFFFF),
|
||||
onBackground = Color(0xFF1A1A1A),
|
||||
surface = Color(0xFFFFFFFF),
|
||||
onSurface = Color(0xFF1A1A1A),
|
||||
surfaceVariant = Color(0xFFEEEEEE),
|
||||
onSurfaceVariant = Color(0xFF444444),
|
||||
error = Color(0xFF8B4545),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFE0D4D4),
|
||||
onErrorContainer = Color(0xFF370808)
|
||||
),
|
||||
dark = darkColorScheme(
|
||||
primary = Color(0xFFBBBBBB),
|
||||
onPrimary = Color(0xFF1A1A1A),
|
||||
primaryContainer = Color(0xFF444444),
|
||||
onPrimaryContainer = Color(0xFFE0E0E0),
|
||||
secondary = Color(0xFFAAAAAA),
|
||||
onSecondary = Color(0xFF1A1A1A),
|
||||
background = Color(0xFF121212),
|
||||
onBackground = Color(0xFFE0E0E0),
|
||||
surface = Color(0xFF121212),
|
||||
onSurface = Color(0xFFE0E0E0),
|
||||
surfaceVariant = Color(0xFF333333),
|
||||
onSurfaceVariant = Color(0xFFBBBBBB),
|
||||
error = Color(0xFFC4A0A0),
|
||||
onError = Color(0xFF370808),
|
||||
errorContainer = Color(0xFF3A2222),
|
||||
onErrorContainer = Color(0xFFE0D4D4)
|
||||
)
|
||||
)
|
||||
|
||||
// System theme uses device dynamic colors (handled in Theme.kt), this is fallback
|
||||
private val systemTheme = warmGoldTheme
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.roundingmobile.notisaver.presentation.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650A4)
|
||||
val PurpleGrey40 = Color(0xFF625B71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.roundingmobile.notisaver.presentation.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
val LocalAppThemeType = compositionLocalOf { AppThemeType.SYSTEM }
|
||||
|
||||
@Composable
|
||||
fun NotiSaverTheme(
|
||||
themeType: AppThemeType = AppThemeType.SYSTEM,
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
themeType == AppThemeType.SYSTEM && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context)
|
||||
else dynamicLightColorScheme(context)
|
||||
}
|
||||
else -> {
|
||||
val theme = AppThemes.get(themeType)
|
||||
if (darkTheme) theme.dark else theme.light
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalAppThemeType provides themeType) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package com.roundingmobile.notisaver.presentation.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
)
|
||||
|
|
@ -0,0 +1,757 @@
|
|||
package com.roundingmobile.notisaver.presentation.timeline
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Sort
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.FiberNew
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.roundingmobile.notisaver.R
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import com.roundingmobile.notisaver.domain.model.CapturedNotification
|
||||
import com.roundingmobile.notisaver.domain.model.SortOrder
|
||||
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
|
||||
import com.roundingmobile.notisaver.instrumentation.testTrack
|
||||
import com.roundingmobile.notisaver.presentation.common.EmptyState
|
||||
import com.roundingmobile.notisaver.presentation.common.SeenDivider
|
||||
import androidx.compose.ui.draw.clip
|
||||
import com.roundingmobile.notisaver.presentation.common.SwipeableNotificationItem
|
||||
import com.roundingmobile.notisaver.presentation.settings.FilterRuleEditorSheet
|
||||
import com.roundingmobile.notisaver.presentation.settings.FilterRulesViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import com.roundingmobile.notisaver.presentation.common.TimeBlockHeader
|
||||
import com.roundingmobile.notisaver.util.DateTimeUtils
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun TimelineScreen(
|
||||
onNotificationClick: (Long) -> Unit,
|
||||
instrumentation: TestInstrumentation,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: TimelineViewModel = hiltViewModel(),
|
||||
filterRulesViewModel: FilterRulesViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val sortOrder by viewModel.sortOrder.collectAsStateWithLifecycle()
|
||||
var ruleCreationNotification by remember { mutableStateOf<CapturedNotification?>(null) }
|
||||
val searchActive by viewModel.searchActive.collectAsStateWithLifecycle()
|
||||
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
|
||||
val filterSheetVisible by viewModel.filterSheetVisible.collectAsStateWithLifecycle()
|
||||
val filterApps by viewModel.filterApps.collectAsStateWithLifecycle()
|
||||
val activeFilterCount by viewModel.activeFilterCount.collectAsStateWithLifecycle()
|
||||
val showNewOnly by viewModel.showNewOnly.collectAsStateWithLifecycle()
|
||||
|
||||
// Back button closes search instead of exiting app
|
||||
BackHandler(enabled = searchActive) {
|
||||
viewModel.deactivateSearch()
|
||||
}
|
||||
|
||||
var sortMenuExpanded by remember { mutableStateOf(false) }
|
||||
var showClearAllDialog by remember { mutableStateOf(false) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
|
||||
val coroutineScope = androidx.compose.runtime.rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = {
|
||||
androidx.compose.material3.SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier.padding(bottom = 80.dp)
|
||||
)
|
||||
},
|
||||
topBar = {
|
||||
AnimatedContent(targetState = searchActive, label = "topbar") { isSearching ->
|
||||
if (isSearching) {
|
||||
TopAppBar(
|
||||
expandedHeight = 48.dp,
|
||||
title = {
|
||||
TextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { viewModel.onSearchQueryChange(it) },
|
||||
placeholder = { Text(stringResource(R.string.search_hint)) },
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.testTrack(instrumentation, "search_field", searchQuery),
|
||||
trailingIcon = {
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
IconButton(onClick = { viewModel.onSearchQueryChange("") }) {
|
||||
Icon(Icons.Default.Clear, contentDescription = "Clear")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { viewModel.deactivateSearch() }) {
|
||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.cd_back))
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
)
|
||||
)
|
||||
} else {
|
||||
TopAppBar(
|
||||
expandedHeight = 48.dp,
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.nav_timeline),
|
||||
modifier = Modifier.testTrack(instrumentation, "timeline_title", "Timeline")
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { viewModel.activateSearch() },
|
||||
modifier = Modifier.testTrack(instrumentation, "btn_search", "Search")
|
||||
) {
|
||||
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.cd_search))
|
||||
}
|
||||
IconButton(
|
||||
onClick = { viewModel.showFilterSheet() },
|
||||
modifier = Modifier.testTrack(instrumentation, "btn_filter", "Filter")
|
||||
) {
|
||||
Icon(Icons.Default.FilterList, contentDescription = stringResource(R.string.cd_filter))
|
||||
}
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { sortMenuExpanded = true },
|
||||
modifier = Modifier.testTrack(instrumentation, "btn_sort", "Sort: $sortOrder")
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = stringResource(R.string.cd_sort))
|
||||
}
|
||||
SortDropdown(
|
||||
expanded = sortMenuExpanded,
|
||||
currentSort = sortOrder,
|
||||
onSelect = { viewModel.setSortOrder(it); sortMenuExpanded = false },
|
||||
onClearAll = { showClearAllDialog = true },
|
||||
onDismiss = { sortMenuExpanded = false }
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
) { innerPadding ->
|
||||
Column(modifier = Modifier.padding(innerPadding)) {
|
||||
// Chip bar: new count + new only filter + active app filters
|
||||
val state = uiState
|
||||
val newCount = if (state is TimelineUiState.Success) state.newCount else 0
|
||||
|
||||
AnimatedVisibility(visible = (newCount > 0 || activeFilterCount > 0 || showNewOnly) && !searchActive) {
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
// "X new" chip
|
||||
if (newCount > 0) {
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
label = { Text("$newCount new") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.FiberNew,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
},
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
labelColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
),
|
||||
modifier = Modifier.testTrack(instrumentation, "chip_new_count", "$newCount")
|
||||
)
|
||||
}
|
||||
|
||||
// "New only" filter chip
|
||||
FilterChip(
|
||||
selected = showNewOnly,
|
||||
onClick = { viewModel.toggleShowNewOnly() },
|
||||
label = { Text("New only") },
|
||||
modifier = Modifier.testTrack(instrumentation, "chip_new_only", "$showNewOnly")
|
||||
)
|
||||
|
||||
// Active app filter chips
|
||||
val visibleApps = filterApps.filter { it.isVisible }
|
||||
if (visibleApps.isNotEmpty() && visibleApps.size < filterApps.size) {
|
||||
visibleApps.forEach { app ->
|
||||
AssistChip(
|
||||
onClick = { viewModel.showFilterSheet() },
|
||||
label = { Text(app.appName, style = MaterialTheme.typography.labelSmall) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main content
|
||||
when (state) {
|
||||
is TimelineUiState.Loading -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
is TimelineUiState.Empty -> {
|
||||
EmptyState(
|
||||
modifier = Modifier.testTrack(instrumentation, "empty_state", "No notifications")
|
||||
)
|
||||
}
|
||||
is TimelineUiState.SearchEmpty -> {
|
||||
EmptyState(
|
||||
message = stringResource(R.string.empty_search_query, state.query),
|
||||
modifier = Modifier.testTrack(instrumentation, "search_empty", "No results for ${state.query}")
|
||||
)
|
||||
}
|
||||
is TimelineUiState.Success -> {
|
||||
TimelineList(
|
||||
notifications = state.notifications,
|
||||
lastSeenTimestamp = state.lastSeenTimestamp,
|
||||
onNotificationClick = onNotificationClick,
|
||||
onDelete = { notification ->
|
||||
viewModel.deleteNotification(notification)
|
||||
coroutineScope.launch {
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = "Notification deleted",
|
||||
actionLabel = "Undo",
|
||||
duration = androidx.compose.material3.SnackbarDuration.Short
|
||||
)
|
||||
if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
|
||||
viewModel.undoDelete()
|
||||
}
|
||||
}
|
||||
},
|
||||
onBookmark = { id, bookmarked -> viewModel.bookmarkNotification(id, bookmarked) },
|
||||
onCreateRule = { notification -> ruleCreationNotification = notification },
|
||||
onDeleteMultiple = { items, label ->
|
||||
viewModel.deleteMultiple(items)
|
||||
coroutineScope.launch {
|
||||
snackbarHostState.showSnackbar("$label deleted")
|
||||
}
|
||||
},
|
||||
isSearching = searchActive,
|
||||
instrumentation = instrumentation
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter rule editor sheet
|
||||
ruleCreationNotification?.let { notification ->
|
||||
FilterRuleEditorSheet(
|
||||
initialRule = FilterRulesViewModel.prefillFromNotification(notification),
|
||||
onSave = { rule ->
|
||||
filterRulesViewModel.createRule(rule)
|
||||
ruleCreationNotification = null
|
||||
},
|
||||
onDismiss = { ruleCreationNotification = null }
|
||||
)
|
||||
}
|
||||
|
||||
// Clear all confirmation dialog
|
||||
if (showClearAllDialog) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { showClearAllDialog = false },
|
||||
title = { Text("Clear all notifications") },
|
||||
text = { Text("This will permanently delete all captured notifications. This cannot be undone.") },
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(onClick = {
|
||||
viewModel.clearAll()
|
||||
showClearAllDialog = false
|
||||
}) { Text("Clear all", color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton(onClick = { showClearAllDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Refresh last-seen timestamp each time the activity resumes
|
||||
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
|
||||
androidx.compose.runtime.DisposableEffect(lifecycleOwner) {
|
||||
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
|
||||
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
|
||||
viewModel.refreshLastSeen()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
LaunchedEffect(searchActive) {
|
||||
if (searchActive) focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
if (filterSheetVisible) {
|
||||
FilterBottomSheet(
|
||||
apps = filterApps,
|
||||
onToggle = { viewModel.toggleAppFilter(it) },
|
||||
onSelectAll = { viewModel.selectAllApps() },
|
||||
onDeselectAll = { viewModel.deselectAllApps() },
|
||||
onDismiss = { viewModel.hideFilterSheet() },
|
||||
instrumentation = instrumentation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SortDropdown(
|
||||
expanded: Boolean,
|
||||
currentSort: SortOrder,
|
||||
onSelect: (SortOrder) -> Unit,
|
||||
onClearAll: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
SortOrder.entries.forEach { order ->
|
||||
val label = when (order) {
|
||||
SortOrder.NEWEST_FIRST -> stringResource(R.string.sort_newest)
|
||||
SortOrder.OLDEST_FIRST -> stringResource(R.string.sort_oldest)
|
||||
SortOrder.BY_APP -> stringResource(R.string.sort_by_app)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = label,
|
||||
color = if (order == currentSort) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
onClick = { onSelect(order) }
|
||||
)
|
||||
}
|
||||
androidx.compose.material3.HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Clear all", color = MaterialTheme.colorScheme.error) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Clear,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
onClick = { onDismiss(); onClearAll() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun FilterBottomSheet(
|
||||
apps: List<FilterAppInfo>,
|
||||
onToggle: (String) -> Unit,
|
||||
onSelectAll: () -> Unit,
|
||||
onDeselectAll: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
instrumentation: TestInstrumentation
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
|
||||
Column(modifier = Modifier.padding(bottom = 32.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(R.string.filter_title), style = MaterialTheme.typography.titleMedium)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
androidx.compose.material3.TextButton(onClick = onSelectAll) {
|
||||
Text(stringResource(R.string.filter_select_all))
|
||||
}
|
||||
androidx.compose.material3.TextButton(onClick = onDeselectAll) {
|
||||
Text(stringResource(R.string.filter_deselect_all))
|
||||
}
|
||||
}
|
||||
}
|
||||
apps.forEach { app ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.testTrack(instrumentation, "filter_app_${app.packageName}", "${app.appName} visible=${app.isVisible}"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
|
||||
androidx.compose.material3.Checkbox(
|
||||
checked = app.isVisible,
|
||||
onCheckedChange = { onToggle(app.packageName) }
|
||||
)
|
||||
com.roundingmobile.notisaver.presentation.common.AppIcon(
|
||||
packageName = app.packageName,
|
||||
appName = app.appName,
|
||||
size = 32.dp,
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
Text(app.appName, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
AssistChip(onClick = {}, label = { Text("${app.count}") })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimelineList(
|
||||
notifications: List<CapturedNotification>,
|
||||
lastSeenTimestamp: Long,
|
||||
onNotificationClick: (Long) -> Unit,
|
||||
onDelete: (CapturedNotification) -> Unit,
|
||||
onBookmark: (Long, Boolean) -> Unit,
|
||||
onCreateRule: (CapturedNotification) -> Unit,
|
||||
onDeleteMultiple: (List<CapturedNotification>, String) -> Unit,
|
||||
isSearching: Boolean,
|
||||
instrumentation: TestInstrumentation,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val bottomPad = 78.dp
|
||||
val dayGroups = DateTimeUtils.groupByDay(notifications)
|
||||
val expandedDays = remember { mutableStateOf(setOf("Today")) }
|
||||
val expandedAppGroups = remember { mutableStateOf(setOf<String>()) }
|
||||
var globalIndex = 0
|
||||
var seenDividerInserted = false
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.testTrack(instrumentation, "timeline_list", "count=${notifications.size}"),
|
||||
contentPadding = PaddingValues(bottom = bottomPad)
|
||||
) {
|
||||
dayGroups.forEach { dayGroup ->
|
||||
val isDayExpanded = isSearching || dayGroup.key in expandedDays.value
|
||||
|
||||
// Day header
|
||||
item(key = "day_${dayGroup.key}") {
|
||||
DayHeader(
|
||||
dayKey = dayGroup.key,
|
||||
timestamp = dayGroup.representativeTimestamp,
|
||||
count = dayGroup.notifications.size,
|
||||
isExpanded = isDayExpanded,
|
||||
onClick = {
|
||||
expandedDays.value = if (isDayExpanded) expandedDays.value - dayGroup.key
|
||||
else expandedDays.value + dayGroup.key
|
||||
},
|
||||
modifier = Modifier.animateItem()
|
||||
)
|
||||
}
|
||||
|
||||
if (!isDayExpanded) {
|
||||
globalIndex += dayGroup.notifications.size
|
||||
return@forEach
|
||||
}
|
||||
|
||||
// Within expanded day: group consecutive same-app
|
||||
if (isSearching) {
|
||||
// Search: flat list, no grouping
|
||||
dayGroup.notifications.forEach { notification ->
|
||||
val idx = globalIndex++
|
||||
item(key = notification.id) {
|
||||
SwipeableNotificationItem(
|
||||
notification = notification,
|
||||
onClick = { onNotificationClick(notification.id) },
|
||||
onDelete = { onDelete(notification) },
|
||||
onBookmark = { bookmarked -> onBookmark(notification.id, bookmarked) },
|
||||
onCreateRule = { onCreateRule(notification) },
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
.testTrack(instrumentation, "notif_item_$idx", "${notification.appName}: ${notification.title}")
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val consecutiveGroups = DateTimeUtils.groupConsecutiveByApp(dayGroup.notifications)
|
||||
|
||||
consecutiveGroups.forEachIndexed { groupIdx, group ->
|
||||
if (group.isGroup) {
|
||||
// Consecutive app group (2+)
|
||||
val groupKey = "${dayGroup.key}_${group.packageName}_$groupIdx"
|
||||
val isGroupExpanded = groupKey in expandedAppGroups.value
|
||||
|
||||
item(key = "appgroup_$groupKey") {
|
||||
ConsecutiveAppGroupHeader(
|
||||
group = group,
|
||||
isExpanded = isGroupExpanded,
|
||||
onClick = {
|
||||
expandedAppGroups.value = if (isGroupExpanded) expandedAppGroups.value - groupKey
|
||||
else expandedAppGroups.value + groupKey
|
||||
},
|
||||
onSwipeDelete = {
|
||||
onDeleteMultiple(group.notifications, "${group.count} ${group.appName} notifications")
|
||||
},
|
||||
modifier = Modifier.animateItem().padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (isGroupExpanded) {
|
||||
group.notifications.forEach { notification ->
|
||||
val idx = globalIndex++
|
||||
val isNew = notification.timestamp > lastSeenTimestamp
|
||||
if (!isNew && !seenDividerInserted && lastSeenTimestamp > 0 && dayGroup.key == "Today") {
|
||||
seenDividerInserted = true
|
||||
item(key = "seen_divider") { SeenDivider(modifier = Modifier.animateItem()) }
|
||||
}
|
||||
item(key = notification.id) {
|
||||
SwipeableNotificationItem(
|
||||
notification = notification,
|
||||
onClick = { onNotificationClick(notification.id) },
|
||||
onDelete = { onDelete(notification) },
|
||||
onBookmark = { bookmarked -> onBookmark(notification.id, bookmarked) },
|
||||
onCreateRule = { onCreateRule(notification) },
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.padding(start = 24.dp, end = 8.dp, top = 1.dp, bottom = 1.dp)
|
||||
.testTrack(instrumentation, "notif_item_$idx", "${notification.appName}: ${notification.title}")
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
globalIndex += group.count
|
||||
}
|
||||
} else {
|
||||
// Single notification
|
||||
val notification = group.latest
|
||||
val idx = globalIndex++
|
||||
val isNew = notification.timestamp > lastSeenTimestamp
|
||||
if (!isNew && !seenDividerInserted && lastSeenTimestamp > 0 && dayGroup.key == "Today") {
|
||||
seenDividerInserted = true
|
||||
item(key = "seen_divider") { SeenDivider(modifier = Modifier.animateItem()) }
|
||||
}
|
||||
item(key = notification.id) {
|
||||
SwipeableNotificationItem(
|
||||
notification = notification,
|
||||
onClick = { onNotificationClick(notification.id) },
|
||||
onDelete = { onDelete(notification) },
|
||||
onBookmark = { bookmarked -> onBookmark(notification.id, bookmarked) },
|
||||
onCreateRule = { onCreateRule(notification) },
|
||||
modifier = Modifier
|
||||
.animateItem()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
.testTrack(instrumentation, "notif_item_$idx", "${notification.appName}: ${notification.title}")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayHeader(
|
||||
dayKey: String,
|
||||
timestamp: Long,
|
||||
count: Int,
|
||||
isExpanded: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val label = when (dayKey) {
|
||||
"Today" -> "Today"
|
||||
"Yesterday" -> "Yesterday"
|
||||
else -> {
|
||||
val cal = java.util.Calendar.getInstance().apply { timeInMillis = timestamp }
|
||||
val dayName = java.text.SimpleDateFormat("EEEE", java.util.Locale.getDefault()).format(java.util.Date(timestamp))
|
||||
val date = java.text.SimpleDateFormat("d MMMM", java.util.Locale.getDefault()).format(java.util.Date(timestamp))
|
||||
"$dayName $date"
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (!isExpanded) {
|
||||
Text(
|
||||
"$count",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
if (isExpanded) androidx.compose.material.icons.Icons.Default.Close
|
||||
else Icons.Default.FilterList,
|
||||
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ConsecutiveAppGroupHeader(
|
||||
group: com.roundingmobile.notisaver.util.ConsecutiveGroup,
|
||||
isExpanded: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onSwipeDelete: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dismissState = rememberSwipeToDismissBoxState(
|
||||
confirmValueChange = { value ->
|
||||
if (value == SwipeToDismissBoxValue.EndToStart) { onSwipeDelete(); true } else false
|
||||
}
|
||||
)
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
backgroundContent = {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(vertical = 2.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.errorContainer),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Text(
|
||||
"Delete ${group.count}",
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(horizontal = 20.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
enableDismissFromStartToEnd = false,
|
||||
enableDismissFromEndToStart = true,
|
||||
modifier = modifier
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||
onClick = onClick
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
com.roundingmobile.notisaver.presentation.common.AppIcon(
|
||||
packageName = group.packageName,
|
||||
appName = group.appName,
|
||||
size = 40.dp
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).padding(start = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(group.appName, style = MaterialTheme.typography.labelMedium, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(10.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 1.dp)
|
||||
) {
|
||||
Text("${group.count}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onPrimaryContainer)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
com.roundingmobile.notisaver.presentation.common.formatTimeShort(group.latest.timestamp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
group.latest.title?.takeIf { it.isNotBlank() }?.let { title ->
|
||||
Text(title, style = MaterialTheme.typography.bodyMedium, fontWeight = androidx.compose.ui.text.font.FontWeight.Medium, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
|
||||
}
|
||||
group.latest.text?.takeIf { it.isNotBlank() }?.let { text ->
|
||||
Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
if (isExpanded) androidx.compose.material.icons.Icons.Default.Close else Icons.Default.FilterList,
|
||||
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
||||
modifier = Modifier.padding(start = 4.dp).size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
package com.roundingmobile.notisaver.presentation.timeline
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.roundingmobile.notisaver.data.local.db.dao.AppFilterDao
|
||||
import com.roundingmobile.notisaver.data.local.db.entity.AppFilterEntity
|
||||
import com.roundingmobile.notisaver.domain.model.CapturedNotification
|
||||
import com.roundingmobile.notisaver.domain.model.SortOrder
|
||||
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class TimelineViewModel @Inject constructor(
|
||||
private val repository: NotificationRepository,
|
||||
private val appFilterDao: AppFilterDao,
|
||||
private val prefs: SharedPreferences,
|
||||
@ApplicationContext private val context: Context
|
||||
) : ViewModel() {
|
||||
|
||||
// --- New-since-last-open ---
|
||||
private val _lastSeenTimestamp = MutableStateFlow(prefs.getLong(KEY_LAST_SEEN, 0L))
|
||||
val lastSeenTimestamp: StateFlow<Long> = _lastSeenTimestamp.asStateFlow()
|
||||
|
||||
private val _showNewOnly = MutableStateFlow(false)
|
||||
val showNewOnly: StateFlow<Boolean> = _showNewOnly.asStateFlow()
|
||||
|
||||
// --- Search ---
|
||||
private val _searchActive = MutableStateFlow(false)
|
||||
val searchActive: StateFlow<Boolean> = _searchActive.asStateFlow()
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
// --- Sort ---
|
||||
private val _sortOrder = MutableStateFlow(SortOrder.NEWEST_FIRST)
|
||||
val sortOrder: StateFlow<SortOrder> = _sortOrder.asStateFlow()
|
||||
|
||||
// --- Filter ---
|
||||
private val _filteredOutPackages = MutableStateFlow<Set<String>>(emptySet())
|
||||
val filteredOutPackages: StateFlow<Set<String>> = _filteredOutPackages.asStateFlow()
|
||||
|
||||
// --- Filter bottom sheet ---
|
||||
private val _filterSheetVisible = MutableStateFlow(false)
|
||||
val filterSheetVisible: StateFlow<Boolean> = _filterSheetVisible.asStateFlow()
|
||||
|
||||
// --- App info cache ---
|
||||
private val appNameCache = mutableMapOf<String, String>()
|
||||
private val appIconCache = mutableMapOf<String, Drawable?>()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
_filteredOutPackages.value = appFilterDao.getHiddenFilterPackages().toSet()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val allNotifications = _sortOrder.flatMapLatest { order ->
|
||||
repository.getTimeline(order)
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||
private val searchResults = _searchQuery.debounce(300).flatMapLatest { query ->
|
||||
if (query.isBlank()) allNotifications
|
||||
else repository.search(query)
|
||||
}
|
||||
|
||||
val filterApps: StateFlow<List<FilterAppInfo>> = repository.getDistinctApps()
|
||||
.combine(_filteredOutPackages) { apps, filtered ->
|
||||
apps.map { app ->
|
||||
FilterAppInfo(
|
||||
packageName = app.packageName,
|
||||
appName = resolveAppName(app.packageName),
|
||||
count = app.notificationCount,
|
||||
isVisible = app.packageName !in filtered,
|
||||
icon = resolveAppIcon(app.packageName)
|
||||
)
|
||||
}
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
val uiState: StateFlow<TimelineUiState> = combine(
|
||||
searchResults,
|
||||
_filteredOutPackages,
|
||||
_searchQuery,
|
||||
_lastSeenTimestamp,
|
||||
_showNewOnly
|
||||
) { notifications, filtered, query, lastSeen, newOnly ->
|
||||
var displayList = if (filtered.isEmpty()) notifications
|
||||
else notifications.filter { it.packageName !in filtered }
|
||||
|
||||
if (newOnly) {
|
||||
displayList = displayList.filter { it.timestamp > lastSeen }
|
||||
}
|
||||
|
||||
val newCount = displayList.count { it.timestamp > lastSeen }
|
||||
|
||||
when {
|
||||
query.isNotBlank() && displayList.isEmpty() -> TimelineUiState.SearchEmpty(query)
|
||||
displayList.isEmpty() -> TimelineUiState.Empty
|
||||
else -> TimelineUiState.Success(
|
||||
notifications = displayList,
|
||||
newCount = newCount,
|
||||
lastSeenTimestamp = lastSeen
|
||||
)
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TimelineUiState.Loading)
|
||||
|
||||
val activeFilterCount: StateFlow<Int> = _filteredOutPackages
|
||||
.map { it.size }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||
|
||||
// --- Delete / Undo ---
|
||||
|
||||
private var lastDeleted: CapturedNotification? = null
|
||||
|
||||
fun deleteNotification(notification: CapturedNotification) {
|
||||
lastDeleted = notification
|
||||
viewModelScope.launch {
|
||||
repository.deleteById(notification.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun undoDelete() {
|
||||
lastDeleted?.let { notification ->
|
||||
viewModelScope.launch {
|
||||
repository.insert(notification.copy(id = 0))
|
||||
}
|
||||
lastDeleted = null
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
viewModelScope.launch { repository.deleteAll() }
|
||||
}
|
||||
|
||||
fun deleteMultiple(notifications: List<CapturedNotification>) {
|
||||
viewModelScope.launch {
|
||||
notifications.forEach { repository.deleteById(it.id) }
|
||||
}
|
||||
}
|
||||
|
||||
fun bookmarkNotification(id: Long, bookmarked: Boolean) {
|
||||
viewModelScope.launch { repository.setBookmarked(id, bookmarked) }
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
fun refreshLastSeen() {
|
||||
_lastSeenTimestamp.value = prefs.getLong(KEY_LAST_SEEN, 0L)
|
||||
}
|
||||
|
||||
fun markAsSeen() {
|
||||
val now = System.currentTimeMillis()
|
||||
prefs.edit().putLong(KEY_LAST_SEEN, now).apply()
|
||||
_lastSeenTimestamp.value = now
|
||||
}
|
||||
|
||||
fun toggleShowNewOnly() {
|
||||
_showNewOnly.value = !_showNewOnly.value
|
||||
}
|
||||
|
||||
fun activateSearch() { _searchActive.value = true }
|
||||
fun deactivateSearch() {
|
||||
_searchActive.value = false
|
||||
_searchQuery.value = ""
|
||||
}
|
||||
fun onSearchQueryChange(query: String) { _searchQuery.value = query }
|
||||
|
||||
fun setSortOrder(order: SortOrder) { _sortOrder.value = order }
|
||||
|
||||
fun showFilterSheet() { _filterSheetVisible.value = true }
|
||||
fun hideFilterSheet() { _filterSheetVisible.value = false }
|
||||
|
||||
fun toggleAppFilter(packageName: String) {
|
||||
val current = _filteredOutPackages.value
|
||||
_filteredOutPackages.value = if (packageName in current) current - packageName
|
||||
else current + packageName
|
||||
viewModelScope.launch {
|
||||
val isVisible = packageName !in _filteredOutPackages.value
|
||||
appFilterDao.insert(AppFilterEntity(packageName, isVisible))
|
||||
}
|
||||
}
|
||||
|
||||
fun selectAllApps() {
|
||||
_filteredOutPackages.value = emptySet()
|
||||
viewModelScope.launch { appFilterDao.deleteAll() }
|
||||
}
|
||||
|
||||
fun deselectAllApps() {
|
||||
val all = filterApps.value.map { it.packageName }.toSet()
|
||||
_filteredOutPackages.value = all
|
||||
viewModelScope.launch {
|
||||
appFilterDao.insertAll(all.map { AppFilterEntity(it, false) })
|
||||
}
|
||||
}
|
||||
|
||||
fun resolveAppName(packageName: String): String {
|
||||
return appNameCache.getOrPut(packageName) {
|
||||
try {
|
||||
val pm = context.packageManager
|
||||
val appInfo = pm.getApplicationInfo(packageName, 0)
|
||||
pm.getApplicationLabel(appInfo).toString()
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
packageName.substringAfterLast('.').replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resolveAppIcon(packageName: String): Drawable? {
|
||||
return appIconCache.getOrPut(packageName) {
|
||||
try { context.packageManager.getApplicationIcon(packageName) }
|
||||
catch (_: PackageManager.NameNotFoundException) { null }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_LAST_SEEN = "last_seen_timestamp"
|
||||
}
|
||||
}
|
||||
|
||||
data class FilterAppInfo(
|
||||
val packageName: String,
|
||||
val appName: String,
|
||||
val count: Int,
|
||||
val isVisible: Boolean,
|
||||
val icon: Drawable?
|
||||
)
|
||||
|
||||
sealed interface TimelineUiState {
|
||||
data object Loading : TimelineUiState
|
||||
data object Empty : TimelineUiState
|
||||
data class SearchEmpty(val query: String) : TimelineUiState
|
||||
data class Success(
|
||||
val notifications: List<CapturedNotification>,
|
||||
val newCount: Int,
|
||||
val lastSeenTimestamp: Long
|
||||
) : TimelineUiState
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package com.roundingmobile.notisaver.security
|
||||
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AppLockManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val prefs: SharedPreferences
|
||||
) {
|
||||
val isLockEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_LOCK_ENABLED, false)
|
||||
|
||||
val isDeviceSecure: Boolean
|
||||
get() {
|
||||
val km = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||
return km.isDeviceSecure
|
||||
}
|
||||
|
||||
var lastBackgroundTime: Long = 0L
|
||||
private set
|
||||
|
||||
val lockTimeoutMs: Long
|
||||
get() = prefs.getLong(KEY_LOCK_TIMEOUT, DEFAULT_TIMEOUT_MS)
|
||||
|
||||
fun enableLock() {
|
||||
prefs.edit().putBoolean(KEY_LOCK_ENABLED, true).apply()
|
||||
}
|
||||
|
||||
fun disableLock() {
|
||||
prefs.edit().putBoolean(KEY_LOCK_ENABLED, false).apply()
|
||||
}
|
||||
|
||||
fun onBackground() {
|
||||
lastBackgroundTime = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
fun onUnlocked() {
|
||||
lastBackgroundTime = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
fun shouldLock(): Boolean {
|
||||
if (!isLockEnabled) return false
|
||||
if (!isDeviceSecure) return false
|
||||
if (lastBackgroundTime == 0L) return true
|
||||
return System.currentTimeMillis() - lastBackgroundTime > lockTimeoutMs
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_LOCK_ENABLED = "lock_enabled_v2"
|
||||
private const val KEY_LOCK_TIMEOUT = "lock_timeout_ms"
|
||||
private const val DEFAULT_TIMEOUT_MS = 30_000L
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package com.roundingmobile.notisaver.security
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.SharedPreferences
|
||||
import android.service.notification.StatusBarNotification
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NotificationFilter @Inject constructor(
|
||||
private val prefs: SharedPreferences
|
||||
) {
|
||||
val filterSystemEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_FILTER_SYSTEM, true)
|
||||
|
||||
val collapseRapidEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_COLLAPSE_RAPID, true)
|
||||
|
||||
val collapseWindowMs: Long
|
||||
get() = prefs.getLong(KEY_COLLAPSE_WINDOW, 60_000L)
|
||||
|
||||
fun setFilterSystem(enabled: Boolean) {
|
||||
prefs.edit().putBoolean(KEY_FILTER_SYSTEM, enabled).apply()
|
||||
}
|
||||
|
||||
fun setCollapseRapid(enabled: Boolean) {
|
||||
prefs.edit().putBoolean(KEY_COLLAPSE_RAPID, enabled).apply()
|
||||
}
|
||||
|
||||
fun setCollapseWindow(ms: Long) {
|
||||
prefs.edit().putLong(KEY_COLLAPSE_WINDOW, ms).apply()
|
||||
}
|
||||
|
||||
fun shouldIgnore(sbn: StatusBarNotification): Boolean {
|
||||
if (!filterSystemEnabled) return false
|
||||
|
||||
// Auto-ignore packages
|
||||
if (sbn.packageName in AUTO_IGNORE_PACKAGES) return true
|
||||
|
||||
val notification = sbn.notification
|
||||
|
||||
val flags = notification.flags
|
||||
|
||||
// Ignore group summary notifications (e.g. WhatsApp sends both individual + summary)
|
||||
if (flags and Notification.FLAG_GROUP_SUMMARY != 0) return true
|
||||
|
||||
// Ignore ongoing/no-clear (charging, VPN, USB, media transport)
|
||||
if (flags and Notification.FLAG_ONGOING_EVENT != 0) return true
|
||||
if (flags and Notification.FLAG_NO_CLEAR != 0) return true
|
||||
|
||||
// Ignore spammy categories
|
||||
val category = notification.category
|
||||
if (category in IGNORE_CATEGORIES) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_FILTER_SYSTEM = "filter_system_notifications"
|
||||
const val KEY_COLLAPSE_RAPID = "collapse_rapid_updates"
|
||||
const val KEY_COLLAPSE_WINDOW = "collapse_window_ms"
|
||||
|
||||
val AUTO_IGNORE_PACKAGES = setOf(
|
||||
"android",
|
||||
"com.android.systemui",
|
||||
"com.android.providers.downloads",
|
||||
"com.android.vending", // Play Store update progress
|
||||
"com.android.bluetooth",
|
||||
"com.android.nfc",
|
||||
"com.android.shell",
|
||||
"com.android.server.telecom"
|
||||
)
|
||||
|
||||
val IGNORE_CATEGORIES = setOf(
|
||||
Notification.CATEGORY_PROGRESS,
|
||||
Notification.CATEGORY_TRANSPORT,
|
||||
Notification.CATEGORY_SERVICE,
|
||||
Notification.CATEGORY_SYSTEM,
|
||||
Notification.CATEGORY_STATUS
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
package com.roundingmobile.notisaver.service
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import android.util.Log
|
||||
import com.roundingmobile.notisaver.data.local.db.dao.HiddenAppDao
|
||||
import com.roundingmobile.notisaver.domain.model.FilterAction
|
||||
import com.roundingmobile.notisaver.domain.repository.FilterRuleRepository
|
||||
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
|
||||
import com.roundingmobile.notisaver.domain.usecase.FilterRuleEngine
|
||||
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
|
||||
import com.roundingmobile.notisaver.security.NotificationFilter
|
||||
import com.roundingmobile.notisaver.util.NotificationParser
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class NotificationCaptureService : NotificationListenerService() {
|
||||
|
||||
@Inject lateinit var repository: NotificationRepository
|
||||
@Inject lateinit var hiddenAppDao: HiddenAppDao
|
||||
@Inject lateinit var instrumentation: TestInstrumentation
|
||||
@Inject lateinit var filter: NotificationFilter
|
||||
@Inject lateinit var filterRuleRepository: FilterRuleRepository
|
||||
@Inject lateinit var filterRuleEngine: FilterRuleEngine
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
// In-memory dedup: notification key → timestamp of last processing
|
||||
private val recentKeys = ConcurrentHashMap<String, Long>()
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification) {
|
||||
if (sbn.packageName == packageName) return
|
||||
|
||||
// System/spam filter
|
||||
if (filter.shouldIgnore(sbn)) return
|
||||
|
||||
// Reject rapid-fire duplicate callbacks for the same notification
|
||||
val dedupKey = "${sbn.packageName}|${sbn.id}|${sbn.tag}"
|
||||
val now = System.currentTimeMillis()
|
||||
val lastSeen = recentKeys.put(dedupKey, now)
|
||||
if (lastSeen != null && now - lastSeen < DEDUP_WINDOW_MS) return
|
||||
|
||||
// Prune old entries periodically
|
||||
if (recentKeys.size > 100) {
|
||||
recentKeys.entries.removeAll { now - it.value > DEDUP_WINDOW_MS }
|
||||
}
|
||||
|
||||
serviceScope.launch {
|
||||
try {
|
||||
if (hiddenAppDao.isHidden(sbn.packageName)) return@launch
|
||||
|
||||
val appName = resolveAppName(sbn.packageName)
|
||||
val parsed = NotificationParser.parse(sbn, appName)
|
||||
|
||||
// Check user-defined filter rules
|
||||
val ruleAction = filterRuleEngine.evaluate(
|
||||
filterRuleRepository.getEnabledRules(),
|
||||
sbn.packageName, appName, parsed.title, parsed.text
|
||||
)
|
||||
if (ruleAction == FilterAction.SUPPRESS) {
|
||||
Log.d(TAG, "Suppressed by filter rule: pkg=${sbn.packageName} title=${parsed.title}")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Check for existing notification with same id+tag+package → update in place
|
||||
val previous = repository.findPrevious(
|
||||
parsed.notificationId, parsed.notificationTag, parsed.packageName
|
||||
)
|
||||
if (previous != null) {
|
||||
repository.update(previous.copy(
|
||||
text = parsed.text ?: previous.text,
|
||||
bigText = parsed.bigText ?: previous.bigText,
|
||||
timestamp = parsed.timestamp,
|
||||
isUpdate = true
|
||||
))
|
||||
instrumentation.reportEvent(
|
||||
"notification_updated",
|
||||
mapOf("package" to sbn.packageName, "id" to previous.id.toString())
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Collapse rapid updates: same package + same title within window
|
||||
if (filter.collapseRapidEnabled && !parsed.title.isNullOrBlank()) {
|
||||
val since = System.currentTimeMillis() - filter.collapseWindowMs
|
||||
val recent = repository.findRecentByPackageAndTitle(
|
||||
parsed.packageName, parsed.title, since
|
||||
)
|
||||
if (recent != null) {
|
||||
repository.update(recent.copy(
|
||||
text = parsed.text ?: recent.text,
|
||||
bigText = parsed.bigText ?: recent.bigText,
|
||||
timestamp = parsed.timestamp,
|
||||
isUpdate = true
|
||||
))
|
||||
instrumentation.reportEvent(
|
||||
"notification_collapsed",
|
||||
mapOf("package" to sbn.packageName, "title" to parsed.title, "id" to recent.id.toString())
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
val insertedId = repository.insert(parsed)
|
||||
instrumentation.reportEvent(
|
||||
"notification_captured",
|
||||
mapOf(
|
||||
"package" to sbn.packageName,
|
||||
"title" to (parsed.title ?: ""),
|
||||
"id" to insertedId.toString()
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error capturing notification", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationRemoved(sbn: StatusBarNotification) {
|
||||
if (sbn.packageName == packageName) return
|
||||
if (filter.shouldIgnore(sbn)) return
|
||||
|
||||
serviceScope.launch {
|
||||
try {
|
||||
val previous = repository.findPrevious(sbn.id, sbn.tag, sbn.packageName)
|
||||
if (previous != null) {
|
||||
val now = System.currentTimeMillis()
|
||||
val delayMs = now - previous.timestamp
|
||||
repository.markRemoved(previous.id, now, delayMs)
|
||||
instrumentation.reportEvent(
|
||||
"notification_removed",
|
||||
mapOf("id" to previous.id.toString(), "delay_ms" to delayMs.toString())
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error handling notification removal", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
serviceScope.cancel()
|
||||
}
|
||||
|
||||
private fun resolveAppName(packageName: String): String {
|
||||
return try {
|
||||
val pm = applicationContext.packageManager
|
||||
val appInfo = pm.getApplicationInfo(packageName, 0)
|
||||
pm.getApplicationLabel(appInfo).toString()
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
packageName
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NotificationCapture"
|
||||
private const val DEDUP_WINDOW_MS = 1000L
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
package com.roundingmobile.notisaver.util
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
enum class DateFormatOption(val label: String, val pattern: String?) {
|
||||
SYSTEM("System default", null),
|
||||
ISO("YYYY-MM-DD", "yyyy-MM-dd"),
|
||||
DMY_SLASH("DD/MM/YYYY", "dd/MM/yyyy"),
|
||||
MDY_SLASH("MM/DD/YYYY", "MM/dd/yyyy"),
|
||||
DMY_TEXT("DD MMM YYYY", "dd MMM yyyy"),
|
||||
DMY_DOT("DD.MM.YYYY", "dd.MM.yyyy");
|
||||
}
|
||||
|
||||
enum class TimeFormatOption(val label: String, val pattern: String?) {
|
||||
SYSTEM("System default", null),
|
||||
H24("24-hour", "HH:mm"),
|
||||
H12("12-hour", "h:mm a");
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class AppDateFormatter @Inject constructor(
|
||||
private val prefs: SharedPreferences
|
||||
) {
|
||||
val dateFormat: DateFormatOption
|
||||
get() = runCatching {
|
||||
DateFormatOption.valueOf(prefs.getString(KEY_DATE_FORMAT, DateFormatOption.SYSTEM.name)!!)
|
||||
}.getOrDefault(DateFormatOption.SYSTEM)
|
||||
|
||||
val timeFormat: TimeFormatOption
|
||||
get() = runCatching {
|
||||
TimeFormatOption.valueOf(prefs.getString(KEY_TIME_FORMAT, TimeFormatOption.SYSTEM.name)!!)
|
||||
}.getOrDefault(TimeFormatOption.SYSTEM)
|
||||
|
||||
fun setDateFormat(option: DateFormatOption) {
|
||||
prefs.edit().putString(KEY_DATE_FORMAT, option.name).apply()
|
||||
}
|
||||
|
||||
fun setTimeFormat(option: TimeFormatOption) {
|
||||
prefs.edit().putString(KEY_TIME_FORMAT, option.name).apply()
|
||||
}
|
||||
|
||||
fun formatTime(timestamp: Long): String {
|
||||
val fmt = when (val opt = timeFormat) {
|
||||
TimeFormatOption.SYSTEM -> DateFormat.getTimeInstance(DateFormat.SHORT)
|
||||
else -> SimpleDateFormat(opt.pattern, Locale.getDefault())
|
||||
}
|
||||
return fmt.format(Date(timestamp))
|
||||
}
|
||||
|
||||
fun formatDate(timestamp: Long): String {
|
||||
val fmt = when (val opt = dateFormat) {
|
||||
DateFormatOption.SYSTEM -> DateFormat.getDateInstance(DateFormat.MEDIUM)
|
||||
else -> SimpleDateFormat(opt.pattern, Locale.getDefault())
|
||||
}
|
||||
return fmt.format(Date(timestamp))
|
||||
}
|
||||
|
||||
fun formatDateTime(timestamp: Long): String {
|
||||
return "${formatDate(timestamp)} ${formatTime(timestamp)}"
|
||||
}
|
||||
|
||||
/** Format for timeline: time only if today, date otherwise */
|
||||
fun formatTimeline(timestamp: Long): String {
|
||||
val now = System.currentTimeMillis()
|
||||
val dayBoundary = dayBoundary(Calendar.getInstance())
|
||||
return if (timestamp >= dayBoundary) formatTime(timestamp)
|
||||
else formatDate(timestamp)
|
||||
}
|
||||
|
||||
/** Day header label: "Today", "Yesterday", or formatted date */
|
||||
fun dayHeaderLabel(dayKey: String, timestamp: Long): String {
|
||||
return when (dayKey) {
|
||||
"Today", "Yesterday" -> dayKey
|
||||
else -> {
|
||||
val cal = Calendar.getInstance().apply { timeInMillis = timestamp }
|
||||
val dayName = SimpleDateFormat("EEEE", Locale.getDefault()).format(Date(timestamp))
|
||||
val date = formatDate(timestamp)
|
||||
"$dayName $date"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun previewDate(): String = formatDate(System.currentTimeMillis())
|
||||
fun previewTime(): String = formatTime(System.currentTimeMillis())
|
||||
|
||||
companion object {
|
||||
const val KEY_DATE_FORMAT = "date_format"
|
||||
const val KEY_TIME_FORMAT = "time_format"
|
||||
|
||||
/** Day boundary is 2:00 AM, not midnight */
|
||||
private const val DAY_BOUNDARY_HOUR = 2
|
||||
|
||||
fun dayBoundary(cal: Calendar): Long {
|
||||
val c = cal.clone() as Calendar
|
||||
c.set(Calendar.HOUR_OF_DAY, DAY_BOUNDARY_HOUR)
|
||||
c.set(Calendar.MINUTE, 0)
|
||||
c.set(Calendar.SECOND, 0)
|
||||
c.set(Calendar.MILLISECOND, 0)
|
||||
// If current time is before 2 AM, the boundary is yesterday's 2 AM
|
||||
if (cal.get(Calendar.HOUR_OF_DAY) < DAY_BOUNDARY_HOUR) {
|
||||
c.add(Calendar.DAY_OF_YEAR, -1)
|
||||
}
|
||||
return c.timeInMillis
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
package com.roundingmobile.notisaver.util
|
||||
|
||||
import com.roundingmobile.notisaver.domain.model.CapturedNotification
|
||||
import java.util.Calendar
|
||||
|
||||
object DateTimeUtils {
|
||||
|
||||
private const val DAY_BOUNDARY_HOUR = 2
|
||||
private const val ONE_DAY = 24 * 60 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* Group notifications by day with 2 AM boundary.
|
||||
* Returns list of (dayKey, notifications) where dayKey is "Today", "Yesterday", or a date key.
|
||||
* Notifications within each day are ordered by timestamp descending.
|
||||
*/
|
||||
fun groupByDay(notifications: List<CapturedNotification>): List<DayGroup> {
|
||||
if (notifications.isEmpty()) return emptyList()
|
||||
|
||||
val now = Calendar.getInstance()
|
||||
val todayBoundary = AppDateFormatter.dayBoundary(now)
|
||||
val yesterdayBoundary = todayBoundary - ONE_DAY
|
||||
|
||||
val groups = linkedMapOf<String, MutableList<CapturedNotification>>()
|
||||
|
||||
for (notification in notifications) {
|
||||
val label = when {
|
||||
notification.timestamp >= todayBoundary -> "Today"
|
||||
notification.timestamp >= yesterdayBoundary -> "Yesterday"
|
||||
else -> dayKeyFor(notification.timestamp)
|
||||
}
|
||||
groups.getOrPut(label) { mutableListOf() }.add(notification)
|
||||
}
|
||||
|
||||
return groups.map { (key, items) ->
|
||||
DayGroup(
|
||||
key = key,
|
||||
notifications = items.toList(),
|
||||
representativeTimestamp = items.first().timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Within a day's notifications (ordered by time), group consecutive same-app notifications.
|
||||
* Only groups of 2+ get collapsed. Single items stay individual.
|
||||
*/
|
||||
fun groupConsecutiveByApp(notifications: List<CapturedNotification>): List<ConsecutiveGroup> {
|
||||
if (notifications.isEmpty()) return emptyList()
|
||||
|
||||
val result = mutableListOf<ConsecutiveGroup>()
|
||||
var currentPkg = notifications[0].packageName
|
||||
var currentGroup = mutableListOf(notifications[0])
|
||||
|
||||
for (i in 1 until notifications.size) {
|
||||
val n = notifications[i]
|
||||
if (n.packageName == currentPkg) {
|
||||
currentGroup.add(n)
|
||||
} else {
|
||||
result.add(ConsecutiveGroup(currentGroup.toList()))
|
||||
currentPkg = n.packageName
|
||||
currentGroup = mutableListOf(n)
|
||||
}
|
||||
}
|
||||
result.add(ConsecutiveGroup(currentGroup.toList()))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun dayKeyFor(timestamp: Long): String {
|
||||
val cal = Calendar.getInstance().apply { timeInMillis = timestamp }
|
||||
// Shift back if before 2 AM (belongs to previous day)
|
||||
if (cal.get(Calendar.HOUR_OF_DAY) < DAY_BOUNDARY_HOUR) {
|
||||
cal.add(Calendar.DAY_OF_YEAR, -1)
|
||||
}
|
||||
return "day_${cal.get(Calendar.YEAR)}_${cal.get(Calendar.DAY_OF_YEAR)}"
|
||||
}
|
||||
|
||||
// Keep old method for backward compat
|
||||
fun groupByTimeBlock(notifications: List<CapturedNotification>): List<Pair<String, List<CapturedNotification>>> {
|
||||
return groupByDay(notifications).map { it.key to it.notifications }
|
||||
}
|
||||
}
|
||||
|
||||
data class DayGroup(
|
||||
val key: String,
|
||||
val notifications: List<CapturedNotification>,
|
||||
val representativeTimestamp: Long
|
||||
)
|
||||
|
||||
data class ConsecutiveGroup(
|
||||
val notifications: List<CapturedNotification>
|
||||
) {
|
||||
val isGroup: Boolean get() = notifications.size > 1
|
||||
val count: Int get() = notifications.size
|
||||
val latest: CapturedNotification get() = notifications.first()
|
||||
val packageName: String get() = latest.packageName
|
||||
val appName: String get() = latest.appName
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package com.roundingmobile.notisaver.util
|
||||
|
||||
import android.app.Notification
|
||||
import android.service.notification.StatusBarNotification
|
||||
import com.roundingmobile.notisaver.domain.model.CapturedNotification
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object NotificationParser {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun parse(sbn: StatusBarNotification, appName: String): CapturedNotification {
|
||||
val notification = sbn.notification
|
||||
val extras = notification.extras
|
||||
|
||||
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
|
||||
val text = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString()
|
||||
val bigText = extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString()
|
||||
val category = notification.category
|
||||
|
||||
val actions = notification.actions?.map { it.title.toString() } ?: emptyList()
|
||||
val actionsJson = if (actions.isNotEmpty()) json.encodeToString(actions) else null
|
||||
|
||||
val extrasMap = buildMap<String, String> {
|
||||
extras.keySet().forEach { key ->
|
||||
try {
|
||||
val value = extras.get(key)
|
||||
if (value != null && value !is android.graphics.Bitmap && value !is android.os.Parcelable) {
|
||||
put(key, value.toString())
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Skip unreadable extras
|
||||
}
|
||||
}
|
||||
}
|
||||
val extrasJson = if (extrasMap.isNotEmpty()) json.encodeToString(extrasMap) else null
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
return CapturedNotification(
|
||||
id = 0,
|
||||
packageName = sbn.packageName,
|
||||
appName = appName,
|
||||
title = title,
|
||||
text = text,
|
||||
bigText = bigText,
|
||||
category = category,
|
||||
priority = notification.priority,
|
||||
timestamp = sbn.postTime,
|
||||
notificationId = sbn.id,
|
||||
notificationTag = sbn.tag,
|
||||
isUpdate = false,
|
||||
previousVersionId = null,
|
||||
isRemoved = false,
|
||||
removedAt = null,
|
||||
removalDelayMs = null,
|
||||
isBookmarked = false,
|
||||
extrasJson = extrasJson,
|
||||
actionsJson = actionsJson,
|
||||
iconUri = null,
|
||||
createdAt = now
|
||||
)
|
||||
}
|
||||
}
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 517 B |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 996 B |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 337 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 744 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 687 B |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |