Compare commits
11 Commits
2c08af7541
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c6f0bc2c7 | |||
| 9114b3189e | |||
| 276d2f2615 | |||
| 8c0cbac246 | |||
| 82890d85ba | |||
| 6a87a33ea0 | |||
| 2b554dc227 | |||
| 2169f28632 | |||
| 21aed4f682 | |||
| b58f745fbc | |||
| d0311e7632 |
50
.claude/agent-memory/MEMORY.md
Normal file
50
.claude/agent-memory/MEMORY.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Fluffytrix Android Bug Hunter Memory
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- Messages stored descending (newest at index 0) for `reverseLayout = true` LazyColumn
|
||||||
|
- Thread replies filtered from main timeline into `threadMessageCache[roomId][threadRootEventId]`
|
||||||
|
- Thread detection: parse `content.m.relates_to.rel_type == "m.thread"` from raw JSON via `eventItem.lazyProvider.debugInfo().originalJson` (must be in try/catch)
|
||||||
|
- Space hierarchy: top-level spaces → child spaces → rooms. Orphan rooms = rooms not in any space
|
||||||
|
- Static channel ordering enforced via `_channelOrderMap` (DataStore-persisted)
|
||||||
|
- `MainViewModel` uses `ProcessLifecycleOwner` observer to pause/resume sync on app background/foreground
|
||||||
|
- `AuthRepository` holds `matrixClient` and `syncService` as plain `var` (not thread-safe, accessed from IO threads)
|
||||||
|
|
||||||
|
## Recurring Bug Patterns
|
||||||
|
|
||||||
|
### Threading / Coroutines
|
||||||
|
- `loadMoreMessages()` launches on `Dispatchers.Default` (omits dispatcher), so `timeline.paginateBackwards()` runs on main thread — SDK calls must use `Dispatchers.IO`
|
||||||
|
- `TimelineListener.onUpdate` launches `viewModelScope.launch(Dispatchers.Default)` — fine for CPU work, but the mutex-protected list manipulation inside is correct
|
||||||
|
- `processRooms` uses `withContext(Dispatchers.Default)` for CPU-heavy room processing — correct pattern
|
||||||
|
- `rebuildThreadList` and `updateThreadMessagesView` called from within `Dispatchers.Default` coroutine (inside `onUpdate`) — these write to `_roomThreads` and `_threadMessages` StateFlows, which is safe from any thread
|
||||||
|
|
||||||
|
### Memory Leaks
|
||||||
|
- `messageCache`, `messageIds`, `memberCache`, `threadMessageCache` are plain `mutableMapOf` on ViewModel — accessed from multiple coroutines without synchronization (race condition potential)
|
||||||
|
- `senderAvatarCache` and `senderNameCache` similarly unsynchronized
|
||||||
|
- `activeTimeline` is written from `Dispatchers.IO` coroutine and read from `Dispatchers.Default` in the listener — not volatile/synchronized
|
||||||
|
|
||||||
|
### Compose
|
||||||
|
- `rememberLazyListState()` in `MessageTimeline` is recreated on channel/thread switch — loses scroll position. Should be keyed per channel or held in ViewModel
|
||||||
|
- `collectAsState()` without `repeatOnLifecycle` in `MainScreen` — acceptable since `collectAsState` internally uses `repeatOnLifecycle(STARTED)` in Compose lifecycle-runtime
|
||||||
|
- Thread items in `ChannelList` rendered with `for` loop inside `LazyColumn` items block — not using `item(key=)` for thread rows, causing missed optimizations but not a correctness bug
|
||||||
|
|
||||||
|
### Visual
|
||||||
|
- `senderColors` array contains hardcoded hex colors — violates Material You convention but is intentional Discord-style sender coloring (acceptable)
|
||||||
|
- `Color.White` used directly in `VideoContent` play button and fullscreen viewers — minor Material You violation but acceptable for media overlays
|
||||||
|
|
||||||
|
### Data Correctness
|
||||||
|
- `colorForSender`: `name.hashCode().ushr(1) % senderColors.size` — `hashCode()` can be negative; `ushr(1)` makes it non-negative, so modulo is safe. Correct.
|
||||||
|
- Binary search in `processEventItem` for descending insert: comparator is `msg.timestamp.compareTo(it.timestamp)` — this inserts newer messages at lower indices (ascending by timestamp reversed). For `reverseLayout` this is correct.
|
||||||
|
- Thread binary search uses same comparator — threads stored ascending by timestamp, which for `reverseLayout` is correct (newest at index 0 visually).
|
||||||
|
- `sendThreadMessage` sends as plain message without thread relation — documented known limitation/TODO in code
|
||||||
|
|
||||||
|
### Build
|
||||||
|
- `isMinifyEnabled = true` in debug build — unusual, slows debug builds and can make debugging harder, but not a bug per se
|
||||||
|
- `kotlin = "2.2.10"` in version catalog — check that this is a valid release (2.2.0 is latest as of mid-2025; 2.2.10 may be typo for 2.2.0 or future patch)
|
||||||
|
|
||||||
|
## Key File Paths
|
||||||
|
- ViewModel: `app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt`
|
||||||
|
- Main screen: `app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt`
|
||||||
|
- Message timeline: `app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt`
|
||||||
|
- Channel list: `app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt`
|
||||||
|
- Auth repo: `app/src/main/java/com/example/fluffytrix/data/repository/AuthRepository.kt`
|
||||||
|
- Preferences: `app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt`
|
||||||
53
.claude/agent-memory/android-bug-hunter/MEMORY.md
Normal file
53
.claude/agent-memory/android-bug-hunter/MEMORY.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Android Bug Hunter — Project Memory
|
||||||
|
|
||||||
|
## Recurring Patterns Found
|
||||||
|
|
||||||
|
### NotificationHelper: runBlocking on Service Thread
|
||||||
|
`NotificationHelper.show()` calls `runBlocking` three times (notificationsEnabled, mutedRooms, roomNameCache).
|
||||||
|
This blocks the `PushService` callback thread. Fix: pass pre-collected values in or make `show()` a suspend fun.
|
||||||
|
See: `push/NotificationHelper.kt`
|
||||||
|
|
||||||
|
### FluffytrixPushService: Leaking CoroutineScope
|
||||||
|
`FluffytrixPushService` creates its own `CoroutineScope(SupervisorJob() + Dispatchers.IO)` but never cancels
|
||||||
|
it when the service is destroyed. Coroutines launched from `onNewEndpoint`/`onUnregistered` can outlive the
|
||||||
|
service. Fix: cancel scope in `onDestroy()`.
|
||||||
|
|
||||||
|
### PushRegistrationManager: OkHttp response body not closed
|
||||||
|
`registerPusher()` stores `response.body?.string()` (which auto-closes), but the `execute()` call itself is
|
||||||
|
never inside a `use {}` block — if `body?.string()` throws, the response is not closed.
|
||||||
|
Fix: wrap in `response.use { ... }`.
|
||||||
|
|
||||||
|
### build.gradle.kts: API key in plain BuildConfig
|
||||||
|
`TENOR_API_KEY` hardcoded in `buildConfigField` — visible in plaintext in the APK's BuildConfig class.
|
||||||
|
|
||||||
|
### MainViewModel: `onUpdate` listener launches on `Dispatchers.Default` inside `viewModelScope`
|
||||||
|
The timeline listener's `onUpdate` callback launches a coroutine with `viewModelScope.launch(Dispatchers.Default)`.
|
||||||
|
If the ViewModel is cleared mid-flight the scope is cancelled but the `onUpdate` lambda (captured by the
|
||||||
|
SDK via JNI) can still fire and attempt `launch` on a cancelled scope — harmless but noisy.
|
||||||
|
|
||||||
|
### MainViewModel: `CoroutineScope` inside `loadTimeline` shared across closures
|
||||||
|
A `Mutex` local to `loadTimeline` is captured by the `TimelineListener` lambda. When `loadTimeline` is called
|
||||||
|
again for a new room, the old listener still holds the old mutex — correct, but produces confusing parallelism.
|
||||||
|
|
||||||
|
### MainViewModel: `sendGif` creates a new OkHttpClient per call
|
||||||
|
A brand-new `OkHttpClient()` is constructed inside `sendGif` on every invocation. Should reuse a shared
|
||||||
|
client (like the one in `PushRegistrationManager`).
|
||||||
|
|
||||||
|
### SettingsScreen: new OkHttpClient per API-key test
|
||||||
|
A new `OkHttpClient()` is created on every "Save" button press inside `SettingsScreen`. Leaks the connection
|
||||||
|
pool after the composable is disposed.
|
||||||
|
|
||||||
|
### DeepLinkState: global singleton StateFlow — never clears on logout
|
||||||
|
`DeepLinkState` is a process-level `object`. After logout/re-login the stale `pendingRoomId` can navigate
|
||||||
|
the new session to the old room.
|
||||||
|
|
||||||
|
### PreferencesManager: `notificationsEnabled` default logic is inverted-looking
|
||||||
|
`prefs[KEY_NOTIFICATIONS_ENABLED] != false` — treats missing key as `true` (desired), but any corruption
|
||||||
|
storing a non-boolean type would also return `true`. Low risk but worth noting.
|
||||||
|
|
||||||
|
## Key File Paths
|
||||||
|
- Push infra: `app/src/main/java/com/example/fluffytrix/push/`
|
||||||
|
- ViewModel: `app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt`
|
||||||
|
- Settings UI: `app/src/main/java/com/example/fluffytrix/ui/screens/settings/SettingsScreen.kt`
|
||||||
|
- DI: `app/src/main/java/com/example/fluffytrix/di/AppModule.kt`
|
||||||
|
- Prefs: `app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt`
|
||||||
20
.claude/agent-memory/ui-ux-reviewer/MEMORY.md
Normal file
20
.claude/agent-memory/ui-ux-reviewer/MEMORY.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# UI/UX Reviewer Memory — Fluffytrix
|
||||||
|
|
||||||
|
See `patterns.md` for detailed design conventions.
|
||||||
|
|
||||||
|
## Key Conventions (short form)
|
||||||
|
- Spacing rhythm: 4/6/8/10/12/16/24/32dp — no odd values
|
||||||
|
- Channel list item padding: horizontal 12dp, vertical 10dp
|
||||||
|
- Settings screen row padding: vertical 6dp (info rows), vertical 12dp (nav rows)
|
||||||
|
- Icon size in lists: 20dp (channel icons, drag handles); 32dp (member avatars)
|
||||||
|
- Space sidebar icons: 48dp touch target, 64dp column width
|
||||||
|
- Color tokens: always MaterialTheme.colorScheme — EXCEPT senderColors array (Discord palette, intentional)
|
||||||
|
- Two hardcoded colors in SpaceList unread dots: `Color.Red` and `Color.Gray` — inconsistent with ChannelList which uses `colorScheme.error` and `colorScheme.primary`
|
||||||
|
- Typography: titleMedium for section headers in panels; labelMedium uppercase for channel sections; bodyLarge for channel names; bodyMedium for settings labels; bodySmall for subtitles
|
||||||
|
- Icon style: filled (Icons.Default.*) throughout — AutoMirrored used for directional icons
|
||||||
|
- Scaffold used in SettingsScreen with TopAppBar; MainScreen Scaffold has no topBar (custom layout)
|
||||||
|
- `collectAsStateWithLifecycle` used in MainScreen; `collectAsState` used in SettingsScreen — inconsistent
|
||||||
|
- MainScreen uses `@Immutable` data class for ProfileSheetState (good practice)
|
||||||
|
- Drag gesture threshold: 60f px for swipe-open/close channel list
|
||||||
|
- LazyListState for channel list is owned by ViewModel (correct — survives recomposition)
|
||||||
|
- IME insets: enableEdgeToEdge() is active; Scaffold default contentWindowInsets excludes IME; MessageTimeline Column must carry `.imePadding()` before static padding — without it the keyboard overlays the input bar. AndroidView EditText does not auto-participate in Compose IME avoidance. Never use windowSoftInputMode=adjustResize with edge-to-edge.
|
||||||
40
.claude/agent-memory/ui-ux-reviewer/patterns.md
Normal file
40
.claude/agent-memory/ui-ux-reviewer/patterns.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Fluffytrix UI Patterns
|
||||||
|
|
||||||
|
## Layout Structure
|
||||||
|
- MainScreen: Scaffold (no topBar) → Box → Row[SpaceList | MessageTimeline | MemberList?]
|
||||||
|
- Channel list overlays via AnimatedVisibility + zIndex(1f), slides from left
|
||||||
|
- SpaceList: 64dp wide, surfaceVariant background, icons 48dp
|
||||||
|
- ChannelList: fills width, surface background, max ~280dp visually
|
||||||
|
- MemberList: 240dp wide, surface background
|
||||||
|
- SettingsScreen: Scaffold + TopAppBar + Column + verticalScroll
|
||||||
|
|
||||||
|
## Spacing Rhythm
|
||||||
|
- Standard horizontal content padding: 16dp (settings, channel list header)
|
||||||
|
- Channel list items: horizontal 12dp, vertical 10dp
|
||||||
|
- Thread sub-items: start indent 24dp, horizontal 10dp, vertical 6dp
|
||||||
|
- Member list items: horizontal 8dp, vertical 4dp
|
||||||
|
- Settings rows: vertical 6dp (info/toggle), vertical 12dp (nav)
|
||||||
|
- Section dividers: padding(vertical = 16.dp)
|
||||||
|
|
||||||
|
## Color Token Usage
|
||||||
|
- Surface backgrounds: `colorScheme.surface` or `colorScheme.surfaceVariant`
|
||||||
|
- Selected item bg: `colorScheme.primaryContainer`; text: `colorScheme.onPrimaryContainer`
|
||||||
|
- Unread dot in ChannelList: error (mention) / primary (unread) — correct
|
||||||
|
- Unread dot in SpaceList: hardcoded `Color.Red` / `Color.Gray` — BUG, inconsistent
|
||||||
|
- Section text: `colorScheme.onSurface.copy(alpha = 0.7f)` uppercase labelMedium
|
||||||
|
- Disabled/subtle text: `colorScheme.onSurfaceVariant` or with `.copy(alpha = 0.6f)`
|
||||||
|
|
||||||
|
## Icon Conventions
|
||||||
|
- All icons: Icons.Default.* (filled style)
|
||||||
|
- Directional icons: Icons.AutoMirrored.Filled.*
|
||||||
|
- Icon size in list rows: 20dp
|
||||||
|
- All icons in touch targets: wrapped in IconButton (48dp auto-sizing) or clickable with min 48dp
|
||||||
|
|
||||||
|
## Notification (push/) — no Compose UI
|
||||||
|
- NotificationHelper: uses `R.mipmap.ic_launcher` as small icon (should be `R.drawable` monochrome)
|
||||||
|
- Channel: "messages" / IMPORTANCE_DEFAULT
|
||||||
|
- Tap → deep link via Intent extra "roomId" → DeepLinkState.set()
|
||||||
|
|
||||||
|
## State Collection
|
||||||
|
- MainScreen: collectAsStateWithLifecycle (correct, lifecycle-aware)
|
||||||
|
- SettingsScreen: collectAsState (should be collectAsStateWithLifecycle for consistency)
|
||||||
144
.claude/agents/android-bug-hunter.md
Normal file
144
.claude/agents/android-bug-hunter.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
---
|
||||||
|
name: android-bug-hunter
|
||||||
|
description: "Use this agent when Android Kotlin code has been written or modified and needs to be reviewed for bugs, performance issues, visual glitches, and incorrect Android lifecycle/behavior. This agent should be invoked proactively after significant Kotlin/Android code changes, especially for UI components, background services, coroutines, or Matrix SDK integration.\\n\\n<example>\\nContext: The user has just written a new composable screen that displays Matrix chat messages with a background sync service.\\nuser: 'I just wrote the MessageListScreen composable and the background sync worker, can you check it?'\\nassistant: 'Let me launch the android-bug-hunter agent to review the newly written code for bugs, performance issues, and Android-specific problems.'\\n<commentary>\\nSince new Android/Kotlin code was written involving UI and background processing, use the Task tool to launch the android-bug-hunter agent to review it.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: The user has implemented a new feature involving coroutines and state management in the Fluffytrix Matrix client.\\nuser: 'Here is my implementation of the thread message caching and timeline filtering'\\nassistant: 'I will now invoke the android-bug-hunter agent to inspect this code for coroutine misuse, memory leaks, recomposition issues, and any Android lifecycle violations.'\\n<commentary>\\nSince a significant piece of Kotlin/Android code was written, use the Task tool to launch the android-bug-hunter agent to catch bugs before they ship.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: User notices the app behaves oddly when backgrounded.\\nuser: 'The Matrix sync keeps running after I close the app and it drains the battery'\\nassistant: 'I will use the android-bug-hunter agent to locate where background work is not properly scoped or cancelled.'\\n<commentary>\\nThis is a classic Android lifecycle/background process bug — use the android-bug-hunter agent to diagnose and fix it.\\n</commentary>\\n</example>"
|
||||||
|
model: sonnet
|
||||||
|
memory: project
|
||||||
|
---
|
||||||
|
|
||||||
|
You are an elite Android engineer and Kotlin expert with deep specialization in finding and fixing bugs across all layers of Android development. You have mastered the Android lifecycle, Jetpack Compose recomposition model, Kotlin coroutines, memory management, and the nuances of background processing on Android. You are intimately familiar with this project: Fluffytrix, a Matrix chat client with a Discord-like UI built with Kotlin, Jetpack Compose, Material You (Material 3), and the Matrix Rust SDK (`org.matrix.rustcomponents:sdk-android`), targeting Android 14+ (minSdk 34).
|
||||||
|
|
||||||
|
## Your Mission
|
||||||
|
|
||||||
|
You will review recently written or modified Kotlin/Android code and identify **all** bugs — no matter how small — across these categories:
|
||||||
|
|
||||||
|
### 1. Performance Bugs
|
||||||
|
- Unnecessary recompositions in Jetpack Compose (unstable parameters, missing `remember`, missing `key`, lambda captures causing restarts)
|
||||||
|
- Blocking the main thread (IO/network on UI thread, synchronous SDK calls)
|
||||||
|
- Inefficient `LazyColumn`/`LazyRow` usage (missing `key` lambdas, excessive item recomposition)
|
||||||
|
- Memory leaks: coroutines not cancelled on lifecycle end, static references to Context/Activity/View, `ViewModel` holding Activity references
|
||||||
|
- Repeated expensive computations that should be cached or memoized
|
||||||
|
- Inefficient list diffing — prefer `DiffUtil`, `toImmutableList()`, stable state holders
|
||||||
|
- Excessive object allocation in hot paths (render loops, scroll callbacks)
|
||||||
|
|
||||||
|
### 2. Android Lifecycle & Background Process Bugs
|
||||||
|
- Work that continues after the app is backgrounded or the user leaves — coroutines launched in wrong scope (e.g., `GlobalScope` instead of `viewModelScope` or `lifecycleScope`)
|
||||||
|
- Services not properly stopped or unbound
|
||||||
|
- `WorkManager` tasks not respecting battery/network constraints
|
||||||
|
- Receivers not unregistered, observers not removed
|
||||||
|
- `repeatOnLifecycle` missing where `collect` is called directly in `lifecycleScope.launch` (causing collection in background)
|
||||||
|
- Missing `Lifecycle.State.STARTED` or `RESUMED` guards on UI-bound collectors
|
||||||
|
- Fragment/Activity back-stack leaks
|
||||||
|
|
||||||
|
### 3. Visual / UI Bugs
|
||||||
|
- Compose state that doesn't survive recomposition (missing `remember`/`rememberSaveable`)
|
||||||
|
- Hardcoded colors/dimensions that break Material You dynamic theming
|
||||||
|
- Missing content descriptions for accessibility
|
||||||
|
- Layout clipping, incorrect padding/margin stacking in Compose
|
||||||
|
- Dark/light theme inconsistencies
|
||||||
|
- Incorrect `Modifier` ordering (e.g., `clickable` before `padding` or vice versa causing wrong touch target)
|
||||||
|
- Text overflow not handled (`overflow = TextOverflow.Ellipsis` missing)
|
||||||
|
- Incorrect use of `fillMaxSize` vs `wrapContentSize` causing invisible or overlapping composables
|
||||||
|
|
||||||
|
### 4. Feature Correctness Bugs
|
||||||
|
- Off-by-one errors in message list indexing (note: messages stored in descending order, newest at index 0, for `reverseLayout` LazyColumn)
|
||||||
|
- Thread detection logic: verifying `content.m.relates_to.rel_type == "m.thread"` parsed correctly from raw JSON via `eventItem.lazyProvider.debugInfo().originalJson` using `org.json.JSONObject`
|
||||||
|
- Thread replies correctly filtered from main timeline and stored in `threadMessageCache`
|
||||||
|
- Null safety violations — unguarded `!!` operators, unsafe casts
|
||||||
|
- Race conditions in coroutine/state updates
|
||||||
|
- StateFlow/SharedFlow not properly initialized, cold vs hot flow confusion
|
||||||
|
- Incorrect `equals`/`hashCode` on data classes used as Compose keys or in `DiffUtil`
|
||||||
|
- SDK calls not wrapped in try/catch where exceptions are expected (e.g., `eventItem.lazyProvider.debugInfo()` which should be wrapped per project convention)
|
||||||
|
- Matrix Rust SDK threading requirements violated (SDK calls on wrong dispatcher)
|
||||||
|
|
||||||
|
### 5. Kotlin-Specific Bugs
|
||||||
|
- Mutable state escaping immutable interfaces
|
||||||
|
- `lateinit var` used where nullable or constructor injection is safer
|
||||||
|
- Improper delegation or extension function scoping
|
||||||
|
- `suspend` functions called from non-suspend context without proper wrapping
|
||||||
|
- Incorrect `withContext` usage — ensure IO-bound work uses `Dispatchers.IO`, CPU-bound uses `Dispatchers.Default`
|
||||||
|
- Missing `@Stable` or `@Immutable` annotations on Compose parameter classes causing unnecessary recompositions
|
||||||
|
|
||||||
|
## Review Methodology
|
||||||
|
|
||||||
|
1. **Read the full diff/code** presented to you before making any judgments.
|
||||||
|
2. **Categorize each bug** you find into one of the categories above.
|
||||||
|
3. **Assess severity**: Critical (crashes/data loss), High (feature broken, battery drain), Medium (visual glitch, perf drop), Low (minor inefficiency).
|
||||||
|
4. **For each bug**:
|
||||||
|
- Quote the problematic code snippet
|
||||||
|
- Explain precisely why it is a bug and what the impact is
|
||||||
|
- Provide the corrected code
|
||||||
|
5. **Do not report false positives** — if you are unsure, say so and explain your uncertainty rather than flagging it as a definite bug.
|
||||||
|
6. **Apply fixes directly** when asked, or present them clearly for the developer to apply.
|
||||||
|
7. **Verify your fixes** do not introduce new bugs — cross-check interactions with the rest of the described system.
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Structure your response as:
|
||||||
|
|
||||||
|
```
|
||||||
|
## Bug Report — [File/Component Name]
|
||||||
|
|
||||||
|
### [SEVERITY] [CATEGORY]: [Short Title]
|
||||||
|
**Location**: `FileName.kt` line N
|
||||||
|
**Problem**: [Precise explanation]
|
||||||
|
**Impact**: [What breaks, when, for whom]
|
||||||
|
**Fix**:
|
||||||
|
```kotlin
|
||||||
|
// corrected code
|
||||||
|
```
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
End with a **Summary** listing total bugs found by severity and a brief overall assessment of the code quality.
|
||||||
|
|
||||||
|
## Project-Specific Conventions to Enforce
|
||||||
|
- JDK 17 is required; do not suggest JDK 11-incompatible features but do use JDK 17 features freely
|
||||||
|
- Package: `com.example.fluffytrix`
|
||||||
|
- Build: Gradle Kotlin DSL, version catalog at `gradle/libs.versions.toml`
|
||||||
|
- All SDK calls to `eventItem.lazyProvider.debugInfo()` must be wrapped in try/catch
|
||||||
|
- Static channel ordering must never be broken by auto-sort logic
|
||||||
|
- Material 3 dynamic colors must be used — no hardcoded color hex values in UI code
|
||||||
|
- Jetpack Compose is the only UI framework — no XML layouts should be introduced
|
||||||
|
|
||||||
|
**Update your agent memory** as you discover recurring bug patterns, architectural anti-patterns, unstable Compose parameters, problematic SDK usage, and common mistakes in this codebase. This builds institutional knowledge across conversations.
|
||||||
|
|
||||||
|
Examples of what to record:
|
||||||
|
- Recurring misuse of coroutine scopes in specific ViewModels
|
||||||
|
- Compose classes missing `@Stable`/`@Immutable` annotations
|
||||||
|
- Patterns where Matrix Rust SDK calls are incorrectly called on the main thread
|
||||||
|
- Common off-by-one mistakes in the descending message list logic
|
||||||
|
- Any places where background work was found not properly scoped
|
||||||
|
|
||||||
|
# Persistent Agent Memory
|
||||||
|
|
||||||
|
You have a persistent Persistent Agent Memory directory at `/home/mrfluffy/Documents/projects/Android/fluffytrix/.claude/agent-memory/android-bug-hunter/`. Its contents persist across conversations.
|
||||||
|
|
||||||
|
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
|
||||||
|
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
|
||||||
|
- Update or remove memories that turn out to be wrong or outdated
|
||||||
|
- Organize memory semantically by topic, not chronologically
|
||||||
|
- Use the Write and Edit tools to update your memory files
|
||||||
|
|
||||||
|
What to save:
|
||||||
|
- Stable patterns and conventions confirmed across multiple interactions
|
||||||
|
- Key architectural decisions, important file paths, and project structure
|
||||||
|
- User preferences for workflow, tools, and communication style
|
||||||
|
- Solutions to recurring problems and debugging insights
|
||||||
|
|
||||||
|
What NOT to save:
|
||||||
|
- Session-specific context (current task details, in-progress work, temporary state)
|
||||||
|
- Information that might be incomplete — verify against project docs before writing
|
||||||
|
- Anything that duplicates or contradicts existing CLAUDE.md instructions
|
||||||
|
- Speculative or unverified conclusions from reading a single file
|
||||||
|
|
||||||
|
Explicit user requests:
|
||||||
|
- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions
|
||||||
|
- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files
|
||||||
|
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
|
||||||
|
|
||||||
|
## MEMORY.md
|
||||||
|
|
||||||
|
Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time.
|
||||||
124
.claude/agents/ui-ux-reviewer.md
Normal file
124
.claude/agents/ui-ux-reviewer.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
---
|
||||||
|
name: ui-ux-reviewer
|
||||||
|
description: "Use this agent when UI components, screens, or navigation flows are added or modified. It reviews Jetpack Compose code for visual consistency, Material 3 adherence, and user experience quality.\\n\\nExamples:\\n\\n- User: \"Add a settings screen with toggle options\"\\n Assistant: *writes the settings screen composable*\\n \"Now let me use the ui-ux-reviewer agent to review the new screen for consistency and UX quality.\"\\n *launches ui-ux-reviewer agent via Task tool*\\n\\n- User: \"Update the channel list to show unread badges\"\\n Assistant: *implements unread badge composable and integrates it*\\n \"Let me run the ui-ux-reviewer agent to ensure the badges are consistent with the rest of the app's design language.\"\\n *launches ui-ux-reviewer agent via Task tool*\\n\\n- User: \"Fix the message input bar layout\"\\n Assistant: *modifies the input bar composable*\\n \"I'll use the ui-ux-reviewer agent to verify the layout changes maintain consistency and good UX.\"\\n *launches ui-ux-reviewer agent via Task tool*"
|
||||||
|
model: sonnet
|
||||||
|
memory: project
|
||||||
|
---
|
||||||
|
|
||||||
|
You are an elite UI/UX reviewer specializing in Android Jetpack Compose applications with Material 3 / Material You theming. You have deep expertise in building consistent, accessible, and intuitive interfaces. Your particular strength is Discord-like chat application layouts.
|
||||||
|
|
||||||
|
## Your Role
|
||||||
|
|
||||||
|
You review recently written or modified UI code to ensure visual consistency across the entire app and a smooth, intuitive user experience. You do NOT rewrite the whole codebase — you focus on the recently changed files and check them against established patterns.
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
This is Fluffytrix, an Android Matrix chat client with a Discord-like UI:
|
||||||
|
- Jetpack Compose UI with Material 3 dynamic colors
|
||||||
|
- Discord-like layout: space sidebar → channel list → message area → member list
|
||||||
|
- Package: `com.example.fluffytrix`
|
||||||
|
- Target: Android 14+ (minSdk 34)
|
||||||
|
|
||||||
|
## Review Process
|
||||||
|
|
||||||
|
1. **Identify changed/new UI files** — focus your review on recently modified composables and screens.
|
||||||
|
|
||||||
|
2. **Check consistency** by examining existing UI patterns in the codebase:
|
||||||
|
- Read several existing screens/components to establish the baseline patterns
|
||||||
|
- Compare the new code against those patterns
|
||||||
|
- Look for: padding values, color usage, typography styles, icon sizing, elevation, shape/corner radius, spacing rhythm
|
||||||
|
|
||||||
|
3. **Evaluate UX quality**:
|
||||||
|
- Touch target sizes (minimum 48dp)
|
||||||
|
- Loading states — are they present where needed?
|
||||||
|
- Error states — are they handled gracefully?
|
||||||
|
- Empty states — do lists show meaningful empty content?
|
||||||
|
- Navigation clarity — is it obvious how to go back or proceed?
|
||||||
|
- Feedback — do interactive elements provide visual feedback (ripple, state changes)?
|
||||||
|
- Scrolling behavior — is content scrollable when it could overflow?
|
||||||
|
- Keyboard handling — does the UI adapt when the soft keyboard appears?
|
||||||
|
|
||||||
|
4. **Check Material 3 adherence**:
|
||||||
|
- Uses `MaterialTheme.colorScheme` tokens instead of hardcoded colors
|
||||||
|
- Uses `MaterialTheme.typography` instead of custom text styles
|
||||||
|
- Proper use of Surface, Card, and container components
|
||||||
|
- Consistent use of Material 3 icons (filled vs outlined — pick one style)
|
||||||
|
- Dynamic color support (no colors that break with light/dark theme)
|
||||||
|
|
||||||
|
5. **Accessibility**:
|
||||||
|
- Content descriptions on icons and images
|
||||||
|
- Sufficient color contrast
|
||||||
|
- Semantic properties for screen readers
|
||||||
|
- Text scaling support (don't use fixed sp that breaks at large font sizes)
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Structure your review as:
|
||||||
|
|
||||||
|
### ✅ Consistent Patterns
|
||||||
|
List what the code does well and matches existing patterns.
|
||||||
|
|
||||||
|
### ⚠️ Inconsistencies Found
|
||||||
|
For each issue:
|
||||||
|
- **File**: path
|
||||||
|
- **Issue**: what's wrong
|
||||||
|
- **Expected**: what the pattern should be (with reference to where the correct pattern exists)
|
||||||
|
- **Fix**: concrete code suggestion
|
||||||
|
|
||||||
|
### 🎯 UX Improvements
|
||||||
|
Suggestions that aren't bugs but would improve the user experience.
|
||||||
|
|
||||||
|
Prioritize issues by impact: blocking issues first, then visual inconsistencies, then nice-to-haves.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Always read existing UI code first to understand established patterns before making judgments
|
||||||
|
- Never suggest changes that would break the Discord-like layout intent
|
||||||
|
- Prefer MaterialTheme tokens over any hardcoded values
|
||||||
|
- If you're unsure whether something is intentional, flag it as a question rather than an error
|
||||||
|
- Keep suggestions actionable — include code snippets for fixes
|
||||||
|
- Don't nitpick formatting; focus on user-visible consistency and experience
|
||||||
|
|
||||||
|
**Update your agent memory** as you discover UI patterns, design conventions, component reuse patterns, color/spacing constants, and navigation structures in this codebase. This builds up institutional knowledge across conversations. Write concise notes about what you found and where.
|
||||||
|
|
||||||
|
Examples of what to record:
|
||||||
|
- Common padding/spacing values used across screens
|
||||||
|
- Standard composable patterns (e.g., how list items are structured)
|
||||||
|
- Color token usage conventions
|
||||||
|
- Icon style choices (filled vs outlined)
|
||||||
|
- Navigation patterns and screen transition styles
|
||||||
|
- Reusable component locations
|
||||||
|
|
||||||
|
# Persistent Agent Memory
|
||||||
|
|
||||||
|
You have a persistent Persistent Agent Memory directory at `/home/mrfluffy/Documents/projects/Android/fluffytrix/.claude/agent-memory/ui-ux-reviewer/`. Its contents persist across conversations.
|
||||||
|
|
||||||
|
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
|
||||||
|
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
|
||||||
|
- Update or remove memories that turn out to be wrong or outdated
|
||||||
|
- Organize memory semantically by topic, not chronologically
|
||||||
|
- Use the Write and Edit tools to update your memory files
|
||||||
|
|
||||||
|
What to save:
|
||||||
|
- Stable patterns and conventions confirmed across multiple interactions
|
||||||
|
- Key architectural decisions, important file paths, and project structure
|
||||||
|
- User preferences for workflow, tools, and communication style
|
||||||
|
- Solutions to recurring problems and debugging insights
|
||||||
|
|
||||||
|
What NOT to save:
|
||||||
|
- Session-specific context (current task details, in-progress work, temporary state)
|
||||||
|
- Information that might be incomplete — verify against project docs before writing
|
||||||
|
- Anything that duplicates or contradicts existing CLAUDE.md instructions
|
||||||
|
- Speculative or unverified conclusions from reading a single file
|
||||||
|
|
||||||
|
Explicit user requests:
|
||||||
|
- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions
|
||||||
|
- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files
|
||||||
|
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
|
||||||
|
|
||||||
|
## MEMORY.md
|
||||||
|
|
||||||
|
Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time.
|
||||||
@@ -13,7 +13,9 @@
|
|||||||
"WebFetch(domain:raw.githubusercontent.com)",
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
"Bash(export TERM=dumb:*)",
|
"Bash(export TERM=dumb:*)",
|
||||||
"Bash(grep:*)",
|
"Bash(grep:*)",
|
||||||
"Bash(jar tf:*)"
|
"Bash(jar tf:*)",
|
||||||
|
"Bash(javap:*)",
|
||||||
|
"Bash(jar xf:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
.idea/.name
generated
Normal file
1
.idea/.name
generated
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Fluffytrix
|
||||||
13
.idea/deviceManager.xml
generated
Normal file
13
.idea/deviceManager.xml
generated
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DeviceTable">
|
||||||
|
<option name="columnSorters">
|
||||||
|
<list>
|
||||||
|
<ColumnSorterState>
|
||||||
|
<option name="column" value="Name" />
|
||||||
|
<option name="order" value="ASCENDING" />
|
||||||
|
</ColumnSorterState>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
2
.idea/gradle.xml
generated
2
.idea/gradle.xml
generated
@@ -6,7 +6,7 @@
|
|||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
<option name="gradleJvm" value="#JAVA_HOME" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
|
|||||||
6
.idea/studiobot.xml
generated
Normal file
6
.idea/studiobot.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="StudioBotProjectSettings">
|
||||||
|
<option name="shareContext" value="OptedIn" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
161
AGENTS.md
161
AGENTS.md
@@ -6,7 +6,7 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
|
|||||||
|
|
||||||
**Package**: `com.example.fluffytrix`
|
**Package**: `com.example.fluffytrix`
|
||||||
**Build system**: Gradle with KotlinDSL, version catalog at `gradle/libs.versions.toml`
|
**Build system**: Gradle with KotlinDSL, version catalog at `gradle/libs.versions.toml`
|
||||||
**Container/DI**: Koin
|
**DI**: Koin
|
||||||
**State management**: Jetpack Compose StateFlow, ViewModel
|
**State management**: Jetpack Compose StateFlow, ViewModel
|
||||||
**UI framework**: Jetpack Compose with Material 3 (Dynamic Colors)
|
**UI framework**: Jetpack Compose with Material 3 (Dynamic Colors)
|
||||||
**Protocol**: Trixnity SDK for Matrix
|
**Protocol**: Trixnity SDK for Matrix
|
||||||
@@ -20,28 +20,22 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
|
|||||||
```bash
|
```bash
|
||||||
./gradlew assembleDebug # Build debug APK (minified for performance)
|
./gradlew assembleDebug # Build debug APK (minified for performance)
|
||||||
./gradlew assembleRelease # Build release APK
|
./gradlew assembleRelease # Build release APK
|
||||||
./gradlew test # Run unit tests
|
./gradlew test # Run all unit tests
|
||||||
./gradlew connectedAndroidTest # Run instrumented tests on device/emulator
|
./gradlew connectedAndroidTest # Run instrumented tests on device
|
||||||
./gradlew testDebugUnitTest --tests "com.example.fluffytrix.ExampleUnitTest" # Run single test
|
./gradlew testDebugUnitTest --tests "com.example.fluffytrix.*" # Run single test class
|
||||||
|
./gradlew app:testDebugUnitTest --tests "LoginViewModelTest" # Run specific test
|
||||||
```
|
```
|
||||||
|
|
||||||
**Notes**:
|
- Debug builds use R8 with `isDebuggable = true` for balanced speed/debuggability
|
||||||
- Debug builds use R8 with `isDebuggable = true` but strip material-icons-extended and optimize Compose for performance
|
|
||||||
- Use `--tests` with Gradle test tasks to run a single test class
|
|
||||||
- Instrumented tests require a connected device or emulator
|
- Instrumented tests require a connected device or emulator
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
**Unit tests**: Located in `app/src/test/java/`, run with `./gradlew test`
|
**Unit tests**: `app/src/test/java/`
|
||||||
**Instrumented tests**: Located in `app/src/androidTest/java/`, run with `./gradlew connectedAndroidTest`
|
**Instrumented tests**: `app/src/androidTest/java/`
|
||||||
|
**Single test**: `./gradlew testDebugUnitTest --tests "com.example.fluffytrix.ui.main.LoginViewModelTest"`
|
||||||
**Single test execution**:
|
|
||||||
```bash
|
|
||||||
./gradlew testDebugUnitTest --tests "com.example.fluffytrix.*"
|
|
||||||
./gradlew app:testDebugUnitTest --tests "ExampleUnitTest"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -49,17 +43,17 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
|
|||||||
|
|
||||||
### Kotlin Conventions
|
### Kotlin Conventions
|
||||||
|
|
||||||
- **Android Official Kotlin style** (`kotlin.code.style=official` in gradle.properties)
|
- **Android Official Kotlin style** (`kotlin.code.style=official`)
|
||||||
- File naming: `PascalCase.kt` (e.g., `MainViewModel.kt`)
|
- File naming: `PascalCase.kt` (e.g., `MainViewModel.kt`)
|
||||||
- Class naming: `PascalCase` (e.g., `MainViewModel`, `AuthRepository`)
|
- Class naming: `PascalCase` (e.g., `AuthRepository`, `MainViewModel`)
|
||||||
- Function/property naming: `camelCase` (e.g., `sendMessage`, `selectedChannel`)
|
- Function/property naming: `camelCase` (e.g., `sendMessage`, `selectedChannel`)
|
||||||
- Constant naming: `PascalCase` for top-level constants
|
- Constants: `PascalCase` for top-level constants
|
||||||
- **Never use underscores in variable names**
|
- **Never use underscores in variable names**
|
||||||
|
|
||||||
### Imports
|
### Imports
|
||||||
|
|
||||||
- Explicit imports only (no wildcard imports)
|
- Explicit imports only (no wildcards)
|
||||||
- Group imports: Android/X → Kotlin → Javax/Java → Third-party → Same package
|
- Group: Android/X → Kotlin → Javax/Java → Third-party → Same package
|
||||||
- Example:
|
- Example:
|
||||||
```kotlin
|
```kotlin
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -69,59 +63,37 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
|
|||||||
import com.example.fluffytrix.ui.theme.FluffytrixTheme
|
import com.example.fluffytrix.ui.theme.FluffytrixTheme
|
||||||
```
|
```
|
||||||
|
|
||||||
### Types
|
### Types & Error Handling
|
||||||
|
|
||||||
- Prefer `val` over `var` (immutable data)
|
- Prefer `val` over `var` (immutable data)
|
||||||
- Use `StateFlow<T>` for observable state in ViewModels
|
- Use `StateFlow<T>` for observable ViewModel state, `Flow<T>` for read-only streams
|
||||||
- Use `Flow<T>` for read-only data streams
|
- Use `suspend` for async operations, `Result<T>` for failing operations
|
||||||
- Use `suspend` functions for async operations
|
- `try-catch` with `catch (_: Exception) { }` for graceful degradation in ViewModels
|
||||||
- Use `Result<T>` for operations that can fail
|
- Use `?:` operators when appropriate, **never crash on recoverable errors**
|
||||||
|
|
||||||
### Error Handling
|
### Compose & ViewModels
|
||||||
|
|
||||||
- Prefer `try-catch` with silent failure or `?` operators where appropriate
|
- Use `@Composable` for all UI functions; use `MaterialTheme` for consistent theming
|
||||||
- In ViewModels, use `catch (_: Exception) { }` or `?:` for graceful degradation
|
- Discord-like layout: space sidebar → channel list → message area → member list
|
||||||
- Expose error state via `StateFlow<AuthState>` where users need feedback
|
- Use `Modifier.padding()`, `wrapContentWidth()`, `fillMaxWidth()` appropriately
|
||||||
- **Never crash the app on recoverable errors**
|
- Inject deps via constructor with Koin; use `viewModelScope` for coroutines
|
||||||
|
- Cache expensive operations (e.g., `messageCache`, `memberCache`)
|
||||||
|
|
||||||
### Compose UI
|
### Coroutines & Data Layer
|
||||||
|
|
||||||
- Use `@Composable` for all UI functions
|
- `Dispatchers.Default` for CPU work, `Dispatchers.IO` for I/O operations
|
||||||
- Follow Discord-like layout: space sidebar → channel list → message area → member list
|
- Cancel jobs on ViewModel cleanup: `job?.cancel()`
|
||||||
- Use `MaterialTheme` for consistent theming
|
- Use `Room` for persistent storage; `DataStore Preferences` for small key-value data
|
||||||
- Prefer `Modifier.padding()` over nested `Box` with margins
|
- Prefer Flow-based APIs; cache in ViewModels
|
||||||
- Use `wrapContentWidth()` and `fillMaxWidth()` appropriately
|
|
||||||
- For columnar layouts: `Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp))`
|
|
||||||
|
|
||||||
### ViewModels
|
|
||||||
|
|
||||||
- Inject dependencies via constructor (Koin)
|
|
||||||
- Use `viewModelScope` for coroutines
|
|
||||||
- Expose state via `StateFlow` (e.g., `val messages: StateFlow<List<MessageItem>>`)
|
|
||||||
- Use `MutableStateFlow` for internal state, expose as read-only `StateFlow`
|
|
||||||
|
|
||||||
### Coroutines
|
|
||||||
|
|
||||||
- Use `Dispatchers.Default` for CPU-intensive work (parsing, filtering)
|
|
||||||
- Use `Dispatchers.IO` for file/network operations
|
|
||||||
- Always cancel jobs on ViewModel cleanup: `job?.cancel()`
|
|
||||||
- Use `withContext(Dispatchers.Default)` to switch threads explicitly
|
|
||||||
|
|
||||||
### Data Layer
|
|
||||||
|
|
||||||
- Use Room for persistent storage (Trixnity uses Room internally)
|
|
||||||
- Use DataStore Preferences for small key-value data
|
|
||||||
- Prefer Flow-based APIs for reactive data streams
|
|
||||||
- Cache expensive operations in ViewModel (e.g., `messageCache`, `memberCache`)
|
|
||||||
|
|
||||||
### Naming Conventions
|
### Naming Conventions
|
||||||
|
|
||||||
- State Flow properties: `_name` (private) / `name` (public)
|
- State Flow: `_state` (private) / `state` (public)
|
||||||
- Repository class: `AuthService`, `AuthRepository`
|
- Repositories: `AuthRepository`, `MessageRepository`
|
||||||
- ViewModel class: `MainViewModel`, `LoginViewModel`
|
- ViewModels: `MainViewModel`, `LoginViewModel`
|
||||||
- UI composable: `MainScreen`, `ChannelList`, `MessageItem`
|
- UI composables: `MainScreen`, `ChannelList`, `MessageItem`
|
||||||
- Model data class: `MessageItem`, `ChannelItem`, `SpaceItem`
|
- Models: `MessageItem`, `ChannelItem`, `SpaceItem`
|
||||||
- Use `full` property for Matrix IDs (e.g., `userId.full`, `roomId.full`)
|
- Matrix IDs: Use `RoomId`, `UserId` types; access `.full` for strings
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -130,62 +102,19 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
|
|||||||
**Layered Architecture**:
|
**Layered Architecture**:
|
||||||
```
|
```
|
||||||
ui/ — ViewModels, Screens, Navigation
|
ui/ — ViewModels, Screens, Navigation
|
||||||
data/ — Repositories, local storage, models
|
data/ — Repositories, storage, models
|
||||||
di/ — Koin modules
|
di/ — Koin modules
|
||||||
ui/theme/ — Material 3 Theme (colors, typography)
|
ui/theme/ — Material 3 Theme
|
||||||
```
|
```
|
||||||
|
|
||||||
**Dependency Injection**: Koin with two modules:
|
**Koin DI**:
|
||||||
- `appModule`: ViewModels (`viewModel { MainViewModel(...) }`)
|
- `appModule`: ViewModel injection
|
||||||
- `dataModule`: singleton services (`single { AuthRepository(...) }`)
|
- `dataModule`: Singleton repositories/UI Flow: NavHost in `MainActivity` with `FluffytrixTheme`, ViewModels expose StateFlow, screens observe with `collectAsState()`
|
||||||
|
|
||||||
**UI Flow**:
|
|
||||||
1. `FluffytrixNavigation` handles nav graph and session restoration
|
|
||||||
2. `MainActivity` hosts the NavHost with `FluffytrixTheme`
|
|
||||||
3. ViewModels expose state via StateFlow
|
|
||||||
4. Screens observe state with `collectAsState()`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Key Dependencies (from libs.versions.toml)
|
## Key Dependencies
|
||||||
|
|
||||||
- **Compose BOM**: `2025.06.00`
|
- **Compose BOM**: `2025.06.00`, **Kotlin**: `2.2.10`, **AGP**: `9.0.1`
|
||||||
- **Kotlin**: `2.2.10`
|
- **Koin**: `4.1.1`, **Trixnity**: `4.22.7`, **Ktor**: `3.3.0`
|
||||||
- **AGP**: `9.0.1`
|
- **Coroutines**: `1.10.2`, **DataStore**: `1.1.7`, **Coil**: `3.2.0`
|
||||||
- **Koin**: `4.1.1`
|
|
||||||
- **Trixnity**: `4.22.7`
|
|
||||||
- **Ktor**: `3.3.0`
|
|
||||||
- **Coroutines**: `1.10.2`
|
|
||||||
- **DataStore**: `1.1.7`
|
|
||||||
- **Coil**: `3.2.0`
|
|
||||||
- **Media3**: `1.6.0`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Special Notes
|
|
||||||
|
|
||||||
1. **Matrix IDs**: Use `RoomId`, `UserId` types from Trixnity; access `.full` for string representation
|
|
||||||
|
|
||||||
2. **MXC URLs**: Convert with `MxcUrlHelper.mxcToDownloadUrl()` and `mxcToThumbnailUrl()`
|
|
||||||
|
|
||||||
3. **Build Performance**: Debug builds use R8 minification with `isDebuggable = true` to balance speed and debuggability
|
|
||||||
|
|
||||||
4. **Channel Reordering**: Channel order is saved per-space in DataStore and restored on navigation
|
|
||||||
|
|
||||||
5. **Encrypted Rooms**: Handle decryption state—messages may appear as `Unable to decrypt` until keys arrive
|
|
||||||
|
|
||||||
6. **Theme**: Material 3 with Discord-inspired color scheme (primary: `#5865F2`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Running Lint/Checks
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew lintDebug
|
|
||||||
./gradlew spotlessCheck
|
|
||||||
```
|
|
||||||
|
|
||||||
## CI/CD
|
|
||||||
|
|
||||||
- All builds use Gradle wrapper (`./gradlew`)
|
|
||||||
- No manual Gradle installation required (project uses Gradle 9.1.0)
|
|
||||||
|
|||||||
@@ -15,20 +15,16 @@ android {
|
|||||||
minSdk = 34
|
minSdk = 34
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = false
|
||||||
isShrinkResources = true
|
isShrinkResources = false
|
||||||
isDebuggable = true
|
isDebuggable = true
|
||||||
proguardFiles(
|
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
||||||
"proguard-rules.pro"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
@@ -45,7 +41,9 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
dex {
|
dex {
|
||||||
useLegacyPackaging = true
|
useLegacyPackaging = true
|
||||||
@@ -69,6 +67,7 @@ dependencies {
|
|||||||
implementation(libs.navigation.compose)
|
implementation(libs.navigation.compose)
|
||||||
implementation(libs.lifecycle.viewmodel.compose)
|
implementation(libs.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.lifecycle.runtime.compose)
|
implementation(libs.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.lifecycle.process)
|
||||||
debugImplementation(libs.compose.ui.tooling)
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
|
|
||||||
// Koin
|
// Koin
|
||||||
@@ -104,6 +103,12 @@ dependencies {
|
|||||||
implementation(libs.markdown.renderer.code)
|
implementation(libs.markdown.renderer.code)
|
||||||
implementation(libs.markdown.renderer.coil3)
|
implementation(libs.markdown.renderer.coil3)
|
||||||
|
|
||||||
|
// Jetpack Emoji Picker
|
||||||
|
implementation(libs.emoji.picker)
|
||||||
|
|
||||||
|
// UnifiedPush
|
||||||
|
implementation(libs.unifiedpush)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".FluffytrixApplication"
|
android:name=".FluffytrixApplication"
|
||||||
@@ -16,13 +18,32 @@
|
|||||||
android:theme="@style/Theme.Fluffytrix">
|
android:theme="@style/Theme.Fluffytrix">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:windowSoftInputMode="adjustNothing">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<service
|
||||||
|
android:name=".push.FluffytrixPushService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_provider_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -9,21 +9,42 @@ import coil3.memory.MemoryCache
|
|||||||
import coil3.gif.AnimatedImageDecoder
|
import coil3.gif.AnimatedImageDecoder
|
||||||
import coil3.video.VideoFrameDecoder
|
import coil3.video.VideoFrameDecoder
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import com.example.fluffytrix.data.local.PreferencesManager
|
||||||
import com.example.fluffytrix.data.repository.AuthRepository
|
import com.example.fluffytrix.data.repository.AuthRepository
|
||||||
import com.example.fluffytrix.di.appModule
|
import com.example.fluffytrix.di.appModule
|
||||||
import com.example.fluffytrix.di.dataModule
|
import com.example.fluffytrix.di.dataModule
|
||||||
|
import com.example.fluffytrix.push.NotificationHelper
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
|
import org.unifiedpush.android.connector.UnifiedPush
|
||||||
|
|
||||||
class FluffytrixApplication : Application(), SingletonImageLoader.Factory {
|
class FluffytrixApplication : Application(), SingletonImageLoader.Factory {
|
||||||
|
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
startKoin {
|
startKoin {
|
||||||
androidContext(this@FluffytrixApplication)
|
androidContext(this@FluffytrixApplication)
|
||||||
modules(appModule, dataModule)
|
modules(appModule, dataModule)
|
||||||
}
|
}
|
||||||
|
NotificationHelper.ensureChannel(this)
|
||||||
|
val preferencesManager: PreferencesManager by inject()
|
||||||
|
appScope.launch {
|
||||||
|
val loggedIn = preferencesManager.isLoggedIn.first()
|
||||||
|
if (loggedIn) {
|
||||||
|
// Only register if a distributor is already saved; user must select one manually otherwise
|
||||||
|
if (UnifiedPush.getSavedDistributor(this@FluffytrixApplication) != null) {
|
||||||
|
UnifiedPush.register(this@FluffytrixApplication)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun newImageLoader(context: coil3.PlatformContext): ImageLoader {
|
override fun newImageLoader(context: coil3.PlatformContext): ImageLoader {
|
||||||
|
|||||||
@@ -1,20 +1,41 @@
|
|||||||
package com.example.fluffytrix
|
package com.example.fluffytrix
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.example.fluffytrix.push.DeepLinkState
|
||||||
import com.example.fluffytrix.ui.navigation.FluffytrixNavigation
|
import com.example.fluffytrix.ui.navigation.FluffytrixNavigation
|
||||||
import com.example.fluffytrix.ui.theme.FluffytrixTheme
|
import com.example.fluffytrix.ui.theme.FluffytrixTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val requestNotificationPermission =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* no-op */ }
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
intent.getStringExtra("roomId")?.let { DeepLinkState.set(it) }
|
||||||
setContent {
|
setContent {
|
||||||
FluffytrixTheme {
|
FluffytrixTheme {
|
||||||
FluffytrixNavigation()
|
FluffytrixNavigation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
intent.getStringExtra("roomId")?.let { DeepLinkState.set(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,18 @@ class PreferencesManager(private val context: Context) {
|
|||||||
private val KEY_OIDC_DATA = stringPreferencesKey("oidc_data")
|
private val KEY_OIDC_DATA = stringPreferencesKey("oidc_data")
|
||||||
private val KEY_SLIDING_SYNC_VERSION = stringPreferencesKey("sliding_sync_version")
|
private val KEY_SLIDING_SYNC_VERSION = stringPreferencesKey("sliding_sync_version")
|
||||||
private val KEY_USERNAME = stringPreferencesKey("username")
|
private val KEY_USERNAME = stringPreferencesKey("username")
|
||||||
private val KEY_PASSWORD = stringPreferencesKey("password")
|
|
||||||
private val KEY_IS_LOGGED_IN = booleanPreferencesKey("is_logged_in")
|
private val KEY_IS_LOGGED_IN = booleanPreferencesKey("is_logged_in")
|
||||||
private val KEY_CHANNEL_ORDER = stringPreferencesKey("channel_order")
|
private val KEY_CHANNEL_ORDER = stringPreferencesKey("channel_order")
|
||||||
private val KEY_CHILD_SPACE_ORDER = stringPreferencesKey("child_space_order")
|
private val KEY_CHILD_SPACE_ORDER = stringPreferencesKey("child_space_order")
|
||||||
private val KEY_HIDE_SPACES_WHEN_CLOSED = booleanPreferencesKey("hide_spaces_when_closed")
|
private val KEY_HIDE_SPACES_WHEN_CLOSED = booleanPreferencesKey("hide_spaces_when_closed")
|
||||||
|
private val KEY_THREAD_NAMES = stringPreferencesKey("thread_names")
|
||||||
|
private val KEY_HIDDEN_THREADS = stringPreferencesKey("hidden_threads")
|
||||||
|
private val KEY_TENOR_API_KEY = stringPreferencesKey("tenor_api_key")
|
||||||
|
private val KEY_LAST_OPENED_ROOM = stringPreferencesKey("last_opened_room")
|
||||||
|
private val KEY_UP_ENDPOINT = stringPreferencesKey("up_endpoint")
|
||||||
|
private val KEY_ROOM_NAME_CACHE = stringPreferencesKey("room_name_cache")
|
||||||
|
private val KEY_NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
|
||||||
|
private val KEY_MUTED_ROOMS = stringPreferencesKey("muted_rooms")
|
||||||
}
|
}
|
||||||
|
|
||||||
val isLoggedIn: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
val isLoggedIn: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||||
@@ -52,10 +59,6 @@ class PreferencesManager(private val context: Context) {
|
|||||||
prefs[KEY_USERNAME]
|
prefs[KEY_USERNAME]
|
||||||
}
|
}
|
||||||
|
|
||||||
val password: Flow<String?> = context.dataStore.data.map { prefs ->
|
|
||||||
prefs[KEY_PASSWORD]
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun saveSession(
|
suspend fun saveSession(
|
||||||
accessToken: String,
|
accessToken: String,
|
||||||
refreshToken: String?,
|
refreshToken: String?,
|
||||||
@@ -128,6 +131,106 @@ class PreferencesManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val tenorApiKey: Flow<String> = context.dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_TENOR_API_KEY] ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTenorApiKey(key: String) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_TENOR_API_KEY] = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thread names: key = "roomId:threadRootEventId", value = custom name
|
||||||
|
val threadNames: Flow<Map<String, String>> = context.dataStore.data.map { prefs ->
|
||||||
|
val raw = prefs[KEY_THREAD_NAMES] ?: return@map emptyMap()
|
||||||
|
try { Json.decodeFromString<Map<String, String>>(raw) } catch (_: Exception) { emptyMap() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveThreadName(roomId: String, threadRootEventId: String, name: String) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
val existing = prefs[KEY_THREAD_NAMES]?.let {
|
||||||
|
try { Json.decodeFromString<Map<String, String>>(it) } catch (_: Exception) { emptyMap() }
|
||||||
|
} ?: emptyMap()
|
||||||
|
val key = "$roomId:$threadRootEventId"
|
||||||
|
val updated = if (name.isBlank()) existing - key else existing + (key to name)
|
||||||
|
prefs[KEY_THREAD_NAMES] = Json.encodeToString(updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hidden (removed) threads: set of "roomId:threadRootEventId"
|
||||||
|
val hiddenThreads: Flow<Set<String>> = context.dataStore.data.map { prefs ->
|
||||||
|
val raw = prefs[KEY_HIDDEN_THREADS] ?: return@map emptySet()
|
||||||
|
try { Json.decodeFromString<Set<String>>(raw) } catch (_: Exception) { emptySet() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setThreadHidden(roomId: String, threadRootEventId: String, hidden: Boolean) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
val existing = prefs[KEY_HIDDEN_THREADS]?.let {
|
||||||
|
try { Json.decodeFromString<Set<String>>(it) } catch (_: Exception) { emptySet() }
|
||||||
|
} ?: emptySet()
|
||||||
|
val key = "$roomId:$threadRootEventId"
|
||||||
|
prefs[KEY_HIDDEN_THREADS] = Json.encodeToString(if (hidden) existing + key else existing - key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val lastOpenedRoom: Flow<String?> = context.dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_LAST_OPENED_ROOM]
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setLastOpenedRoom(roomId: String) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_LAST_OPENED_ROOM] = roomId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val roomNameCache: Flow<Map<String, String>> = context.dataStore.data.map { prefs ->
|
||||||
|
val raw = prefs[KEY_ROOM_NAME_CACHE] ?: return@map emptyMap()
|
||||||
|
try { Json.decodeFromString<Map<String, String>>(raw) } catch (_: Exception) { emptyMap() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveRoomNameCache(names: Map<String, String>) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_ROOM_NAME_CACHE] = Json.encodeToString(names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val upEndpoint: Flow<String?> = context.dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_UP_ENDPOINT]
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setUpEndpoint(url: String?) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
if (url != null) prefs[KEY_UP_ENDPOINT] = url else prefs.remove(KEY_UP_ENDPOINT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val notificationsEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_NOTIFICATIONS_ENABLED] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setNotificationsEnabled(enabled: Boolean) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_NOTIFICATIONS_ENABLED] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val mutedRooms: Flow<Set<String>> = context.dataStore.data.map { prefs ->
|
||||||
|
val raw = prefs[KEY_MUTED_ROOMS] ?: return@map emptySet()
|
||||||
|
try { Json.decodeFromString<Set<String>>(raw) } catch (_: Exception) { emptySet() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun toggleRoomMute(roomId: String) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
val existing = prefs[KEY_MUTED_ROOMS]?.let {
|
||||||
|
try { Json.decodeFromString<Set<String>>(it) } catch (_: Exception) { emptySet() }
|
||||||
|
} ?: emptySet()
|
||||||
|
prefs[KEY_MUTED_ROOMS] = Json.encodeToString(
|
||||||
|
if (roomId in existing) existing - roomId else existing + roomId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun clearSession() {
|
suspend fun clearSession() {
|
||||||
context.dataStore.edit { it.clear() }
|
context.dataStore.edit { it.clear() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.example.fluffytrix.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CustomEmoji(
|
||||||
|
val url: String,
|
||||||
|
val body: String? = null,
|
||||||
|
val usage: List<String> = listOf("emoticon"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class EmojiPackInfo(
|
||||||
|
@SerialName("display_name") val displayName: String? = null,
|
||||||
|
@SerialName("avatar_url") val avatarUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class EmojiPackData(
|
||||||
|
val pack: EmojiPackInfo = EmojiPackInfo(),
|
||||||
|
val images: Map<String, CustomEmoji> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserEmojiAccountData(
|
||||||
|
val packs: Map<String, EmojiPackData> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// UI-facing (resolved URLs)
|
||||||
|
data class EmojiEntry(
|
||||||
|
val shortcode: String,
|
||||||
|
val mxcUrl: String,
|
||||||
|
val displayName: String,
|
||||||
|
val resolvedUrl: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EmojiPack(
|
||||||
|
val packId: String,
|
||||||
|
val displayName: String,
|
||||||
|
val avatarMxcUrl: String?,
|
||||||
|
val emojis: List<EmojiEntry>,
|
||||||
|
val isRoomPack: Boolean = false,
|
||||||
|
val roomId: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.example.fluffytrix.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GiphyResponse(
|
||||||
|
val data: List<GiphyResult> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GiphyResult(
|
||||||
|
val id: String,
|
||||||
|
val title: String = "",
|
||||||
|
val images: GiphyImages = GiphyImages(),
|
||||||
|
) {
|
||||||
|
val previewUrl: String get() = images.fixedWidthDownsampled.url
|
||||||
|
.ifBlank { images.fixedHeight.url }
|
||||||
|
val fullUrl: String get() = images.original.url
|
||||||
|
.ifBlank { images.fixedHeight.url }
|
||||||
|
val previewWidth: Int get() = images.fixedWidthDownsampled.width.toIntOrNull() ?: 200
|
||||||
|
val previewHeight: Int get() = images.fixedWidthDownsampled.height.toIntOrNull() ?: 200
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GiphyImages(
|
||||||
|
@SerialName("fixed_height") val fixedHeight: GiphyImageEntry = GiphyImageEntry(),
|
||||||
|
@SerialName("fixed_width_downsampled") val fixedWidthDownsampled: GiphyImageEntry = GiphyImageEntry(),
|
||||||
|
val original: GiphyImageEntry = GiphyImageEntry(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GiphyImageEntry(
|
||||||
|
val url: String = "",
|
||||||
|
val width: String = "200",
|
||||||
|
val height: String = "200",
|
||||||
|
)
|
||||||
@@ -40,6 +40,12 @@ class AuthRepository(
|
|||||||
|
|
||||||
fun getSyncService(): SyncService? = syncService
|
fun getSyncService(): SyncService? = syncService
|
||||||
|
|
||||||
|
suspend fun restartSync(): SyncService? {
|
||||||
|
try { syncService?.stop() } catch (_: Exception) { }
|
||||||
|
syncService = null
|
||||||
|
return getOrStartSync()
|
||||||
|
}
|
||||||
|
|
||||||
private fun sessionDataPath(): String {
|
private fun sessionDataPath(): String {
|
||||||
val dir = File(context.filesDir, "matrix_session_data")
|
val dir = File(context.filesDir, "matrix_session_data")
|
||||||
dir.mkdirs()
|
dir.mkdirs()
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.example.fluffytrix.data.repository
|
||||||
|
|
||||||
|
import com.example.fluffytrix.data.MxcUrlHelper
|
||||||
|
import com.example.fluffytrix.data.model.CustomEmoji
|
||||||
|
import com.example.fluffytrix.data.model.EmojiEntry
|
||||||
|
import com.example.fluffytrix.data.model.EmojiPack
|
||||||
|
import com.example.fluffytrix.data.model.EmojiPackData
|
||||||
|
import com.example.fluffytrix.data.model.EmojiPackInfo
|
||||||
|
import com.example.fluffytrix.data.model.UserEmojiAccountData
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
class EmojiPackRepository(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
) {
|
||||||
|
private val baseUrl: String
|
||||||
|
get() = try {
|
||||||
|
authRepository.getClient()?.session()?.homeserverUrl?.trimEnd('/') ?: ""
|
||||||
|
} catch (_: Exception) { "" }
|
||||||
|
|
||||||
|
suspend fun loadUserPacks(): List<EmojiPack> {
|
||||||
|
val client = authRepository.getClient() ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val raw = client.accountData("im.ponies.user_emojis") ?: return emptyList()
|
||||||
|
val data = json.decodeFromString<UserEmojiAccountData>(raw)
|
||||||
|
data.packs.map { (packId, packData) ->
|
||||||
|
packData.toEmojiPack(packId, baseUrl)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("EmojiPackRepo", "Failed to load user packs", e)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveUserPacks(packs: List<EmojiPack>) {
|
||||||
|
val client = authRepository.getClient() ?: return
|
||||||
|
try {
|
||||||
|
val packsMap = packs.associate { pack ->
|
||||||
|
pack.packId to EmojiPackData(
|
||||||
|
pack = EmojiPackInfo(displayName = pack.displayName, avatarUrl = pack.avatarMxcUrl),
|
||||||
|
images = pack.emojis.associate { entry ->
|
||||||
|
entry.shortcode to CustomEmoji(url = entry.mxcUrl, body = entry.displayName.ifBlank { null })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val data = UserEmojiAccountData(packs = packsMap)
|
||||||
|
client.setAccountData("im.ponies.user_emojis", json.encodeToString(data))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("EmojiPackRepo", "Failed to save user packs", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun uploadImage(mimeType: String, data: ByteArray): String? {
|
||||||
|
val client = authRepository.getClient() ?: return null
|
||||||
|
return try {
|
||||||
|
client.uploadMedia(mimeType, data, null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("EmojiPackRepo", "Failed to upload emoji image", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadAllPacks(roomId: String? = null): List<EmojiPack> {
|
||||||
|
return loadUserPacks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun EmojiPackData.toEmojiPack(packId: String, baseUrl: String): EmojiPack {
|
||||||
|
val emojis = images.map { (shortcode, emoji) ->
|
||||||
|
EmojiEntry(
|
||||||
|
shortcode = shortcode,
|
||||||
|
mxcUrl = emoji.url,
|
||||||
|
displayName = emoji.body ?: shortcode,
|
||||||
|
resolvedUrl = MxcUrlHelper.mxcToDownloadUrl(baseUrl, emoji.url) ?: emoji.url,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return EmojiPack(
|
||||||
|
packId = packId,
|
||||||
|
displayName = pack.displayName ?: packId,
|
||||||
|
avatarMxcUrl = pack.avatarUrl,
|
||||||
|
emojis = emojis,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.example.fluffytrix.data.repository
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.example.fluffytrix.data.model.GiphyResponse
|
||||||
|
import com.example.fluffytrix.data.model.GiphyResult
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
|
||||||
|
class GifRepository {
|
||||||
|
private val client = OkHttpClient()
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val baseUrl = "https://api.giphy.com/v1/gifs"
|
||||||
|
|
||||||
|
fun trending(apiKey: String, limit: Int = 24): List<GiphyResult> {
|
||||||
|
val url = "$baseUrl/trending?api_key=$apiKey&limit=$limit&rating=g"
|
||||||
|
return fetch(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun search(apiKey: String, query: String, limit: Int = 24): List<GiphyResult> {
|
||||||
|
val encoded = java.net.URLEncoder.encode(query, "UTF-8")
|
||||||
|
val url = "$baseUrl/search?api_key=$apiKey&q=$encoded&limit=$limit&rating=g"
|
||||||
|
return fetch(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetch(url: String): List<GiphyResult> {
|
||||||
|
Log.d("GifRepository", "Fetching: $url")
|
||||||
|
return try {
|
||||||
|
val request = Request.Builder().url(url).build()
|
||||||
|
val response = client.newCall(request).execute()
|
||||||
|
Log.d("GifRepository", "Response code: ${response.code}")
|
||||||
|
val body = response.body?.string() ?: run {
|
||||||
|
Log.w("GifRepository", "Empty body")
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
Log.e("GifRepository", "Error response: $body")
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
val results = json.decodeFromString<GiphyResponse>(body).data
|
||||||
|
Log.d("GifRepository", "Parsed ${results.size} results")
|
||||||
|
results
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("GifRepository", "Fetch failed", e)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package com.example.fluffytrix.di
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.example.fluffytrix.data.local.PreferencesManager
|
import com.example.fluffytrix.data.local.PreferencesManager
|
||||||
import com.example.fluffytrix.data.repository.AuthRepository
|
import com.example.fluffytrix.data.repository.AuthRepository
|
||||||
|
import com.example.fluffytrix.data.repository.EmojiPackRepository
|
||||||
|
import com.example.fluffytrix.push.PushRegistrationManager
|
||||||
import com.example.fluffytrix.ui.screens.login.LoginViewModel
|
import com.example.fluffytrix.ui.screens.login.LoginViewModel
|
||||||
import com.example.fluffytrix.ui.screens.main.MainViewModel
|
import com.example.fluffytrix.ui.screens.main.MainViewModel
|
||||||
import com.example.fluffytrix.ui.screens.verification.VerificationViewModel
|
import com.example.fluffytrix.ui.screens.verification.VerificationViewModel
|
||||||
@@ -11,7 +13,7 @@ import org.koin.core.module.dsl.viewModel
|
|||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val appModule = module {
|
val appModule = module {
|
||||||
viewModel { MainViewModel(androidApplication(), get(), get()) }
|
viewModel { MainViewModel(androidApplication(), get(), get(), get()) }
|
||||||
viewModel { LoginViewModel(get()) }
|
viewModel { LoginViewModel(get()) }
|
||||||
viewModel { VerificationViewModel(get()) }
|
viewModel { VerificationViewModel(get()) }
|
||||||
}
|
}
|
||||||
@@ -19,4 +21,6 @@ val appModule = module {
|
|||||||
val dataModule = module {
|
val dataModule = module {
|
||||||
single { PreferencesManager(get()) }
|
single { PreferencesManager(get()) }
|
||||||
single { AuthRepository(get(), get()) }
|
single { AuthRepository(get(), get()) }
|
||||||
|
single { EmojiPackRepository(get()) }
|
||||||
|
single { PushRegistrationManager(get()) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.example.fluffytrix.push
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
object DeepLinkState {
|
||||||
|
private val _pendingRoomId = MutableStateFlow<String?>(null)
|
||||||
|
val pendingRoomId: StateFlow<String?> = _pendingRoomId
|
||||||
|
|
||||||
|
fun set(roomId: String) {
|
||||||
|
_pendingRoomId.value = roomId
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
_pendingRoomId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.example.fluffytrix.push
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.example.fluffytrix.data.local.PreferencesManager
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
|
import org.unifiedpush.android.connector.FailedReason
|
||||||
|
import org.unifiedpush.android.connector.PushService
|
||||||
|
import org.unifiedpush.android.connector.data.PushEndpoint
|
||||||
|
import org.unifiedpush.android.connector.data.PushMessage
|
||||||
|
|
||||||
|
private const val TAG = "PushReceiver"
|
||||||
|
|
||||||
|
class FluffytrixPushService : PushService(), KoinComponent {
|
||||||
|
|
||||||
|
private val preferencesManager: PreferencesManager by inject()
|
||||||
|
private val pushRegistrationManager: PushRegistrationManager by inject()
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(message: PushMessage, instance: String) {
|
||||||
|
Log.d(TAG, "Push message received")
|
||||||
|
scope.launch {
|
||||||
|
NotificationHelper.show(this@FluffytrixPushService, message.content, preferencesManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
|
||||||
|
Log.i(TAG, "New UP endpoint: ${endpoint.url}")
|
||||||
|
scope.launch {
|
||||||
|
preferencesManager.setUpEndpoint(endpoint.url)
|
||||||
|
try {
|
||||||
|
pushRegistrationManager.registerPusher(endpoint.url)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to register pusher: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
|
||||||
|
Log.w(TAG, "UP registration failed: $reason")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUnregistered(instance: String) {
|
||||||
|
Log.i(TAG, "UP unregistered")
|
||||||
|
scope.launch {
|
||||||
|
val endpoint = preferencesManager.upEndpoint.first()
|
||||||
|
if (endpoint != null) {
|
||||||
|
pushRegistrationManager.unregisterPusher(endpoint)
|
||||||
|
}
|
||||||
|
preferencesManager.setUpEndpoint(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.example.fluffytrix.push
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.example.fluffytrix.MainActivity
|
||||||
|
import com.example.fluffytrix.R
|
||||||
|
import com.example.fluffytrix.data.local.PreferencesManager
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
object NotificationHelper {
|
||||||
|
|
||||||
|
private const val CHANNEL_ID = "messages"
|
||||||
|
private const val CHANNEL_NAME = "Messages"
|
||||||
|
|
||||||
|
fun ensureChannel(context: Context) {
|
||||||
|
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (nm.getNotificationChannel(CHANNEL_ID) == null) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
CHANNEL_NAME,
|
||||||
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
|
)
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun show(context: Context, payload: ByteArray, preferencesManager: PreferencesManager) {
|
||||||
|
val notificationsEnabled = preferencesManager.notificationsEnabled.first()
|
||||||
|
if (!notificationsEnabled) return
|
||||||
|
|
||||||
|
val mutedRooms = preferencesManager.mutedRooms.first()
|
||||||
|
|
||||||
|
val json = try {
|
||||||
|
JSONObject(String(payload))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val notification = json.optJSONObject("notification") ?: return
|
||||||
|
val roomId = notification.optString("room_id").takeIf { it.isNotBlank() } ?: return
|
||||||
|
|
||||||
|
if (roomId in mutedRooms) return
|
||||||
|
|
||||||
|
val cachedNames = preferencesManager.roomNameCache.first()
|
||||||
|
val roomName = notification.optString("room_name").ifBlank {
|
||||||
|
cachedNames[roomId] ?: roomId
|
||||||
|
}
|
||||||
|
val sender = notification.optString("sender_display_name").ifBlank {
|
||||||
|
notification.optString("sender").ifBlank { "Someone" }
|
||||||
|
}
|
||||||
|
val content = notification.optJSONObject("counts")?.let {
|
||||||
|
val msgs = it.optInt("unread", 0)
|
||||||
|
if (msgs > 0) "$msgs unread message${if (msgs > 1) "s" else ""}" else null
|
||||||
|
} ?: "$sender sent a message"
|
||||||
|
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val tapIntent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
putExtra("roomId", roomId)
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
roomId.hashCode(),
|
||||||
|
tapIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val notif = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(roomName)
|
||||||
|
.setContentText(content)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
nm.notify(roomId.hashCode(), notif)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.example.fluffytrix.push
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.example.fluffytrix.data.local.PreferencesManager
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
private const val TAG = "PushReg"
|
||||||
|
|
||||||
|
class PushRegistrationManager(
|
||||||
|
private val preferencesManager: PreferencesManager,
|
||||||
|
) {
|
||||||
|
private val httpClient = OkHttpClient()
|
||||||
|
|
||||||
|
suspend fun registerPusher(endpoint: String) {
|
||||||
|
val homeserver = preferencesManager.homeserverUrl.first() ?: run {
|
||||||
|
Log.w(TAG, "No homeserver URL — skipping pusher registration")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val accessToken = preferencesManager.accessToken.first() ?: run {
|
||||||
|
Log.w(TAG, "No access token — skipping pusher registration")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val deviceId = preferencesManager.deviceId.first() ?: "fluffytrix"
|
||||||
|
val userId = preferencesManager.userId.first() ?: "unknown"
|
||||||
|
|
||||||
|
val body = JSONObject().apply {
|
||||||
|
put("app_id", "com.example.fluffytrix")
|
||||||
|
put("app_display_name", "Fluffytrix")
|
||||||
|
put("device_display_name", deviceId)
|
||||||
|
put("kind", "http")
|
||||||
|
put("lang", "en")
|
||||||
|
put("pushkey", endpoint)
|
||||||
|
put("data", JSONObject().apply {
|
||||||
|
put("url", run {
|
||||||
|
val parsed = java.net.URL(endpoint)
|
||||||
|
"${parsed.protocol}://${parsed.host}${if (parsed.port != -1) ":${parsed.port}" else ""}/_matrix/push/v1/notify"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}.toString()
|
||||||
|
|
||||||
|
val url = "${homeserver.trimEnd('/')}/_matrix/client/v3/pushers/set"
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.addHeader("Authorization", "Bearer $accessToken")
|
||||||
|
.post(body.toRequestBody("application/json".toMediaType()))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
val responseBody = response.body?.string()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
Log.i(TAG, "Pusher registered successfully for $userId")
|
||||||
|
} else {
|
||||||
|
val msg = "HTTP ${response.code}: $responseBody"
|
||||||
|
Log.w(TAG, "Pusher registration failed: $msg")
|
||||||
|
throw Exception(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun unregisterPusher(endpoint: String) {
|
||||||
|
val homeserver = preferencesManager.homeserverUrl.first() ?: return
|
||||||
|
val accessToken = preferencesManager.accessToken.first() ?: return
|
||||||
|
|
||||||
|
val body = JSONObject().apply {
|
||||||
|
put("app_id", "com.example.fluffytrix")
|
||||||
|
put("kind", null)
|
||||||
|
put("pushkey", endpoint)
|
||||||
|
}.toString()
|
||||||
|
|
||||||
|
val url = "${homeserver.trimEnd('/')}/_matrix/client/v3/pushers/set"
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.addHeader("Authorization", "Bearer $accessToken")
|
||||||
|
.post(body.toRequestBody("application/json".toMediaType()))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
try {
|
||||||
|
httpClient.newCall(request).execute().close()
|
||||||
|
Log.i(TAG, "Pusher unregistered")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Pusher unregister error", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,9 @@ import androidx.navigation.compose.composable
|
|||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.example.fluffytrix.data.local.PreferencesManager
|
import com.example.fluffytrix.data.local.PreferencesManager
|
||||||
import com.example.fluffytrix.data.repository.AuthRepository
|
import com.example.fluffytrix.data.repository.AuthRepository
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import com.example.fluffytrix.ui.screens.emoji.EmojiPackManagementScreen
|
||||||
import com.example.fluffytrix.ui.screens.login.LoginScreen
|
import com.example.fluffytrix.ui.screens.login.LoginScreen
|
||||||
import com.example.fluffytrix.ui.screens.main.MainScreen
|
import com.example.fluffytrix.ui.screens.main.MainScreen
|
||||||
import com.example.fluffytrix.ui.screens.settings.SettingsScreen
|
import com.example.fluffytrix.ui.screens.settings.SettingsScreen
|
||||||
@@ -150,6 +153,16 @@ fun FluffytrixNavigation() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable(
|
||||||
|
route = Screen.EmojiPackManagement.routeWithArgs,
|
||||||
|
arguments = listOf(navArgument("roomId") { type = NavType.StringType; nullable = true; defaultValue = null }),
|
||||||
|
) { backStackEntry ->
|
||||||
|
val roomId = backStackEntry.arguments?.getString("roomId")
|
||||||
|
EmojiPackManagementScreen(
|
||||||
|
roomId = roomId,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
composable(Screen.Settings.route) {
|
composable(Screen.Settings.route) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
@@ -158,6 +171,9 @@ fun FluffytrixNavigation() {
|
|||||||
popUpTo(0) { inclusive = true }
|
popUpTo(0) { inclusive = true }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onEmojiPackManagement = {
|
||||||
|
navController.navigate(Screen.EmojiPackManagement.route())
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,8 @@ sealed class Screen(val route: String) {
|
|||||||
data object Verification : Screen("verification")
|
data object Verification : Screen("verification")
|
||||||
data object Main : Screen("main")
|
data object Main : Screen("main")
|
||||||
data object Settings : Screen("settings")
|
data object Settings : Screen("settings")
|
||||||
|
data object EmojiPackManagement : Screen("emoji_packs") {
|
||||||
|
fun route(roomId: String? = null) = if (roomId != null) "emoji_packs?roomId=$roomId" else "emoji_packs"
|
||||||
|
const val routeWithArgs = "emoji_packs?roomId={roomId}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
package com.example.fluffytrix.ui.screens.emoji
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
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.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
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.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.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.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.example.fluffytrix.data.model.EmojiEntry
|
||||||
|
import com.example.fluffytrix.data.model.EmojiPack
|
||||||
|
import com.example.fluffytrix.ui.screens.main.MainViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun EmojiPackManagementScreen(
|
||||||
|
roomId: String?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: MainViewModel = koinViewModel(),
|
||||||
|
) {
|
||||||
|
val emojiPacks by viewModel.emojiPacks.collectAsStateWithLifecycle()
|
||||||
|
val userPacks = emojiPacks.filter { !it.isRoomPack }
|
||||||
|
var showCreateDialog by remember { mutableStateOf(false) }
|
||||||
|
var editingPack by remember { mutableStateOf<EmojiPack?>(null) }
|
||||||
|
|
||||||
|
if (showCreateDialog) {
|
||||||
|
CreatePackDialog(
|
||||||
|
onDismiss = { showCreateDialog = false },
|
||||||
|
onCreate = { name ->
|
||||||
|
val newPack = EmojiPack(
|
||||||
|
packId = name.lowercase().replace(Regex("[^a-z0-9_]"), "_"),
|
||||||
|
displayName = name,
|
||||||
|
avatarMxcUrl = null,
|
||||||
|
emojis = emptyList(),
|
||||||
|
)
|
||||||
|
val updated = userPacks + newPack
|
||||||
|
viewModel.saveUserEmojiPacks(updated)
|
||||||
|
showCreateDialog = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
editingPack?.let { pack ->
|
||||||
|
EditPackDialog(
|
||||||
|
pack = pack,
|
||||||
|
viewModel = viewModel,
|
||||||
|
onDismiss = { editingPack = null },
|
||||||
|
onSave = { updatedPack ->
|
||||||
|
val updated = userPacks.map { if (it.packId == updatedPack.packId) updatedPack else it }
|
||||||
|
viewModel.saveUserEmojiPacks(updated)
|
||||||
|
editingPack = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Emoji Packs") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = { showCreateDialog = true },
|
||||||
|
icon = { Icon(Icons.Default.Add, null) },
|
||||||
|
text = { Text("Create Pack") },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
if (userPacks.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"No emoji packs yet. Create one to get started.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
items(userPacks, key = { it.packId }) { pack ->
|
||||||
|
PackRow(
|
||||||
|
pack = pack,
|
||||||
|
onEdit = { editingPack = pack },
|
||||||
|
onDelete = {
|
||||||
|
val updated = userPacks.filter { it.packId != pack.packId }
|
||||||
|
viewModel.saveUserEmojiPacks(updated)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PackRow(
|
||||||
|
pack: EmojiPack,
|
||||||
|
onEdit: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onEdit() }
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (pack.avatarMxcUrl != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = pack.avatarMxcUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(40.dp).clip(CircleShape),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(40.dp).clip(CircleShape)
|
||||||
|
.then(Modifier.fillMaxWidth()),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
pack.displayName.take(1).uppercase(),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(pack.displayName, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
|
||||||
|
Text("${pack.emojis.size} emojis", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(Icons.Default.Delete, "Delete", tint = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CreatePackDialog(onDismiss: () -> Unit, onCreate: (String) -> Unit) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Create Emoji Pack") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text("Pack name") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { if (name.isNotBlank()) onCreate(name.trim()) }, enabled = name.isNotBlank()) {
|
||||||
|
Text("Create")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EditPackDialog(
|
||||||
|
pack: EmojiPack,
|
||||||
|
viewModel: MainViewModel,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (EmojiPack) -> Unit,
|
||||||
|
) {
|
||||||
|
var emojis by remember { mutableStateOf(pack.emojis) }
|
||||||
|
var addShortcode by remember { mutableStateOf("") }
|
||||||
|
var pendingImageUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
var isUploading by remember { mutableStateOf(false) }
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
|
if (uri != null) pendingImageUri = uri
|
||||||
|
}
|
||||||
|
|
||||||
|
// When we have a pending URI and a shortcode, prompt for shortcode first
|
||||||
|
if (pendingImageUri != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { pendingImageUri = null },
|
||||||
|
title = { Text("Set Shortcode") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = addShortcode,
|
||||||
|
onValueChange = { addShortcode = it },
|
||||||
|
label = { Text("Shortcode (e.g. blobcat)") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val uri = pendingImageUri ?: return@TextButton
|
||||||
|
val code = addShortcode.trim().trimStart(':').trimEnd(':')
|
||||||
|
if (code.isBlank()) return@TextButton
|
||||||
|
isUploading = true
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
|
val mime = context.contentResolver.getType(uri) ?: "image/png"
|
||||||
|
if (bytes != null) {
|
||||||
|
val mxcUrl = viewModel.uploadEmojiImage(mime, bytes)
|
||||||
|
if (mxcUrl != null) {
|
||||||
|
val entry = EmojiEntry(
|
||||||
|
shortcode = code,
|
||||||
|
mxcUrl = mxcUrl,
|
||||||
|
displayName = code,
|
||||||
|
resolvedUrl = mxcUrl,
|
||||||
|
)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
emojis = emojis + entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
isUploading = false
|
||||||
|
pendingImageUri = null
|
||||||
|
addShortcode = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = addShortcode.isNotBlank() && !isUploading,
|
||||||
|
) {
|
||||||
|
Text(if (isUploading) "Uploading…" else "Add")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { pendingImageUri = null; addShortcode = "" }) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(pack.displayName) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Close")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
TextButton(onClick = { onSave(pack.copy(emojis = emojis)) }) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = { imagePicker.launch("image/*") },
|
||||||
|
icon = { Icon(Icons.Default.Add, null) },
|
||||||
|
text = { Text("Add Emoji") },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
if (emojis.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text("No emojis yet. Tap Add Emoji to upload one.", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
) {
|
||||||
|
items(emojis, key = { it.shortcode }) { entry ->
|
||||||
|
EmojiEntryRow(
|
||||||
|
entry = entry,
|
||||||
|
onDelete = { emojis = emojis.filter { it.shortcode != entry.shortcode } },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmojiEntryRow(entry: EmojiEntry, onDelete: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = entry.resolvedUrl,
|
||||||
|
contentDescription = entry.shortcode,
|
||||||
|
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(4.dp)),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(":${entry.shortcode}:", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||||
|
if (entry.displayName != entry.shortcode) {
|
||||||
|
Text(entry.displayName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(Icons.Default.Delete, "Delete", tint = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,50 +9,106 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.ime
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
import com.example.fluffytrix.data.local.PreferencesManager
|
import com.example.fluffytrix.data.local.PreferencesManager
|
||||||
|
import com.example.fluffytrix.push.DeepLinkState
|
||||||
import com.example.fluffytrix.ui.screens.main.components.ChannelList
|
import com.example.fluffytrix.ui.screens.main.components.ChannelList
|
||||||
import com.example.fluffytrix.ui.screens.main.components.MemberList
|
import com.example.fluffytrix.ui.screens.main.components.MemberList
|
||||||
import com.example.fluffytrix.ui.screens.main.components.MessageTimeline
|
import com.example.fluffytrix.ui.screens.main.components.MessageTimeline
|
||||||
import com.example.fluffytrix.ui.screens.main.components.SpaceList
|
import com.example.fluffytrix.ui.screens.main.components.SpaceList
|
||||||
|
import com.example.fluffytrix.ui.screens.main.components.UserProfileSheet
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
private data class ProfileSheetState(val userId: String, val displayName: String, val avatarUrl: String?)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(
|
fun MainScreen(
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
onSettingsClick: () -> Unit = {},
|
onSettingsClick: () -> Unit = {},
|
||||||
viewModel: MainViewModel = koinViewModel(),
|
viewModel: MainViewModel = koinViewModel(),
|
||||||
) {
|
) {
|
||||||
val spaces by viewModel.spaces.collectAsState()
|
val pendingDeepLinkRoom by DeepLinkState.pendingRoomId.collectAsStateWithLifecycle()
|
||||||
val channels by viewModel.channels.collectAsState()
|
LaunchedEffect(pendingDeepLinkRoom) {
|
||||||
val selectedSpace by viewModel.selectedSpace.collectAsState()
|
val roomId = pendingDeepLinkRoom ?: return@LaunchedEffect
|
||||||
val selectedChannel by viewModel.selectedChannel.collectAsState()
|
viewModel.openRoom(roomId)
|
||||||
val showChannelList by viewModel.showChannelList.collectAsState()
|
}
|
||||||
val showMemberList by viewModel.showMemberList.collectAsState()
|
|
||||||
val messages by viewModel.messages.collectAsState()
|
val spaces by viewModel.spaces.collectAsStateWithLifecycle()
|
||||||
val members by viewModel.members.collectAsState()
|
val channels by viewModel.channels.collectAsStateWithLifecycle()
|
||||||
val channelName by viewModel.channelName.collectAsState()
|
val selectedSpace by viewModel.selectedSpace.collectAsStateWithLifecycle()
|
||||||
val isReorderMode by viewModel.isReorderMode.collectAsState()
|
val selectedChannel by viewModel.selectedChannel.collectAsStateWithLifecycle()
|
||||||
val homeUnreadStatus by viewModel.homeUnreadStatus.collectAsState()
|
val showChannelList by viewModel.showChannelList.collectAsStateWithLifecycle()
|
||||||
val channelSections by viewModel.channelSections.collectAsState()
|
val showMemberList by viewModel.showMemberList.collectAsStateWithLifecycle()
|
||||||
val unreadMarkerIndex by viewModel.unreadMarkerIndex.collectAsState()
|
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||||
|
val members by viewModel.members.collectAsStateWithLifecycle()
|
||||||
|
val channelName by viewModel.channelName.collectAsStateWithLifecycle()
|
||||||
|
val isReorderMode by viewModel.isReorderMode.collectAsStateWithLifecycle()
|
||||||
|
val homeUnreadStatus by viewModel.homeUnreadStatus.collectAsStateWithLifecycle()
|
||||||
|
val channelSections by viewModel.channelSections.collectAsStateWithLifecycle()
|
||||||
|
val unreadMarkerIndex by viewModel.unreadMarkerIndex.collectAsStateWithLifecycle()
|
||||||
|
val roomThreads by viewModel.roomThreads.collectAsStateWithLifecycle()
|
||||||
|
val expandedThreadRooms by viewModel.expandedThreadRooms.collectAsStateWithLifecycle()
|
||||||
|
val selectedThread by viewModel.selectedThread.collectAsStateWithLifecycle()
|
||||||
|
val threadMessages by viewModel.threadMessages.collectAsStateWithLifecycle()
|
||||||
|
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
||||||
|
val replyingTo by viewModel.replyingTo.collectAsStateWithLifecycle()
|
||||||
|
val editingMessage by viewModel.editingMessage.collectAsStateWithLifecycle()
|
||||||
|
val emojiPacks by viewModel.emojiPacks.collectAsStateWithLifecycle()
|
||||||
val listState = viewModel.channelListState
|
val listState = viewModel.channelListState
|
||||||
val preferencesManager: PreferencesManager = koinInject()
|
val preferencesManager: PreferencesManager = koinInject()
|
||||||
val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsState(initial = false)
|
val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
|
||||||
// Back button opens channel list when in a chat, or does nothing if already open
|
var profileSheet by remember { mutableStateOf<ProfileSheetState?>(null) }
|
||||||
BackHandler(enabled = selectedChannel != null && !showChannelList) {
|
|
||||||
|
profileSheet?.let { sheet ->
|
||||||
|
UserProfileSheet(
|
||||||
|
userId = sheet.userId,
|
||||||
|
displayName = sheet.displayName,
|
||||||
|
avatarUrl = sheet.avatarUrl,
|
||||||
|
currentUserId = currentUserId,
|
||||||
|
onDismiss = { profileSheet = null },
|
||||||
|
onStartDm = {
|
||||||
|
profileSheet = null
|
||||||
|
viewModel.startDm(sheet.userId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
val imeInsets = WindowInsets.ime
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val isKeyboardVisible = imeInsets.getBottom(density) > 0
|
||||||
|
|
||||||
|
// Back button: dismiss keyboard first, then close thread, then open channel list
|
||||||
|
BackHandler(enabled = isKeyboardVisible || selectedThread != null || (selectedChannel != null && !showChannelList)) {
|
||||||
|
if (isKeyboardVisible) {
|
||||||
|
keyboardController?.hide()
|
||||||
|
} else if (selectedThread != null) {
|
||||||
|
viewModel.closeThread()
|
||||||
|
} else {
|
||||||
viewModel.toggleChannelList()
|
viewModel.toggleChannelList()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold { padding ->
|
Scaffold { padding ->
|
||||||
Box(
|
Box(
|
||||||
@@ -60,12 +116,13 @@ fun MainScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.pointerInput(showChannelList) {
|
.pointerInput(showChannelList) {
|
||||||
if (!showChannelList) {
|
if (!showChannelList) {
|
||||||
|
val thresholdPx = 60.dp.toPx()
|
||||||
var totalDrag = 0f
|
var totalDrag = 0f
|
||||||
detectHorizontalDragGestures(
|
detectHorizontalDragGestures(
|
||||||
onDragStart = { totalDrag = 0f },
|
onDragStart = { totalDrag = 0f },
|
||||||
onHorizontalDrag = { _, dragAmount ->
|
onHorizontalDrag = { _, dragAmount ->
|
||||||
totalDrag += dragAmount
|
totalDrag += dragAmount
|
||||||
if (totalDrag > 60f) {
|
if (totalDrag > thresholdPx) {
|
||||||
viewModel.toggleChannelList()
|
viewModel.toggleChannelList()
|
||||||
totalDrag = 0f
|
totalDrag = 0f
|
||||||
}
|
}
|
||||||
@@ -96,16 +153,53 @@ fun MainScreen(
|
|||||||
onToggleMemberList = { viewModel.toggleMemberList() },
|
onToggleMemberList = { viewModel.toggleMemberList() },
|
||||||
onSendMessage = { viewModel.sendMessage(it) },
|
onSendMessage = { viewModel.sendMessage(it) },
|
||||||
onSendFiles = { uris, caption -> viewModel.sendFiles(uris, caption) },
|
onSendFiles = { uris, caption -> viewModel.sendFiles(uris, caption) },
|
||||||
|
onSendGif = { url -> viewModel.sendGif(url) },
|
||||||
onLoadMore = { viewModel.loadMoreMessages() },
|
onLoadMore = { viewModel.loadMoreMessages() },
|
||||||
unreadMarkerIndex = unreadMarkerIndex,
|
unreadMarkerIndex = unreadMarkerIndex,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
contentPadding = padding,
|
contentPadding = padding,
|
||||||
|
selectedThread = selectedThread,
|
||||||
|
threadMessages = threadMessages,
|
||||||
|
onCloseThread = { viewModel.closeThread() },
|
||||||
|
onSendThreadMessage = { viewModel.sendThreadMessage(it) },
|
||||||
|
onOpenThread = { eventId ->
|
||||||
|
selectedChannel?.let { viewModel.selectThread(it, eventId) }
|
||||||
|
},
|
||||||
|
threadReplyCounts = remember(roomThreads, selectedChannel) {
|
||||||
|
selectedChannel?.let { roomId ->
|
||||||
|
roomThreads[roomId]?.associate { it.rootEventId to it.replyCount }
|
||||||
|
} ?: emptyMap()
|
||||||
|
},
|
||||||
|
selectedThreadName = remember(selectedThread, selectedChannel, roomThreads) {
|
||||||
|
selectedThread?.let { threadId ->
|
||||||
|
selectedChannel?.let { roomId ->
|
||||||
|
roomThreads[roomId]?.find { it.rootEventId == threadId }?.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
currentUserId = currentUserId,
|
||||||
|
replyingTo = replyingTo,
|
||||||
|
editingMessage = editingMessage,
|
||||||
|
onSetReplyingTo = { viewModel.setReplyingTo(it) },
|
||||||
|
onSetEditingMessage = { viewModel.setEditingMessage(it) },
|
||||||
|
onSendReply = { body, eventId -> viewModel.sendReply(body, eventId) },
|
||||||
|
onSendThreadReply = { body, eventId -> viewModel.sendThreadReply(body, eventId) },
|
||||||
|
onEditMessage = { eventId, body -> viewModel.editMessage(eventId, body) },
|
||||||
|
onEditThreadMessage = { eventId, body -> viewModel.editThreadMessage(eventId, body) },
|
||||||
|
onSendReaction = { eventId, emoji -> viewModel.sendReaction(eventId, emoji) },
|
||||||
|
onSendThreadReaction = { eventId, emoji -> viewModel.sendThreadReaction(eventId, emoji) },
|
||||||
|
emojiPacks = emojiPacks,
|
||||||
|
onStartDm = { userId -> viewModel.startDm(userId) },
|
||||||
|
memberNames = remember(members) { members.associate { it.userId to it.displayName } },
|
||||||
)
|
)
|
||||||
|
|
||||||
AnimatedVisibility(visible = showMemberList) {
|
AnimatedVisibility(visible = showMemberList) {
|
||||||
MemberList(
|
MemberList(
|
||||||
members = members,
|
members = members,
|
||||||
contentPadding = padding,
|
contentPadding = padding,
|
||||||
|
onMemberClick = { member ->
|
||||||
|
profileSheet = ProfileSheetState(member.userId, member.displayName, member.avatarUrl)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +231,7 @@ fun MainScreen(
|
|||||||
sections = channelSections,
|
sections = channelSections,
|
||||||
selectedChannel = selectedChannel,
|
selectedChannel = selectedChannel,
|
||||||
onChannelClick = {
|
onChannelClick = {
|
||||||
|
viewModel.closeThread()
|
||||||
viewModel.selectChannel(it)
|
viewModel.selectChannel(it)
|
||||||
viewModel.toggleChannelList()
|
viewModel.toggleChannelList()
|
||||||
},
|
},
|
||||||
@@ -152,6 +247,21 @@ fun MainScreen(
|
|||||||
onMoveChannel = { from, to -> viewModel.moveChannel(from, to) },
|
onMoveChannel = { from, to -> viewModel.moveChannel(from, to) },
|
||||||
onMoveChannelById = { id, delta -> viewModel.moveChannelById(id, delta) },
|
onMoveChannelById = { id, delta -> viewModel.moveChannelById(id, delta) },
|
||||||
onMoveChildSpace = { from, to -> viewModel.moveChildSpace(from, to) },
|
onMoveChildSpace = { from, to -> viewModel.moveChildSpace(from, to) },
|
||||||
|
roomThreads = roomThreads,
|
||||||
|
expandedThreadRooms = expandedThreadRooms,
|
||||||
|
selectedThread = selectedThread,
|
||||||
|
onToggleRoomThreads = { viewModel.toggleRoomThreads(it) },
|
||||||
|
onThreadClick = { roomId, threadRootEventId ->
|
||||||
|
viewModel.selectChannel(roomId)
|
||||||
|
viewModel.selectThread(roomId, threadRootEventId)
|
||||||
|
viewModel.toggleChannelList()
|
||||||
|
},
|
||||||
|
onRenameThread = { roomId, threadRootEventId, name ->
|
||||||
|
viewModel.renameThread(roomId, threadRootEventId, name)
|
||||||
|
},
|
||||||
|
onRemoveThread = { roomId, threadRootEventId ->
|
||||||
|
viewModel.removeThread(roomId, threadRootEventId)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,10 @@
|
|||||||
package com.example.fluffytrix.ui.screens.main.components
|
package com.example.fluffytrix.ui.screens.main.components
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -36,6 +38,7 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
@@ -58,6 +61,7 @@ import androidx.compose.ui.zIndex
|
|||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import com.example.fluffytrix.ui.screens.main.ChannelItem
|
import com.example.fluffytrix.ui.screens.main.ChannelItem
|
||||||
import com.example.fluffytrix.ui.screens.main.ChannelSection
|
import com.example.fluffytrix.ui.screens.main.ChannelSection
|
||||||
|
import com.example.fluffytrix.ui.screens.main.ThreadItem
|
||||||
import com.example.fluffytrix.ui.screens.main.UnreadStatus
|
import com.example.fluffytrix.ui.screens.main.UnreadStatus
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -76,8 +80,17 @@ fun ChannelList(
|
|||||||
onMoveChannel: (from: Int, to: Int) -> Unit = { _, _ -> },
|
onMoveChannel: (from: Int, to: Int) -> Unit = { _, _ -> },
|
||||||
onMoveChannelById: (channelId: String, delta: Int) -> Unit = { _, _ -> },
|
onMoveChannelById: (channelId: String, delta: Int) -> Unit = { _, _ -> },
|
||||||
onMoveChildSpace: (from: Int, to: Int) -> Unit = { _, _ -> },
|
onMoveChildSpace: (from: Int, to: Int) -> Unit = { _, _ -> },
|
||||||
|
roomThreads: Map<String, List<ThreadItem>> = emptyMap(),
|
||||||
|
expandedThreadRooms: Set<String> = emptySet(),
|
||||||
|
selectedThread: String? = null,
|
||||||
|
onToggleRoomThreads: (String) -> Unit = {},
|
||||||
|
onThreadClick: (roomId: String, threadRootEventId: String) -> Unit = { _, _ -> },
|
||||||
|
onRenameThread: (roomId: String, threadRootEventId: String, name: String) -> Unit = { _, _, _ -> },
|
||||||
|
onRemoveThread: (roomId: String, threadRootEventId: String) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
var showLogoutDialog by remember { mutableStateOf(false) }
|
var showLogoutDialog by remember { mutableStateOf(false) }
|
||||||
|
var threadActionTarget by remember { mutableStateOf<Pair<String, ThreadItem>?>(null) }
|
||||||
|
var threadRenameText by remember { mutableStateOf("") }
|
||||||
val collapsedSections = remember { mutableStateMapOf<String, Boolean>() }
|
val collapsedSections = remember { mutableStateMapOf<String, Boolean>() }
|
||||||
|
|
||||||
// Channel drag state — track by ID, no visual offset (let LazyColumn handle positioning)
|
// Channel drag state — track by ID, no visual offset (let LazyColumn handle positioning)
|
||||||
@@ -106,18 +119,65 @@ fun ChannelList(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
threadActionTarget?.let { (roomId, thread) ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { threadActionTarget = null },
|
||||||
|
title = { Text("Thread") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("Rename this thread:", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
TextField(
|
||||||
|
value = threadRenameText,
|
||||||
|
onValueChange = { threadRenameText = it },
|
||||||
|
placeholder = { Text(thread.name ?: thread.rootBody) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
onRenameThread(roomId, thread.rootEventId, threadRenameText.trim())
|
||||||
|
threadRenameText = ""
|
||||||
|
threadActionTarget = null
|
||||||
|
}) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
Row {
|
||||||
|
TextButton(onClick = {
|
||||||
|
onRemoveThread(roomId, thread.rootEventId)
|
||||||
|
threadRenameText = ""
|
||||||
|
threadActionTarget = null
|
||||||
|
}) {
|
||||||
|
Text("Remove", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
TextButton(onClick = {
|
||||||
|
threadRenameText = ""
|
||||||
|
threadActionTarget = null
|
||||||
|
}) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
|
val thresholdPx = 60.dp.toPx()
|
||||||
var totalDrag = 0f
|
var totalDrag = 0f
|
||||||
detectHorizontalDragGestures(
|
detectHorizontalDragGestures(
|
||||||
onDragStart = { totalDrag = 0f },
|
onDragStart = { totalDrag = 0f },
|
||||||
onHorizontalDrag = { _, dragAmount ->
|
onHorizontalDrag = { _, dragAmount ->
|
||||||
totalDrag += dragAmount
|
totalDrag += dragAmount
|
||||||
if (totalDrag < -60f) {
|
if (totalDrag < -thresholdPx) {
|
||||||
onDismiss()
|
onDismiss()
|
||||||
totalDrag = 0f
|
totalDrag = 0f
|
||||||
}
|
}
|
||||||
@@ -262,7 +322,10 @@ fun ChannelList(
|
|||||||
val isSelected = channel.id == selectedChannel
|
val isSelected = channel.id == selectedChannel
|
||||||
val hasUnread = channel.unreadStatus != UnreadStatus.NONE
|
val hasUnread = channel.unreadStatus != UnreadStatus.NONE
|
||||||
val isDragging = draggingChannelId == channel.id
|
val isDragging = draggingChannelId == channel.id
|
||||||
|
val threads = roomThreads[channel.id] ?: emptyList()
|
||||||
|
val isThreadExpanded = channel.id in expandedThreadRooms
|
||||||
|
|
||||||
|
Column {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -362,6 +425,65 @@ fun ChannelList(
|
|||||||
},
|
},
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
// Thread expand/collapse chevron
|
||||||
|
if (threads.isNotEmpty() && !isReorderMode) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isThreadExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||||
|
contentDescription = if (isThreadExpanded) "Collapse threads" else "Expand threads",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(20.dp)
|
||||||
|
.clickable { onToggleRoomThreads(channel.id) },
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thread items under channel
|
||||||
|
if (isThreadExpanded && threads.isNotEmpty()) {
|
||||||
|
for (thread in threads) {
|
||||||
|
val isThreadSelected = selectedThread == thread.rootEventId
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 24.dp)
|
||||||
|
.clip(RoundedCornerShape(6.dp))
|
||||||
|
.background(
|
||||||
|
if (isThreadSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f)
|
||||||
|
else MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = { onThreadClick(channel.id, thread.rootEventId) },
|
||||||
|
onLongClick = {
|
||||||
|
threadRenameText = thread.name ?: ""
|
||||||
|
threadActionTarget = channel.id to thread
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Tag,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = thread.name ?: thread.rootBody,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "${thread.replyCount}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,3 +492,6 @@ fun ChannelList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.example.fluffytrix.ui.screens.main.components
|
package com.example.fluffytrix.ui.screens.main.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -31,6 +32,7 @@ import com.example.fluffytrix.ui.screens.main.MemberItem
|
|||||||
fun MemberList(
|
fun MemberList(
|
||||||
members: List<MemberItem>,
|
members: List<MemberItem>,
|
||||||
contentPadding: PaddingValues = PaddingValues(),
|
contentPadding: PaddingValues = PaddingValues(),
|
||||||
|
onMemberClick: (MemberItem) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -56,6 +58,7 @@ fun MemberList(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.clickable { onMemberClick(member) }
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -101,8 +101,8 @@ fun SpaceList(
|
|||||||
.size(8.dp)
|
.size(8.dp)
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.background(
|
.background(
|
||||||
if (homeUnreadStatus == UnreadStatus.MENTIONED) androidx.compose.ui.graphics.Color.Red
|
if (homeUnreadStatus == UnreadStatus.MENTIONED) MaterialTheme.colorScheme.error
|
||||||
else androidx.compose.ui.graphics.Color.Gray,
|
else MaterialTheme.colorScheme.primary,
|
||||||
CircleShape,
|
CircleShape,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -147,8 +147,8 @@ fun SpaceList(
|
|||||||
.size(8.dp)
|
.size(8.dp)
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.background(
|
.background(
|
||||||
if (space.unreadStatus == UnreadStatus.MENTIONED) androidx.compose.ui.graphics.Color.Red
|
if (space.unreadStatus == UnreadStatus.MENTIONED) MaterialTheme.colorScheme.error
|
||||||
else androidx.compose.ui.graphics.Color.Gray,
|
else MaterialTheme.colorScheme.primary,
|
||||||
CircleShape,
|
CircleShape,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -20,36 +23,61 @@ import androidx.compose.material3.HorizontalDivider
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import org.unifiedpush.android.connector.UnifiedPush
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.example.fluffytrix.data.local.PreferencesManager
|
import com.example.fluffytrix.data.local.PreferencesManager
|
||||||
|
import com.example.fluffytrix.push.PushRegistrationManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
|
private sealed interface GiphyKeyStatus {
|
||||||
|
data object Idle : GiphyKeyStatus
|
||||||
|
data object Testing : GiphyKeyStatus
|
||||||
|
data object Invalid : GiphyKeyStatus
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
|
onEmojiPackManagement: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val preferencesManager: PreferencesManager = koinInject()
|
val preferencesManager: PreferencesManager = koinInject()
|
||||||
val userId by preferencesManager.userId.collectAsState(initial = null)
|
val pushRegistrationManager: PushRegistrationManager = koinInject()
|
||||||
val homeserver by preferencesManager.homeserverUrl.collectAsState(initial = null)
|
val userId by preferencesManager.userId.collectAsStateWithLifecycle(initialValue = null)
|
||||||
val deviceId by preferencesManager.deviceId.collectAsState(initial = null)
|
val homeserver by preferencesManager.homeserverUrl.collectAsStateWithLifecycle(initialValue = null)
|
||||||
val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsState(initial = false)
|
val deviceId by preferencesManager.deviceId.collectAsStateWithLifecycle(initialValue = null)
|
||||||
|
val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
val notificationsEnabled by preferencesManager.notificationsEnabled.collectAsStateWithLifecycle(initialValue = true)
|
||||||
|
val upEndpoint by preferencesManager.upEndpoint.collectAsStateWithLifecycle(initialValue = null)
|
||||||
|
val savedGiphyKey by preferencesManager.tenorApiKey.collectAsStateWithLifecycle(initialValue = "")
|
||||||
|
var giphyKeyInput by remember { mutableStateOf("") }
|
||||||
|
var giphyKeyStatus by remember { mutableStateOf<GiphyKeyStatus>(GiphyKeyStatus.Idle) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
var upDistributor by remember { mutableStateOf(UnifiedPush.getSavedDistributor(context) ?: "") }
|
||||||
|
var showDistributorPicker by remember { mutableStateOf(false) }
|
||||||
|
var pusherRegStatus by remember { mutableStateOf("") }
|
||||||
val appVersion = try {
|
val appVersion = try {
|
||||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "Unknown"
|
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "Unknown"
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
@@ -109,8 +137,196 @@ fun SettingsScreen(
|
|||||||
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
|
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
|
||||||
|
|
||||||
|
SectionHeader("Customization")
|
||||||
|
SettingNavRow(label = "Emoji Packs", onClick = onEmojiPackManagement)
|
||||||
|
Column(modifier = Modifier.padding(vertical = 6.dp)) {
|
||||||
|
Text("GIPHY API Key (for GIF search)", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Text(
|
||||||
|
"Get a free key at developers.giphy.com → Create App",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
if (savedGiphyKey.isNotBlank()) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Key saved (${savedGiphyKey.take(8)}…)",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { scope.launch { preferencesManager.setTenorApiKey("") } },
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
||||||
|
) {
|
||||||
|
Text("Remove key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = giphyKeyInput,
|
||||||
|
onValueChange = {
|
||||||
|
giphyKeyInput = it
|
||||||
|
giphyKeyStatus = GiphyKeyStatus.Idle
|
||||||
|
},
|
||||||
|
placeholder = { Text("Paste API key…") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val key = giphyKeyInput.trim()
|
||||||
|
if (key.isBlank()) return@Button
|
||||||
|
giphyKeyStatus = GiphyKeyStatus.Testing
|
||||||
|
scope.launch {
|
||||||
|
val valid = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val url = "https://api.giphy.com/v1/gifs/trending?api_key=$key&limit=1"
|
||||||
|
okhttp3.OkHttpClient().newCall(okhttp3.Request.Builder().url(url).build())
|
||||||
|
.execute()
|
||||||
|
.use { it.isSuccessful }
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
}
|
||||||
|
if (valid) {
|
||||||
|
preferencesManager.setTenorApiKey(key)
|
||||||
|
giphyKeyInput = ""
|
||||||
|
giphyKeyStatus = GiphyKeyStatus.Idle
|
||||||
|
} else {
|
||||||
|
giphyKeyStatus = GiphyKeyStatus.Invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = giphyKeyInput.isNotBlank() && giphyKeyStatus != GiphyKeyStatus.Testing,
|
||||||
|
) {
|
||||||
|
Text(if (giphyKeyStatus == GiphyKeyStatus.Testing) "Testing…" else "Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (giphyKeyStatus) {
|
||||||
|
GiphyKeyStatus.Invalid -> Text(
|
||||||
|
"Invalid API key — check and try again.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
|
||||||
|
|
||||||
SectionHeader("Notifications")
|
SectionHeader("Notifications")
|
||||||
SettingRow("Push notifications", "Enabled")
|
SettingToggleRow(
|
||||||
|
label = "Enable notifications",
|
||||||
|
description = "Show push notifications for new messages",
|
||||||
|
checked = notificationsEnabled,
|
||||||
|
onCheckedChange = { scope.launch { preferencesManager.setNotificationsEnabled(it) } },
|
||||||
|
)
|
||||||
|
SettingRow(
|
||||||
|
label = "Push distributor",
|
||||||
|
value = upDistributor.ifBlank { "None selected" },
|
||||||
|
)
|
||||||
|
SettingRow(
|
||||||
|
label = "Endpoint",
|
||||||
|
value = upEndpoint?.let { if (it.length > 50) it.take(50) + "…" else it } ?: "Not registered",
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { showDistributorPicker = true },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(if (upDistributor.isBlank()) "Select distributor" else "Change distributor")
|
||||||
|
}
|
||||||
|
if (upEndpoint != null) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
pusherRegStatus = "Registering…"
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
pushRegistrationManager.registerPusher(upEndpoint!!)
|
||||||
|
}
|
||||||
|
pusherRegStatus = "Pusher registered!"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
pusherRegStatus = "Failed: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Re-register pusher with homeserver")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pusherRegStatus.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
pusherRegStatus,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (pusherRegStatus.startsWith("Failed") || pusherRegStatus.startsWith("Pusher reg failed"))
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDistributorPicker) {
|
||||||
|
val distributors = remember(showDistributorPicker) { UnifiedPush.getDistributors(context) }
|
||||||
|
if (distributors.isEmpty()) {
|
||||||
|
androidx.compose.material3.AlertDialog(
|
||||||
|
onDismissRequest = { showDistributorPicker = false },
|
||||||
|
title = { Text("No distributor found") },
|
||||||
|
text = { Text("Install a UnifiedPush distributor app (e.g. ntfy) to enable push notifications.") },
|
||||||
|
confirmButton = {
|
||||||
|
androidx.compose.material3.TextButton(onClick = { showDistributorPicker = false }) {
|
||||||
|
Text("OK")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
androidx.compose.material3.AlertDialog(
|
||||||
|
onDismissRequest = { showDistributorPicker = false },
|
||||||
|
title = { Text("Select distributor") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
distributors.forEach { pkg ->
|
||||||
|
val label = try {
|
||||||
|
context.packageManager.getApplicationLabel(
|
||||||
|
context.packageManager.getApplicationInfo(pkg, 0)
|
||||||
|
).toString()
|
||||||
|
} catch (_: Exception) { pkg }
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
|
.clickable {
|
||||||
|
UnifiedPush.saveDistributor(context, pkg)
|
||||||
|
UnifiedPush.register(context)
|
||||||
|
upDistributor = pkg
|
||||||
|
showDistributorPicker = false
|
||||||
|
}
|
||||||
|
.padding(horizontal = 4.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = {
|
||||||
|
androidx.compose.material3.TextButton(onClick = { showDistributorPicker = false }) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
|
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
|
||||||
|
|
||||||
@@ -143,6 +359,7 @@ private fun SettingToggleRow(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
.padding(vertical = 6.dp),
|
.padding(vertical = 6.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -166,10 +383,34 @@ private fun SettingRow(label: String, value: String) {
|
|||||||
text = label,
|
text = label,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
|
androidx.compose.foundation.text.selection.SelectionContainer {
|
||||||
Text(
|
Text(
|
||||||
text = value,
|
text = value,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingNavRow(label: String, onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(vertical = 14.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(text = label, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
app/src/main/res/drawable/ic_notification.xml
Normal file
10
app/src/main/res/drawable/ic_notification.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z" />
|
||||||
|
</vector>
|
||||||
4
app/src/main/res/xml/file_provider_paths.xml
Normal file
4
app/src/main/res/xml/file_provider_paths.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path name="cache" path="." />
|
||||||
|
</paths>
|
||||||
@@ -19,7 +19,9 @@ matrixRustSdk = "26.02.19"
|
|||||||
coil = "3.2.0"
|
coil = "3.2.0"
|
||||||
media3 = "1.6.0"
|
media3 = "1.6.0"
|
||||||
markdownRenderer = "0.37.0"
|
markdownRenderer = "0.37.0"
|
||||||
|
emojiPicker = "1.6.0"
|
||||||
kotlinxSerialization = "1.8.1"
|
kotlinxSerialization = "1.8.1"
|
||||||
|
unifiedpush = "3.3.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -43,6 +45,7 @@ activity-compose = { group = "androidx.activity", name = "activity-compose", ver
|
|||||||
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewModel" }
|
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewModel" }
|
||||||
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleViewModel" }
|
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleViewModel" }
|
||||||
|
lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleViewModel" }
|
||||||
|
|
||||||
# Koin
|
# Koin
|
||||||
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
|
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
|
||||||
@@ -77,6 +80,12 @@ markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-
|
|||||||
markdown-renderer-code = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-code-android", version.ref = "markdownRenderer" }
|
markdown-renderer-code = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-code-android", version.ref = "markdownRenderer" }
|
||||||
markdown-renderer-coil3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-coil3-android", version.ref = "markdownRenderer" }
|
markdown-renderer-coil3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-coil3-android", version.ref = "markdownRenderer" }
|
||||||
|
|
||||||
|
# Jetpack Emoji Picker
|
||||||
|
emoji-picker = { group = "androidx.emoji2", name = "emoji2-emojipicker", version.ref = "emojiPicker" }
|
||||||
|
|
||||||
|
# UnifiedPush
|
||||||
|
unifiedpush = { module = "org.unifiedpush.android:connector", version.ref = "unifiedpush" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
|||||||
Reference in New Issue
Block a user